From 658657138be332b6eea73fde5a4670e20c117dbc Mon Sep 17 00:00:00 2001 From: Lemmy Date: Sun, 12 Jul 2026 22:19:07 -0400 Subject: [PATCH] feat: open the repo for community plugin submissions --- .github/ISSUE_TEMPLATE/bug_report.yml | 126 ++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 101 +++ .github/PULL_REQUEST_TEMPLATE.md | 50 ++ .github/workflows/update-catalog.py | 134 ++++ .github/workflows/update-catalog.yml | 49 ++ .github/workflows/validate-plugins.py | 751 +++++++++++++++++++++ .github/workflows/validate-plugins.yml | 25 + .gitignore | 4 + .luaurc | 7 + .vscode/settings.json | 8 + README.md | 134 +++- 12 files changed, 1395 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/update-catalog.py create mode 100644 .github/workflows/update-catalog.yml create mode 100644 .github/workflows/validate-plugins.py create mode 100644 .github/workflows/validate-plugins.yml create mode 100644 .gitignore create mode 100644 .luaurc create mode 100644 .vscode/settings.json diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..322bced --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,126 @@ +name: Bug Report +description: Report a bug in a community plugin +title: "[BUG] " +labels: ["bug"] + +body: + - type: checkboxes + id: submission-checklist + attributes: + label: Submission checklist + description: Please confirm the following before submitting. + options: + - label: I have searched existing issues and confirmed this is not a duplicate. + required: true + - label: I am using the latest available version of Noctalia and of the plugin. + required: true + - label: This is a bug in a community plugin, not in Noctalia itself (those belong in the noctalia-shell repo). + required: true + + - type: input + id: plugin-id + attributes: + label: Plugin + description: The plugin id, as it appears in its plugin.toml. + placeholder: "author/plugin" + validations: + required: true + + - type: input + id: plugin-version + attributes: + label: Plugin version + description: The version shown in the plugin's plugin.toml. + placeholder: "1.0.0" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Bug description + description: A clear and concise description of the issue. + placeholder: Describe the problem... + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Steps required to reproduce the issue. + placeholder: | + 1. Enable ... + 2. Click ... + 3. Observe ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs / error output + description: | + Paste any relevant logs here. Plugin errors are logged by the shell. + + Large outputs can be wrapped in a `
` block. + render: text + + - type: input + id: noctalia-version + attributes: + label: Noctalia version + description: Enter the version shown by Noctalia, or the commit hash if you are running a development build. + placeholder: "v5.x.x (commit hash)" + validations: + required: true + + - type: dropdown + id: compositor + attributes: + label: Compositor + description: Select the compositor where the issue occurs. + options: + - Niri + - Hyprland + - Sway + - Scroll + - Labwc + - Mango + - Other + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment information + description: | + Anything else about your system that matters: distribution, installation method, and any external + command the plugin depends on (and its version). + + If you selected `Other` for compositor, please specify it here. + render: text + + - type: textarea + id: additional + attributes: + label: Additional context + description: | + Add any other context, screenshots, or relevant information here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ba5e52a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Noctalia Discord + url: https://discord.noctalia.dev + about: Join the community for support, discussions, and quick help. + - name: Noctalia Docs + url: https://docs.noctalia.dev + about: Browse the documentation, guides, and frequently asked questions. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..a935955 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,101 @@ +name: Feature Request +description: Suggest an improvement to a community plugin +title: "[FEATURE] " +labels: ["feature"] + +body: + - type: checkboxes + id: submission-checklist + attributes: + label: Submission checklist + description: Please confirm the following before submitting. + options: + - label: I have searched existing issues and confirmed this has not been requested before. + required: true + - label: I have checked existing pull requests for similar changes. + required: true + - label: This is about a community plugin, not about Noctalia itself (those belong in the noctalia-shell repo). + required: true + + - type: input + id: plugin-id + attributes: + label: Plugin + description: The plugin id this request is about, as it appears in its plugin.toml. + placeholder: "author/plugin" + validations: + required: true + + - type: dropdown + id: feature-type + attributes: + label: Feature type + description: What kind of feature or improvement is this? + options: + - UI / visual improvement + - New functionality + - Performance improvement + - Configuration / customization + - Accessibility improvement + - Integration support + - Documentation improvement + - Other + validations: + required: true + + - type: textarea + id: summary + attributes: + label: Feature summary + description: A concise description of the feature or enhancement. + placeholder: What would you like to see added or changed? + validations: + required: true + + - type: textarea + id: motivation + attributes: + label: Motivation / use case + description: | + Why would this feature be useful? + What problem does it solve or improve? + placeholder: Explain the benefit or real-world use case... + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed solution + description: | + Describe how you think this could work. + + Mockups, examples, screenshots, or references are welcome. + placeholder: Describe your idea... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: | + Have you considered any alternative solutions or workarounds? + placeholder: Optional... + + - type: textarea + id: references + attributes: + label: References / related projects + description: | + Link any related projects, concepts, screenshots, issues, or examples here. + placeholder: | + https://github.com/... + https://example.com/... + + - type: textarea + id: additional + attributes: + label: Additional context + description: | + Add any additional information, screenshots, mockups, or context here. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..055e8f2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,50 @@ + + +## Plugin + + + +- **Id:** `/` +- [ ] New plugin +- [ ] Update to an existing plugin (version bumped in `plugin.toml`) + +## What it does + + + +## External dependencies + + + +## Testing + + + +- [ ] Tested on Niri +- [ ] Tested on Hyprland +- [ ] Tested on Sway +- [ ] Tested on another compositor: +- **Noctalia version tested against:** + +## Screenshots / Videos + + + +## Checklist + +- [ ] The directory name matches the part of `id` after the `/` in `plugin.toml` exactly. +- [ ] It ships `plugin.toml`, `README.md`, `thumbnail.webp`, and `translations/en.json`. +- [ ] `version` follows semver and is bumped in this PR; `min_noctalia` is the version I tested against. +- [ ] I only added or edited `translations/en.json` (no machine-translated locales). +- [ ] I did not edit `catalog.toml`; CI generates it. +- [ ] This PR touches exactly one plugin directory. + +## Code review attestation + +Plugins run as trusted, unsandboxed Luau in the user's session. Confirm: + +- [ ] The code is readable and not obfuscated, minified, or generated. +- [ ] It does not download and execute remote code. +- [ ] Every network call, filesystem write, and spawned process is something the description above accounts for. +- [ ] I have the right to publish this code under the `license` declared in `plugin.toml`. diff --git a/.github/workflows/update-catalog.py b/.github/workflows/update-catalog.py new file mode 100644 index 0000000..a7e5640 --- /dev/null +++ b/.github/workflows/update-catalog.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parents[2] +CATALOG_PATH = ROOT_DIR / "catalog.toml" +REQUIRED_FIELDS = ("id", "name", "version", "author", "min_noctalia", "tags") +OPTIONAL_STRING_FIELDS = ("license", "icon", "description") +OPTIONAL_BOOL_FIELDS = ("deprecated",) + + +def load_plugin_manifest(path: Path) -> dict: + with path.open("rb") as handle: + manifest = tomllib.load(handle) + + missing = [field for field in REQUIRED_FIELDS if field not in manifest] + if missing: + missing_fields = ", ".join(missing) + raise ValueError(f"{path.relative_to(ROOT_DIR)} is missing: {missing_fields}") + + if not isinstance(manifest["tags"], list) or not all( + isinstance(tag, str) for tag in manifest["tags"] + ): + raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid tags; expected strings") + + out = {field: manifest[field] for field in REQUIRED_FIELDS} + for field in OPTIONAL_STRING_FIELDS: + if field in manifest: + if not isinstance(manifest[field], str): + raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid {field}; expected string") + out[field] = manifest[field] + for field in OPTIONAL_BOOL_FIELDS: + if field in manifest: + if not isinstance(manifest[field], bool): + raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid {field}; expected bool") + out[field] = manifest[field] + return out + + +def existing_catalog_order() -> dict[str, int]: + if not CATALOG_PATH.exists(): + return {} + + content = CATALOG_PATH.read_text(encoding="utf-8") + ids = re.findall(r'(?m)^id\s*=\s*"([^"]+)"', content) + return {plugin_id: index for index, plugin_id in enumerate(ids)} + + +def discover_plugins() -> list[dict]: + order = existing_catalog_order() + plugins = [] + + for manifest_path in sorted(ROOT_DIR.glob("*/plugin.toml")): + manifest = load_plugin_manifest(manifest_path) + directory = manifest_path.parent.name + manifest["_directory"] = directory + manifest["_order"] = order.get(manifest["id"], len(order)) + plugins.append(manifest) + + plugins.sort(key=lambda plugin: (plugin["_order"], plugin["_directory"])) + return plugins + + +def toml_string(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def toml_bool(value: bool) -> str: + return "true" if value else "false" + + +def render_catalog(plugins: list[dict]) -> str: + lines = [ + "# This file is auto-generated. Do not edit manually.", + "# Do not include it in your commit.", + "# Community Noctalia plugins catalog.", + "# Index of every plugin this source ships: the minimum needed to render, search,", + "# and compat-check the list. The per-plugin plugin.toml stays authoritative; the", + "# host re-reads it on enable. Keep one [[plugin]] row per plugin subdirectory.", + "", + ] + + for index, plugin in enumerate(plugins): + if index: + lines.append("") + + lines.extend( + [ + "[[plugin]]", + f"id = {toml_string(plugin['id'])}", + f"name = {toml_string(plugin['name'])}", + f"version = {toml_string(plugin['version'])}", + f"author = {toml_string(plugin['author'])}", + ] + ) + if "license" in plugin: + lines.append(f"license = {toml_string(plugin['license'])}") + if "icon" in plugin: + lines.append(f"icon = {toml_string(plugin['icon'])}") + if "description" in plugin: + lines.append(f"description = {toml_string(plugin['description'])}") + if "deprecated" in plugin: + lines.append(f"deprecated = {toml_bool(plugin['deprecated'])}") + lines.extend( + [ + f"min_noctalia = {toml_string(plugin['min_noctalia'])}", + "tags = [" + + ", ".join(toml_string(tag) for tag in plugin["tags"]) + + "]", + ] + ) + + return "\n".join(lines) + "\n" + + +def main() -> int: + plugins = discover_plugins() + CATALOG_PATH.write_text(render_catalog(plugins), encoding="utf-8") + print(f"Updated {CATALOG_PATH.relative_to(ROOT_DIR)} with {len(plugins)} plugin(s).") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/.github/workflows/update-catalog.yml b/.github/workflows/update-catalog.yml new file mode 100644 index 0000000..7f336d9 --- /dev/null +++ b/.github/workflows/update-catalog.yml @@ -0,0 +1,49 @@ +name: Update Plugin Catalog + +on: + push: + branches: + - main + paths-ignore: + - catalog.toml + workflow_dispatch: + +concurrency: + group: update-plugin-catalog-${{ github.ref }} + cancel-in-progress: true + +jobs: + update-catalog: + runs-on: ubuntu-latest + permissions: + contents: write + if: ${{ github.repository == 'noctalia-dev/community-plugins' }} + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Rebuild catalog + run: python3 .github/workflows/update-catalog.py + + - name: Check for changes + id: check_changes + run: | + git add catalog.toml + if git diff --cached --quiet catalog.toml; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No changes to catalog.toml" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Changes detected in catalog.toml" + fi + + - name: Commit and push changes + if: steps.check_changes.outputs.changed == 'true' + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git commit -m "chore: update plugin catalog [skip ci]" + git push diff --git a/.github/workflows/validate-plugins.py b/.github/workflows/validate-plugins.py new file mode 100644 index 0000000..7e3516e --- /dev/null +++ b/.github/workflows/validate-plugins.py @@ -0,0 +1,751 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import re +import sys +import tomllib +from pathlib import Path +from typing import Any + + +DEFAULT_ROOT = Path(__file__).resolve().parents[2] +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+$") + +# An id segment must be a lowercase flat identifier: the part after the "/" is also the +# plugin's directory here, its export directory on disk, and its slug on the website. +ID_SEGMENT_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +# Names the website reserves for its own routes; a plugin folder cannot take one. +RESERVED_NAMES = {"license", "readme", "index", "api", "admin", "static", "assets"} + +# Files every published plugin ships: the site renders the README as the plugin page and +# the thumbnail as its card, and the English catalog backs every label_key. +REQUIRED_PLUGIN_FILES = ("README.md", "thumbnail.webp", "translations/en.json") +THUMBNAIL_MAX_BYTES = 512 * 1024 +WEBP_MAGIC_PREFIX = b"RIFF" +WEBP_MAGIC_FORMAT = b"WEBP" + +ROOT_STRING_FIELDS = ( + "id", + "name", + "version", + "min_noctalia", + "author", + "license", + "icon", + "description", +) +ROOT_ARRAY_FIELDS = ("dependencies", "tags") +ENTRY_TYPES = ("widget", "panel", "shortcut", "desktop_widget", "launcher_provider", "service") +SETTING_OWNER_TYPES = ("widget", "panel", "desktop_widget", "launcher_provider") +SETTING_TYPES = {"string", "string_list", "bool", "glyph", "select", "folder", "file", "int", "color"} +PANEL_PLACEMENTS = {"attached", "floating"} +PANEL_POSITIONS = { + "auto", + "center", + "top_left", + "top_center", + "top_right", + "center_left", + "center_right", + "bottom_left", + "bottom_center", + "bottom_right", +} + +ROOT_FIELDS = set(ROOT_STRING_FIELDS) | set(ROOT_ARRAY_FIELDS) | set(ENTRY_TYPES) | { + "setting", + "deprecated", +} +BASE_ENTRY_FIELDS = {"id", "entry"} +ENTRY_FIELDS = { + "widget": BASE_ENTRY_FIELDS | {"setting"}, + "panel": BASE_ENTRY_FIELDS | {"setting", "width", "height", "placement", "position", "open_near_click"}, + "desktop_widget": BASE_ENTRY_FIELDS | {"setting"}, + "service": BASE_ENTRY_FIELDS, + "shortcut": BASE_ENTRY_FIELDS, + "launcher_provider": BASE_ENTRY_FIELDS + | {"prefix", "glyph", "include_in_global_search", "debounce_ms", "setting", "category"}, +} +CATEGORY_FIELDS = {"label", "glyph"} +SETTING_FIELDS = { + "key", + "type", + "label_key", + "description_key", + "default", + "options", + "min", + "max", + "step", + "visible_when", + "advanced", +} +OPTION_FIELDS = {"value", "label_key"} +VISIBLE_WHEN_FIELDS = {"key", "values"} + + +def is_non_empty_string(value: Any) -> bool: + return isinstance(value, str) and value.strip() != "" + + +def is_int(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +def is_number(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) + + +def rel(root: Path, path: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + +def has_key_path(data: Any, dotted_key: str) -> bool: + if isinstance(data, dict) and dotted_key in data: + return True + + current = data + for part in dotted_key.split("."): + if not isinstance(current, dict) or part not in current: + return False + current = current[part] + return True + + +class Validator: + def __init__(self, root: Path) -> None: + self.root = root.resolve() + self.errors: list[str] = [] + + def add_error(self, path: Path, message: str) -> None: + self.errors.append(f"{rel(self.root, path)}: {message}") + + def add_context_error(self, path: Path, context: str, message: str) -> None: + self.add_error(path, f"{context}: {message}") + + def load_manifest(self, path: Path) -> dict[str, Any] | None: + try: + with path.open("rb") as handle: + manifest = tomllib.load(handle) + except tomllib.TOMLDecodeError as error: + self.add_error(path, f"invalid TOML: {error}") + return None + + if not isinstance(manifest, dict): + self.add_error(path, "expected a TOML table") + return None + return manifest + + def load_english_translations(self, plugin_dir: Path) -> Any | None: + path = plugin_dir / "translations" / "en.json" + if not path.exists(): + return None + + try: + with path.open("r", encoding="utf-8") as handle: + translations = json.load(handle) + except json.JSONDecodeError as error: + self.add_error(path, f"invalid JSON: {error}") + return None + + if not isinstance(translations, dict): + self.add_error(path, "expected a JSON object") + return None + return translations + + def validate_translation_key( + self, + manifest_path: Path, + translations: Any | None, + context: str, + field: str, + value: Any, + ) -> None: + if not is_non_empty_string(value): + self.add_context_error(manifest_path, context, f"{field} must be a non-empty string") + return + + if translations is None: + self.add_context_error( + manifest_path, + context, + f"{field} references '{value}', but translations/en.json is missing or invalid", + ) + return + + if not has_key_path(translations, value): + self.add_context_error( + manifest_path, + context, + f"{field} references missing translations/en.json key '{value}'", + ) + + def validate_string_list( + self, + manifest_path: Path, + context: str, + field: str, + value: Any, + *, + allow_empty: bool, + ) -> None: + if not isinstance(value, list): + self.add_context_error(manifest_path, context, f"{field} must be a list of strings") + return + + if not allow_empty and not value: + self.add_context_error(manifest_path, context, f"{field} must not be empty") + + seen: set[str] = set() + for index, item in enumerate(value): + if not is_non_empty_string(item): + self.add_context_error( + manifest_path, + context, + f"{field}[{index}] must be a non-empty string", + ) + continue + if item in seen: + self.add_context_error(manifest_path, context, f"{field} contains duplicate '{item}'") + seen.add(item) + + def validate_root_fields(self, manifest_path: Path, manifest: dict[str, Any]) -> None: + unknown = sorted(set(manifest) - ROOT_FIELDS) + for field in unknown: + self.add_error(manifest_path, f"unknown root field '{field}'") + + for field in ROOT_STRING_FIELDS: + if field not in manifest: + self.add_error(manifest_path, f"missing required root field '{field}'") + elif not is_non_empty_string(manifest[field]): + self.add_error(manifest_path, f"root field '{field}' must be a non-empty string") + + for field in ROOT_ARRAY_FIELDS: + if field not in manifest: + self.add_error(manifest_path, f"missing required root field '{field}'") + + if "dependencies" in manifest: + self.validate_string_list( + manifest_path, + "root", + "dependencies", + manifest["dependencies"], + allow_empty=True, + ) + + if "tags" in manifest: + self.validate_string_list(manifest_path, "root", "tags", manifest["tags"], allow_empty=False) + + if "deprecated" in manifest and not isinstance(manifest["deprecated"], bool): + self.add_error(manifest_path, "root field 'deprecated' must be a bool") + + # A plugin id is "/", and the part after the "/" is the directory + # it lives in - so a folder name is taken once for the whole repo. + folder = manifest_path.parent.name + plugin_id = manifest.get("id") + if is_non_empty_string(plugin_id): + author, _, name = plugin_id.partition("/") + if name != folder: + self.add_error(manifest_path, f"id must be '/{folder}', matching the directory name") + for label, segment in (("author", author), ("plugin", name)): + if not ID_SEGMENT_RE.fullmatch(segment): + self.add_error( + manifest_path, + f"id {label} '{segment}' must be lowercase and match {ID_SEGMENT_RE.pattern}", + ) + + if folder in RESERVED_NAMES: + self.add_error(manifest_path, f"'{folder}' is a reserved name and cannot be a plugin directory") + + for field in ("version", "min_noctalia"): + value = manifest.get(field) + if is_non_empty_string(value) and not SEMVER_RE.fullmatch(value): + self.add_error(manifest_path, f"root field '{field}' must use MAJOR.MINOR.PATCH") + + def validate_entry_path(self, manifest_path: Path, context: str, plugin_dir: Path, value: Any) -> None: + if not is_non_empty_string(value): + self.add_context_error(manifest_path, context, "entry must be a non-empty string") + return + + raw_path = Path(value) + if raw_path.is_absolute() or ".." in raw_path.parts: + self.add_context_error(manifest_path, context, "entry must stay inside the plugin directory") + return + + entry_path = plugin_dir / raw_path + try: + entry_path.resolve().relative_to(plugin_dir.resolve()) + except ValueError: + self.add_context_error(manifest_path, context, "entry must stay inside the plugin directory") + return + + if not entry_path.is_file(): + self.add_context_error( + manifest_path, + context, + f"entry file '{value}' does not exist", + ) + + def validate_launcher_fields(self, manifest_path: Path, context: str, entry: dict[str, Any]) -> None: + for field in ("prefix", "glyph"): + if field in entry and not is_non_empty_string(entry[field]): + self.add_context_error(manifest_path, context, f"{field} must be a non-empty string") + + if "include_in_global_search" in entry and not isinstance(entry["include_in_global_search"], bool): + self.add_context_error( + manifest_path, + context, + "include_in_global_search must be a bool", + ) + + if "debounce_ms" in entry: + debounce_ms = entry["debounce_ms"] + if not is_int(debounce_ms) or debounce_ms < 0: + self.add_context_error( + manifest_path, + context, + "debounce_ms must be a non-negative integer", + ) + + if "category" in entry: + self.validate_launcher_categories(manifest_path, f"{context}.category", entry["category"]) + + def validate_launcher_categories(self, manifest_path: Path, context: str, categories: Any) -> None: + if not isinstance(categories, list): + self.add_context_error(manifest_path, context, "must be an array of tables") + return + + if not categories: + self.add_context_error(manifest_path, context, "must not be empty") + + seen_labels: set[str] = set() + for index, category in enumerate(categories): + category_context = f"{context}[{index}]" + if not isinstance(category, dict): + self.add_context_error(manifest_path, category_context, "must be a table") + continue + + unknown = sorted(set(category) - CATEGORY_FIELDS) + for field in unknown: + self.add_context_error(manifest_path, category_context, f"unknown field '{field}'") + + label = category.get("label") + if not is_non_empty_string(label): + self.add_context_error(manifest_path, category_context, "label must be a non-empty string") + elif label in seen_labels: + self.add_context_error(manifest_path, category_context, f"duplicate category label '{label}'") + else: + seen_labels.add(label) + + if not is_non_empty_string(category.get("glyph")): + self.add_context_error(manifest_path, category_context, "glyph must be a non-empty string") + + def validate_panel_fields(self, manifest_path: Path, context: str, entry: dict[str, Any]) -> None: + # Mirrors the shell parser: a positive number (logical px) or the + # literal string "fill" (span the output's available extent; requires + # floating placement). + uses_fill = False + for field in ("width", "height"): + if field not in entry: + continue + + value = entry[field] + if isinstance(value, str): + if value != "fill": + self.add_context_error(manifest_path, context, f'{field} must be a positive number or "fill"') + else: + uses_fill = True + elif not is_number(value) or value <= 0: + self.add_context_error(manifest_path, context, f'{field} must be a positive number or "fill"') + + if uses_fill and entry.get("placement", "floating") != "floating": + self.add_context_error(manifest_path, context, 'width/height "fill" requires placement = "floating"') + + if "placement" in entry: + placement = entry["placement"] + if not is_non_empty_string(placement): + self.add_context_error(manifest_path, context, "placement must be a non-empty string") + elif placement not in PANEL_PLACEMENTS: + valid = ", ".join(sorted(PANEL_PLACEMENTS)) + self.add_context_error(manifest_path, context, f"placement must be one of: {valid}") + + if "position" in entry: + position = entry["position"] + if not is_non_empty_string(position): + self.add_context_error(manifest_path, context, "position must be a non-empty string") + elif position not in PANEL_POSITIONS: + valid = ", ".join(sorted(PANEL_POSITIONS)) + self.add_context_error(manifest_path, context, f"position must be one of: {valid}") + + if "open_near_click" in entry and not isinstance(entry["open_near_click"], bool): + self.add_context_error(manifest_path, context, "open_near_click must be a bool") + + def validate_entries( + self, + manifest_path: Path, + manifest: dict[str, Any], + translations: Any | None, + ) -> None: + plugin_dir = manifest_path.parent + entry_count = 0 + seen_ids: dict[str, str] = {} + + for entry_type in ENTRY_TYPES: + entries = manifest.get(entry_type, []) + if not isinstance(entries, list): + self.add_error(manifest_path, f"'{entry_type}' must be an array of tables") + continue + + for index, entry in enumerate(entries): + entry_count += 1 + context = f"{entry_type}[{index}]" + if not isinstance(entry, dict): + self.add_context_error(manifest_path, context, "must be a table") + continue + + unknown = sorted(set(entry) - ENTRY_FIELDS[entry_type]) + for field in unknown: + self.add_context_error(manifest_path, context, f"unknown field '{field}'") + + entry_id = entry.get("id") + if not is_non_empty_string(entry_id): + self.add_context_error(manifest_path, context, "id must be a non-empty string") + elif entry_id in seen_ids: + self.add_context_error( + manifest_path, + context, + f"id '{entry_id}' is already used by {seen_ids[entry_id]}", + ) + else: + seen_ids[entry_id] = context + + self.validate_entry_path(manifest_path, context, plugin_dir, entry.get("entry")) + + if entry_type == "launcher_provider": + self.validate_launcher_fields(manifest_path, context, entry) + + if entry_type == "panel": + self.validate_panel_fields(manifest_path, context, entry) + + if entry_type in SETTING_OWNER_TYPES and "setting" in entry: + self.validate_settings( + manifest_path, + translations, + entry["setting"], + f"{context}.setting", + ) + + if entry_count == 0: + self.add_error( + manifest_path, + "must define at least one entry: widget, panel, shortcut, desktop_widget, launcher_provider, or service", + ) + + def validate_default( + self, + manifest_path: Path, + context: str, + setting_type: str, + setting: dict[str, Any], + option_values: list[str], + ) -> None: + if "default" not in setting: + if setting_type not in {"folder", "file"}: + self.add_context_error(manifest_path, context, "default is required") + return + + default = setting["default"] + if setting_type in {"string", "folder", "file"} and not isinstance(default, str): + self.add_context_error(manifest_path, context, "default must be a string") + elif setting_type in {"glyph", "color"} and not is_non_empty_string(default): + self.add_context_error(manifest_path, context, "default must be a non-empty string") + elif setting_type == "string_list": + self.validate_string_list(manifest_path, context, "default", default, allow_empty=True) + elif setting_type == "bool" and not isinstance(default, bool): + self.add_context_error(manifest_path, context, "default must be a bool") + elif setting_type == "int" and not is_int(default): + self.add_context_error(manifest_path, context, "default must be an integer") + elif setting_type == "select": + if not is_non_empty_string(default): + self.add_context_error(manifest_path, context, "default must be a non-empty string") + elif default not in option_values: + self.add_context_error(manifest_path, context, "default must match one of the option values") + + def validate_options( + self, + manifest_path: Path, + translations: Any | None, + context: str, + setting: dict[str, Any], + ) -> list[str]: + options = setting.get("options") + setting_type = setting.get("type") + + if options is None: + if setting_type == "select": + self.add_context_error(manifest_path, context, "select settings require options") + return [] + + if setting_type != "select": + self.add_context_error(manifest_path, context, "options is only valid for select settings") + + if not isinstance(options, list): + self.add_context_error(manifest_path, context, "options must be a list of tables") + return [] + + if not options: + self.add_context_error(manifest_path, context, "options must not be empty") + + values: list[str] = [] + seen: set[str] = set() + for index, option in enumerate(options): + option_context = f"{context}.options[{index}]" + if not isinstance(option, dict): + self.add_context_error(manifest_path, option_context, "must be a table") + continue + + unknown = sorted(set(option) - OPTION_FIELDS) + for field in unknown: + self.add_context_error(manifest_path, option_context, f"unknown field '{field}'") + + value = option.get("value") + if not is_non_empty_string(value): + self.add_context_error(manifest_path, option_context, "value must be a non-empty string") + else: + values.append(value) + if value in seen: + self.add_context_error( + manifest_path, + option_context, + f"duplicate option value '{value}'", + ) + seen.add(value) + + self.validate_translation_key( + manifest_path, + translations, + option_context, + "label_key", + option.get("label_key"), + ) + + return values + + def validate_int_bounds(self, manifest_path: Path, context: str, setting: dict[str, Any]) -> None: + setting_type = setting.get("type") + bound_values: dict[str, int] = {} + + for field in ("min", "max", "step"): + if field not in setting: + continue + value = setting[field] + if setting_type != "int": + self.add_context_error(manifest_path, context, f"{field} is only valid for int settings") + elif not is_int(value): + self.add_context_error(manifest_path, context, f"{field} must be an integer") + else: + bound_values[field] = value + + if "min" in bound_values and "max" in bound_values and bound_values["min"] > bound_values["max"]: + self.add_context_error(manifest_path, context, "min must be less than or equal to max") + + if "step" in bound_values and bound_values["step"] <= 0: + self.add_context_error(manifest_path, context, "step must be greater than zero") + + default = setting.get("default") + if setting_type == "int" and is_int(default): + if "min" in bound_values and default < bound_values["min"]: + self.add_context_error(manifest_path, context, "default must be greater than or equal to min") + if "max" in bound_values and default > bound_values["max"]: + self.add_context_error(manifest_path, context, "default must be less than or equal to max") + + def validate_visible_when(self, manifest_path: Path, context: str, value: Any) -> None: + if value is None: + return + + if not isinstance(value, dict): + self.add_context_error(manifest_path, context, "visible_when must be a table") + return + + unknown = sorted(set(value) - VISIBLE_WHEN_FIELDS) + for field in unknown: + self.add_context_error(manifest_path, context, f"visible_when has unknown field '{field}'") + + if not is_non_empty_string(value.get("key")): + self.add_context_error(manifest_path, context, "visible_when.key must be a non-empty string") + + values = value.get("values") + if not isinstance(values, list) or not values: + self.add_context_error( + manifest_path, + context, + "visible_when.values must be a non-empty list of strings", + ) + return + + for index, item in enumerate(values): + if not is_non_empty_string(item): + self.add_context_error( + manifest_path, + context, + f"visible_when.values[{index}] must be a non-empty string", + ) + + def validate_settings( + self, + manifest_path: Path, + translations: Any | None, + settings: Any, + context_prefix: str, + ) -> None: + if not isinstance(settings, list): + self.add_context_error(manifest_path, context_prefix, "must be an array of tables") + return + + seen_keys: set[str] = set() + for index, setting in enumerate(settings): + context = f"{context_prefix}[{index}]" + if not isinstance(setting, dict): + self.add_context_error(manifest_path, context, "must be a table") + continue + + unknown = sorted(set(setting) - SETTING_FIELDS) + for field in unknown: + self.add_context_error(manifest_path, context, f"unknown field '{field}'") + + key = setting.get("key") + if not is_non_empty_string(key): + self.add_context_error(manifest_path, context, "key must be a non-empty string") + elif key in seen_keys: + self.add_context_error(manifest_path, context, f"duplicate setting key '{key}'") + else: + seen_keys.add(key) + + setting_type = setting.get("type") + if not is_non_empty_string(setting_type): + self.add_context_error(manifest_path, context, "type must be a non-empty string") + setting_type = "" + elif setting_type not in SETTING_TYPES: + self.add_context_error(manifest_path, context, f"unsupported setting type '{setting_type}'") + + self.validate_translation_key( + manifest_path, + translations, + context, + "label_key", + setting.get("label_key"), + ) + + if "description_key" in setting: + self.validate_translation_key( + manifest_path, + translations, + context, + "description_key", + setting.get("description_key"), + ) + + option_values = self.validate_options(manifest_path, translations, context, setting) + if setting_type in SETTING_TYPES: + self.validate_default(manifest_path, context, setting_type, setting, option_values) + + self.validate_int_bounds(manifest_path, context, setting) + self.validate_visible_when(manifest_path, context, setting.get("visible_when")) + + if "advanced" in setting and not isinstance(setting["advanced"], bool): + self.add_context_error(manifest_path, context, "advanced must be a bool") + + def validate_required_files(self, manifest_path: Path, plugin_dir: Path) -> None: + for required in REQUIRED_PLUGIN_FILES: + if not (plugin_dir / required).is_file(): + self.add_error(manifest_path, f"missing required file '{required}'") + + def validate_thumbnail(self, manifest_path: Path, plugin_dir: Path) -> None: + thumbnail = plugin_dir / "thumbnail.webp" + if not thumbnail.is_file(): + return + + size = thumbnail.stat().st_size + if size > THUMBNAIL_MAX_BYTES: + self.add_error( + manifest_path, + f"thumbnail.webp is {size} bytes; keep it under {THUMBNAIL_MAX_BYTES}", + ) + + with thumbnail.open("rb") as handle: + header = handle.read(12) + if header[:4] != WEBP_MAGIC_PREFIX or header[8:12] != WEBP_MAGIC_FORMAT: + self.add_error(manifest_path, "thumbnail.webp is not a WebP image") + + def validate_no_symlinks(self, manifest_path: Path, plugin_dir: Path) -> None: + for path in plugin_dir.rglob("*"): + if path.is_symlink(): + self.add_error(manifest_path, f"'{rel(self.root, path)}' is a symlink; plugins ship real files") + + def validate_manifest(self, manifest_path: Path) -> None: + manifest = self.load_manifest(manifest_path) + if manifest is None: + return + + plugin_dir = manifest_path.parent + translations = self.load_english_translations(plugin_dir) + + self.validate_root_fields(manifest_path, manifest) + self.validate_required_files(manifest_path, plugin_dir) + self.validate_thumbnail(manifest_path, plugin_dir) + self.validate_no_symlinks(manifest_path, plugin_dir) + + if "setting" in manifest: + self.validate_settings(manifest_path, translations, manifest["setting"], "setting") + + self.validate_entries(manifest_path, manifest, translations) + + def validate_layout(self) -> None: + # Every plugin is one top-level directory. A manifest anywhere else (repo root, or + # nested deeper) is never loaded by the shell, so fail loudly. + for manifest_path in self.root.rglob("plugin.toml"): + if ".git" in manifest_path.parts: + continue + depth = len(manifest_path.relative_to(self.root).parts) + if depth != 2: + self.add_error(manifest_path, "plugins live at /plugin.toml, one directory per plugin") + + def validate(self) -> int: + self.validate_layout() + manifests = sorted(self.root.glob("*/plugin.toml")) + for manifest_path in manifests: + self.validate_manifest(manifest_path) + + if self.errors: + for error in self.errors: + print(f"error: {error}", file=sys.stderr) + return 1 + + print(f"Validated {len(manifests)} plugin manifest(s).") + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate community Noctalia plugin manifests.") + parser.add_argument( + "--root", + type=Path, + default=DEFAULT_ROOT, + help="Repository root to validate. Defaults to the current repository.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + return Validator(args.root).validate() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml new file mode 100644 index 0000000..e55839c --- /dev/null +++ b/.github/workflows/validate-plugins.yml @@ -0,0 +1,25 @@ +name: Validate Plugin Manifests + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: validate-plugin-manifests-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Validate plugin manifests + run: python3 .github/workflows/validate-plugins.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc11c03 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Editor type definitions, fetched from official-plugins (see README). +/noctalia.d.luau + +/.github/workflows/__pycache__ diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..5b75c42 --- /dev/null +++ b/.luaurc @@ -0,0 +1,7 @@ +{ + "languageMode": "nonstrict", + "lint": { + "FunctionUnused": false + }, + "lintErrors": false +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..af0d5bc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "luau-lsp.platform.type": "standard", + "luau-lsp.sourcemap.enabled": false, + "luau-lsp.types.definitionFiles": { + "noctalia": "noctalia.d.luau" + }, + "luau-lsp.ignoreGlobs": ["**/*.d.luau"] +} diff --git a/README.md b/README.md index e3507ae..2e56fb2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,136 @@ --- -This is where we fetch all the additional plugins for noctalia from. +This repo is the **community** plugin source for [Noctalia](https://github.com/noctalia-dev/noctalia-shell). Every +plugin merged here is listed in the shell's plugin store and on +[noctalia.dev/plugins](https://noctalia.dev/plugins), and users can install it without adding a source of their own. -WE DO NOT ACCEPT COMMUNITY PLUGINS YET. +Plugins maintained by the core team live in +[official-plugins](https://github.com/noctalia-dev/official-plugins), which does not accept third-party plugins. +This one does. **PRs are welcome.** + +> The plugin system is in **beta**. The manifest format and the plugin API may still change before v5 is stable. +> Expect to bump your plugin when they do. + +## Layout + +Each plugin is **one top-level directory**, named after the part of its id that follows the `/`, so `me/hello` lives +in `hello/`: + +``` +hello/ + plugin.toml # manifest: id ("me/hello"), metadata, entries, settings + hello.luau # your entry scripts + README.md # rendered as the plugin's page on noctalia.dev + thumbnail.webp # the plugin's card image + translations/ + en.json # every label_key / description_key the manifest references +``` + +`catalog.toml` at the repo root indexes every plugin. **It is generated by CI, so never edit it or include it in a +commit.** + +A plugin id is `/`. The author part is yours (your GitHub handle is the obvious choice) and keeps your +id distinct from everyone else's, but the **directory name is first-come within this repo**. If `weather/` is already +taken, pick another name; the official repo is a separate source, so a name used there is not taken here. Both id +segments must be lowercase and match `[a-z0-9][a-z0-9._-]*`. + +## What a plugin is allowed to be + +Noctalia plugins are **trusted, unsandboxed Luau**. There is no permission broker and no capability sandbox: +installing a plugin is equivalent to running a script the user owns. It can read and write files, spawn processes, and +talk to the network as the user. + +That is a deliberate design choice, and it puts the burden on review. So: + +- **No obfuscated, minified, or generated code.** A reviewer must be able to read every line you ship. +- **No downloading and executing remote code.** Ship your logic in the repo, at a version people reviewed. +- **Declare what you shell out to.** External commands go in `dependencies` in `plugin.toml` and get a mention in your + README. +- **Account for every network call, filesystem write, and spawned process** in your PR description. + +Anything that looks like it is hiding what it does will be rejected, regardless of intent. + +## Writing a plugin + +The [plugin development docs](https://docs.noctalia.dev/v5/plugins/development/) are the reference: the +[manifest](https://docs.noctalia.dev/v5/plugins/development/manifest/), the +[entry types](https://docs.noctalia.dev/v5/plugins/development/entries/) (`[[widget]]`, `[[panel]]`, `[[shortcut]]`, +`[[service]]`, `[[desktop_widget]]`, `[[launcher_provider]]`), the +[declarative UI](https://docs.noctalia.dev/v5/plugins/development/declarative-ui/) vocabulary, the +[runtime API](https://docs.noctalia.dev/v5/plugins/development/runtime-api/), and the +[workflow](https://docs.noctalia.dev/v5/plugins/development/workflow/) for developing and testing locally. + +The fastest start is to read +[`noctalia/example`](https://github.com/noctalia-dev/official-plugins/tree/main/example) in the official repo. It +exercises a bar widget, a declarative widget, a service, a shortcut, a launcher provider, and a panel in one plugin. + +To run your plugin while you work on it, add this checkout as a `path` source: + +```sh +noctalia msg plugins source add dev path ~/dev/community-plugins +noctalia msg plugins enable me/hello +``` + +`.luau` edits hot-reload; manifest changes are picked up on the next config reload. + +### Editor setup + +`noctalia.d.luau` declares the whole plugin API, so luau-lsp gives you autocomplete and typo diagnostics. It lives in +official-plugins, which is its single source of truth; it is **not** vendored here, because a committed copy would be a +second one for everyone to trust and keep in sync. Fetch it into the repo root, where it is gitignored: + +```sh +curl -O https://raw.githubusercontent.com/noctalia-dev/official-plugins/main/noctalia.d.luau +``` + +Re-run that whenever the plugin API changes; your local copy is a snapshot, not a subscription. + +The committed `.vscode/settings.json` already points luau-lsp at it; for another editor, add it to luau-lsp's +`types.definitionFiles`. `.luaurc` sets `nonstrict` mode, matching the `--!nonstrict` directive every plugin file +starts with. + +### Thumbnail + +Every plugin ships a `thumbnail.webp`. It is the card image in the plugin store and on the website. Generate one with +the **[thumbnail generator](https://assets.noctalia.dev/plugins/thumbnail-generator.html)**: drop in a screenshot of +your plugin, set the title, category tag and accent color, then export the 960×540 WebP and commit it as +`/thumbnail.webp`. + +### Translations + +Write `translations/en.json` only. Every `label_key` and `description_key` in your manifest must resolve to a key in +it, and CI checks this. Do not add machine-translated locales; other languages are handled separately. + +## Submitting + +Open a PR against `main`. CI validates your manifest, entry scripts, required files, and thumbnail on every push. + +- **One plugin per PR.** +- The directory name matches the part of `id` after the `/` in `plugin.toml` **exactly**. +- `version` is semver and gets bumped on every change to the plugin. +- `min_noctalia` is the Noctalia version you actually tested against. Users on older builds are then told the plugin + needs an upgrade instead of getting a broken install. +- `license` is set in `plugin.toml`. You keep the copyright on your plugin; if it is not MIT, put a `LICENSE` file in + your plugin directory. There is no repo-wide license covering contributed plugins. +- Screenshots or a short video for anything with a visual surface. + +Maintainers read the code before merging. Expect review comments about clarity, and about anything the plugin does +that is not obvious from its description. + +## Maintaining your plugin + +The plugin directory is yours. Someone else's PR changing your plugin is not merged without your sign-off, unless it +fixes something that is broken or is a mechanical change applied across the whole repo. Maintainers will @-mention you +on PRs and issues that touch it. + +If you stop maintaining a plugin, set `deprecated = true` in its `plugin.toml` rather than deleting the directory. The +store keeps working for people who already installed it, but it stops being offered to new users. Plugins that are +broken and unmaintained across a Noctalia release may be deprecated by maintainers. + +## Help + +- [Documentation](https://docs.noctalia.dev) +- [Discord](https://discord.noctalia.dev) +- Bugs in Noctalia itself (not in a plugin) belong in + [noctalia](https://github.com/noctalia-dev/noctalia/issues).