From bb4cec3efd616780cad45889b180d9853fa7037b Mon Sep 17 00:00:00 2001 From: millennIumAMbiguity <37588844+millennIumAMbiguity@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:28:51 +0200 Subject: [PATCH 1/2] Add Saitek Pro Flight Multi Panel and War Thunder Telemetry --- catalogue.json | 18 ++++++++++++++++++ ...b.millenniumambiguity.saitekmultipanel.png | Bin 0 -> 1937 bytes ....github.millenniumambiguity.warthunder.png | Bin 0 -> 2937 bytes 3 files changed, 18 insertions(+) create mode 100644 icons/io.github.millenniumambiguity.saitekmultipanel.png create mode 100644 icons/io.github.millenniumambiguity.warthunder.png diff --git a/catalogue.json b/catalogue.json index 8267bad..5348ad5 100644 --- a/catalogue.json +++ b/catalogue.json @@ -128,6 +128,15 @@ "repository": "https://github.com/mdvictor/opendeck-volume-controller", "description": "A per-application volume control plugin for the Stream Deck using OpenDeck on Linux." }, + "io.github.millenniumambiguity.warthunder": { + "name": "War Thunder Telemetry", + "author": "millennIumAMbiguity", + "repository": "https://github.com/millennIumAMbiguity/opendeck-warthunder", + "description": "Live War Thunder flight data on Stream Deck keys and the Saitek Multi Panel, for OpenDeck", + "flags": { + "ai_assisted": true + } + }, "fr.jourdois.pipewire": { "name": "PipeWire Audio (Native)", "author": "Stephane Jourdois", @@ -209,6 +218,15 @@ "repository": "https://github.com/MikaBust/opendeck-duo87", "description": "An unofficial plugin for the Womier DUO87 keyboard's detachable screen module." }, + "io.github.millenniumambiguity.saitekmultipanel": { + "name": "Saitek Pro Flight Multi Panel", + "author": "millennIumAMbiguity", + "repository": "https://github.com/millennIumAMbiguity/opendeck-saitek-multipanel", + "description": "OpenDeck device plugin for the Logitech/Saitek Pro Flight Multi Panel", + "flags": { + "ai_assisted": true + } + }, "com.monkeykiller.plugins.opendeck-ss550": { "name": "Redragon Streamcraft SS550", "author": "MonkeyKiller", diff --git a/icons/io.github.millenniumambiguity.saitekmultipanel.png b/icons/io.github.millenniumambiguity.saitekmultipanel.png new file mode 100644 index 0000000000000000000000000000000000000000..52b8d35827c8b07f2c9ddcafb4b350b5b9b799e2 GIT binary patch literal 1937 zcmdT^YfzF|7(U+zt%?bnMy7^#B@1ue6mO{2n$}vGo0X1QZMQX%5)-rHYfYUpwXN*p z1>J4CCwXbILTN4TZY@jeOMj2B5jRj4H1SNz920(A0l9+~#32oi>1OTuNUjoWqinTQy zhHTmpw$|F(4uiqyy%+D;5wl6W!@|OH=1l7qN4>~oUq?qL9L{1<5Xs*^(9Lb7z1#et zhAkwL+kyp-^X3r=gn4+pjqL&l*EsiutC!di2$q)kUJ~~s0N;s&*7%1p3Wnakz0|SB zDt)uq97fk!-I!o*#xI>ks$BT`Hvf!7-S0hfj7O>AC?;G`PvFm4MY$GH%C#7 zr0zpn2jn1ZOhjn^EzE_gwMxE}F&s!sZw3GXcV}1%>a`TZV(#BU^ zcOIF9rc;9tglC9$57=geSJhoJa;tnHj%t}WjEB@4+i1OnvXG8SJdm_nAM2_hyA1yr2d^px5Q>2;> zCCgPmqiGdoK3<{fokJs>=3zHl@f3Q39 z=OTUNA<1*ChYE^}I{rz3E0$;P^?)B$4>Cn|+Lq{fNMX`=T0#y?pdjnt;C{Gn#Iol# z3%#cL0#J{}&p~vSY+SlwyrpD5a;-TQKc=y+MD2G914T~QuE~$*iJdIwPf`M9O%QXe8R4O-z&->1Yyc^u2;q!yZ9aX@6Y!GWbo zHR^NS{KOu(-%I#z?qFmbWmih6LQ+wV+ArPgnbw+_OPr2lU%>nAq~t?(I$rI>#a50@ zv__iz<8AplN&ddIig)O}qMwZY5EDbfIs=0YT6!A=f9_jyIfT8_m(Y=qv{8v+)AH$@s=u z%xzMqN$bYEu^BK?2_+HfzCUK%UV7#F)Er=MhFkHl>wLZ7C&mc47^vxhz`LRer? zdkU9NhHn|UR`hrYTP^%#E{Pe~UG&Mpmt1duAd3BDEb`Qd(B>ha`~L&Aen46)JEToG z@wIVl(EaY`*!LZ)KK_2<0<~zeNkx literal 0 HcmV?d00001 diff --git a/icons/io.github.millenniumambiguity.warthunder.png b/icons/io.github.millenniumambiguity.warthunder.png new file mode 100644 index 0000000000000000000000000000000000000000..afc84a978696f4fbaacfaa780de714fe0ce9cae9 GIT binary patch literal 2937 zcma)8YdF+-8~zV+dYwBVhl$2nIh1lP5y?h)om;a=8-++oYM2g`QoEfbrE)CRx#C45 zghbeiQbJa=!dNUjnwfo`|6cEx`LuIg=6UY>Io$W}x#okjZaW>NC6y%!f{@Esm-Zc?l$VD=^9xF*ZkCL`j#LhOSao@ z@r=uy=uh?2Y>{yoS}!UkMQ@H_(T?)-&nLTR4Mx8+tp4AB>mKs7$4q;UlzX;~T(kw7 z|MHWl`@xYx1nqXfFM9@Iw_8R%+m`Z^S>ybe>*kyZwjx&|rBXR1@pVt5$E-HBYsAPc z>BfP>!kd?9J?fVxmZ!fB+emoQgU0&_x1^)o{hRfS$~tBs-*+HOEK$-We9j8We&eH* zJ33GRetZPk{8g7E zUG~ic5ewQPOZ%QE0(bXKDkW|u*EoHE^Njdl&yWh8FQ+6AeK$KwkGSa#HuEZuZvY1t zh5vvP2+T@w(cTJlZt}hkdR1V6O7$? zG7Pmg%a4%O3ZxG#-vC?X0S)K=pKg*GA9HQGvU9^=vCz6~mi#O8e(0%uCA~>%D%(~8 zVI$j|qiKBW6^uDsVzt~DQpr`XAT1S0e^>4Ww&);w5KJGGp|J9MXeba!!;}Yr&6!3C z!Bk@h+=Xd?lI82E%XM0Rg;pw(@%vGx3=W{vf=^fR2QnJT2W4hJHunLk7!G7m5s>GQ zEO>^OS#}Ia_pd-Ym%)K`NK(NSNINSaC8trS2_Gnjq(uUdZEO^3#P>-B0;#u_u}N(3 z^VZq?;e>hF$XhIagpkd^Hipg1n8mnZ-d|bV4!u%(WG!Qk@#RzJ7+;B9z1TB2%HP+) z*p-m1JN|DclYAyVm=SY;G6j8(yKVO9-;w83f=d|8>!E(xZw{XNmeg27rOzuMHL^4??w z(Tu8V{lc(t{xCoGIlj}VEx9FsEHKQXNF+buwK%E-Pd>5d+nCa61@fe-@UljM*Wswnhs4_Z5m!e4Gj0X5|PJ+&-goT z#rMuQm#+Rc+BNRX{-VKg)w-bO%`8;!X6W5N^hn`Dqy)b@wo)NsA(buL-Qv7?LF`;0 zET)&~Q-%hTs$k=keJjxOA%9~2G86gSYS3W$#}t8)-n{RjfAYI3F!G|S`|6#gldP@A zoW|}mmj3r_RSv|74O$?Y&vFYQ>;Y*2Y8Dp0bn^(|)vcapR;-x#p;nkJ#~u1{IE~WU zVM%x+9ifD@9B)1#TV9lEK1$>)PF1x=e>|x~EPqK!;q9;dR4eAlF!P(1CVJ^gPmUQ0 zQyVuD0Vnq?Og@!Llo}wSq^}Lth?x>53BvUoe9la%wG*vuf#z5v;(4`bVtA=cuXQ@5 zYe+e9?BX`d;$u;>j@hc6uwuohYFJ@sg)34TY%m( zAJkJ6Y?cPg2f=coFNO zymFa9^Bh{nE33Ui%fWbMhhuqQx#gbq3$z@9S7sXEic2GS<#N38mTRcjY65yod{K`o z*enm0f50o7;FTTDfMrjYAHG>HXmLD@lS+p9vUH!_+Zcv^i~AS2$wTjt&(7bw|6*O< z07J1y{hqCobU(|K$7_t@8gsI{+b3jarfj8LZ;o|t(~SIRaf08!maPjv*QPAj;sM6! z@!9Xick$Q*rm6I@8D}b!-?IU4e-uDP+LpUU~{p>?lgYrnY zGSr`h^kx!P`S#)qo_;20>@yfBE2kYq23jZ?H1?B$8im2ArV9t_+nMBG#W~Vhdww#> z25?BR_U!8 Date: Tue, 6 Oct 2026 17:29:12 +0100 Subject: [PATCH 2/2] ci: add GitHub Actions workflow to validate plugin submissions --- .../workflows/validate-plugin-submission.yml | 31 +++ README.md | 2 + validate_submission.py | 259 ++++++++++++++++++ 3 files changed, 292 insertions(+) create mode 100644 .github/workflows/validate-plugin-submission.yml create mode 100644 validate_submission.py diff --git a/.github/workflows/validate-plugin-submission.yml b/.github/workflows/validate-plugin-submission.yml new file mode 100644 index 0000000..05179d1 --- /dev/null +++ b/.github/workflows/validate-plugin-submission.yml @@ -0,0 +1,31 @@ +name: Validate plugin submission + +on: + pull_request: + paths: + - "catalogue.json" + - "icons/**" + - "validate_submission.py" + +permissions: + contents: read + +jobs: + validate: + name: Validate plugin submission + runs-on: ubuntu-latest + steps: + - name: Check out pull request + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Validate catalogue submission + env: + GITHUB_TOKEN: ${{ github.token }} + run: python validate_submission.py --base "${{ github.event.pull_request.base.sha }}" --head "${{ github.event.pull_request.head.sha }}" diff --git a/README.md b/README.md index a4b9705..e18471f 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,5 @@ To submit your plugin to the OpenAction Marketplace, please follow these steps: 4. **Add an Icon**: Add a high-resolution icon representing your plugin to the `icons/` directory. The icon should match the icon provided in your plugin's manifest / bundle. The file should be named matching your plugin's bundle ID (e.g. `com.yourname.plugin.png`). *Note: You do not need to run the `format_icons.py` script yourself; a maintainer will run it in a standardised environment to format your icon when reviewing your submission.* Once you have added your entry to the appropriate section, submit a Pull Request to this repository for review. + +Pull requests that change the catalogue are checked automatically. The check verifies the repository topic, manifest metadata, repository description, bundle ID, latest-release artifacts, icon format and resolution, section placement, and repository URL ordering before maintainers review the submission. diff --git a/validate_submission.py b/validate_submission.py new file mode 100644 index 0000000..a856da2 --- /dev/null +++ b/validate_submission.py @@ -0,0 +1,259 @@ +"""Validate plugin catalogue submissions in a pull request.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +import struct +import subprocess +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlparse +from urllib.request import Request, urlopen + +CATALOGUE = Path("catalogue.json") +ICONS = Path("icons") + +SECTION_NAMES = ("official", "native", "device", "stream-deck") +ALLOWED_SECTIONS = {"native", "device"} + + +class ValidationError(Exception): + pass + + +def load_catalogue(path: Path) -> dict[str, dict]: + try: + with path.open(encoding="utf-8") as file: + return json.load(file, object_pairs_hook=_unique_object) + except (OSError, json.JSONDecodeError) as exc: + raise ValidationError(f"{path} is not valid JSON: {exc}") from exc + + +def load_catalogue_text(contents: str) -> dict[str, dict]: + try: + return json.loads(contents, object_pairs_hook=_unique_object) + except json.JSONDecodeError as exc: + raise ValidationError(f"catalogue is not valid JSON: {exc}") from exc + + +def validate_catalogue_format(path: Path, catalogue: dict[str, dict]) -> None: + try: + contents = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValidationError(f"could not read {path}: {exc}") from exc + + for line_number, line in enumerate(contents.splitlines(), start=1): + if line.rstrip() != line: + raise ValidationError(f"{path}:{line_number}: trailing whitespace is not allowed") + if line and line[0] == " ": + raise ValidationError(f"{path}:{line_number}: indentation must use tabs") + if not contents.endswith("\n"): + raise ValidationError(f"{path}: file must end with a newline") + + expected = json.dumps(catalogue, indent="\t", ensure_ascii=False) + "\n" + if contents != expected: + raise ValidationError( + f"{path}: formatting must match tab-indented JSON generated by json.dumps" + ) + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValidationError(f"duplicate catalogue key: {key}") + result[key] = value + return result + + +def changed_files(base: str, head: str) -> set[str]: + output = subprocess.check_output( + ["git", "diff", "--name-only", f"{base}...{head}", "--"], + text=True, + ) + return {line for line in output.splitlines() if line} + + +def section_for(plugin_id: str, catalogue: dict[str, dict]) -> str: + ids = list(catalogue) + try: + index = ids.index(plugin_id) + except ValueError as exc: + raise ValidationError(f"{plugin_id} is not present in the catalogue") from exc + + section_starts = catalogue_section_starts(catalogue) + for position, start in enumerate(section_starts): + end = section_starts[position + 1] if position + 1 < len(section_starts) else len(ids) + if start <= index < end: + return SECTION_NAMES[position] + raise ValidationError(f"could not determine the catalogue section for {plugin_id}") + + +def catalogue_section_starts(catalogue: dict[str, dict]) -> list[int]: + """Find section starts from the URL sort resets in the ordered catalogue.""" + ids = list(catalogue) + repositories = [catalogue[plugin_id].get("repository", "").casefold() for plugin_id in ids] + resets = [ + index for index in range(1, len(ids)) if repositories[index] < repositories[index - 1] + ] + if len(resets) < len(SECTION_NAMES) - 1: + raise ValidationError("could not determine all catalogue sections from repository ordering") + return [0, *resets[: len(SECTION_NAMES) - 1]] + + +def validate_order(catalogue: dict[str, dict], relevant_ids: set[str]) -> None: + ids = list(catalogue) + for plugin_id in relevant_ids: + section = section_for(plugin_id, catalogue) + if section not in ALLOWED_SECTIONS: + continue + index = ids.index(plugin_id) + repository = catalogue[plugin_id].get("repository", "") + if ( + index + and catalogue[ids[index - 1]].get("repository", "").casefold() > repository.casefold() + ): + raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL") + if ( + index + 1 < len(ids) + and repository.casefold() > catalogue[ids[index + 1]].get("repository", "").casefold() + ): + raise ValidationError(f"{plugin_id}: plugin is not sorted by repository URL") + + +def github_json(path: str) -> dict: + url = f"https://api.github.com{path}" + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = "Bearer " + token + try: + with urlopen(Request(url, headers=headers), timeout=20) as response: + return json.load(response) + except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc: + raise ValidationError(f"GitHub API request failed for {path}: {exc}") from exc + + +def repository_parts(url: str) -> tuple[str, str]: + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.netloc.lower() != "github.com": + raise ValidationError(f"repository must be an HTTPS GitHub URL: {url}") + parts = parsed.path.strip("/").split("/") + if len(parts) != 2 or not all(parts): + raise ValidationError(f"repository URL must identify an owner and repository: {url}") + return parts[0], parts[1].removesuffix(".git") + + +def validate_repository(plugin_id: str, entry: dict, changed: set[str]) -> None: + for field in ("name", "author", "repository", "description"): + if field not in entry: + raise ValidationError(f"{plugin_id}: missing catalogue field '{field}'") + + owner, repo = repository_parts(entry["repository"]) + repo_info = github_json(f"/repos/{owner}/{repo}") + release = github_json(f"/repos/{owner}/{repo}/releases/latest") + if not any( + asset.get("name", "").lower().endswith((".zip", ".streamdeckplugin")) + for asset in release.get("assets", []) + ): + raise ValidationError( + f"{plugin_id}: latest release must include a .zip or .streamDeckPlugin artifact" + ) + if "openaction" not in github_json(f"/repos/{owner}/{repo}/topics").get("names", []): + raise ValidationError(f"{plugin_id}: repository is missing the openaction topic") + if entry["description"] != repo_info.get("description"): + raise ValidationError( + f"{plugin_id}: description does not match the repository sidebar description" + ) + + tree = github_json( + f"/repos/{owner}/{repo}/git/trees/{repo_info['default_branch']}?recursive=1" + ).get("tree", []) + manifest_paths = [ + item["path"] + for item in tree + if item.get("type") == "blob" and item["path"].endswith("manifest.json") + ] + if not manifest_paths: + raise ValidationError(f"{plugin_id}: could not find a manifest.json in the repository") + manifest_path = ( + "assets/manifest.json" if "assets/manifest.json" in manifest_paths else manifest_paths[0] + ) + manifest_response = github_json(f"/repos/{owner}/{repo}/contents/{manifest_path}") + try: + manifest = json.loads(base64.b64decode(manifest_response["content"]).decode("utf-8")) + except (KeyError, ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValidationError(f"{plugin_id}: manifest.json is not valid JSON") from exc + if manifest.get("Name") != entry["name"] or manifest.get("Author") != entry["author"]: + raise ValidationError(f"{plugin_id}: name and author must match manifest.json") + action_ids = [action.get("UUID", "") for action in manifest.get("Actions", [])] + if action_ids and not any(action_id.startswith(f"{plugin_id}.") for action_id in action_ids): + raise ValidationError(f"{plugin_id}: catalogue key is not the manifest bundle ID") + + icon_path = ICONS / f"{plugin_id}.png" + if not icon_path.is_file(): + raise ValidationError(f"{plugin_id}: missing icon {icon_path}") + if plugin_id not in {path.removeprefix("icons/").removesuffix(".png") for path in changed}: + raise ValidationError(f"{plugin_id}: submission must add or update its icon") + try: + with icon_path.open("rb") as icon: + if icon.read(8) != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + length = struct.unpack(">I", icon.read(4))[0] + if icon.read(4) != b"IHDR" or length < 8: + raise ValueError("missing PNG dimensions") + width, height = struct.unpack(">II", icon.read(8)) + except (OSError, ValueError, struct.error) as exc: + raise ValidationError(f"{plugin_id}: icon is not a valid PNG") from exc + if width < 144 or height < 144: + raise ValidationError(f"{plugin_id}: icon must be at least 144x144 pixels") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base", required=True) + parser.add_argument("--head", default="HEAD") + args = parser.parse_args() + try: + base_catalogue = load_catalogue_text( + subprocess.check_output(["git", "show", f"{args.base}:catalogue.json"], text=True) + ) + head_catalogue = load_catalogue(CATALOGUE) + validate_catalogue_format(CATALOGUE, head_catalogue) + changed = changed_files(args.base, args.head) + changed_ids = { + plugin_id + for plugin_id in set(base_catalogue) | set(head_catalogue) + if base_catalogue.get(plugin_id) != head_catalogue.get(plugin_id) + } + if not changed_ids: + print("No catalogue entries changed; plugin submission checks skipped.") + return 0 + validate_order(head_catalogue, changed_ids) + list(head_catalogue) + for plugin_id in changed_ids: + if plugin_id not in head_catalogue: + raise ValidationError( + f"{plugin_id}: catalogue entries may not be removed in a submission PR" + ) + section = section_for(plugin_id, head_catalogue) + if section not in ALLOWED_SECTIONS: + raise ValidationError( + f"{plugin_id}: submissions are only allowed in Native or Device sections" + ) + validate_repository(plugin_id, head_catalogue[plugin_id], changed) + print( + f"Validated {len(changed_ids)} plugin catalogue entr{'y' if len(changed_ids) == 1 else 'ies'}." + ) + return 0 + except (ValidationError, subprocess.CalledProcessError) as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())