feat: open the repo for community plugin submissions
This commit is contained in:
@@ -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 `<details>` 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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<!-- If this PR is not ready for review yet, please mark it as Draft. -->
|
||||||
|
|
||||||
|
## Plugin
|
||||||
|
|
||||||
|
<!-- The canonical id. The part after the "/" is the plugin's directory in this repo. -->
|
||||||
|
|
||||||
|
- **Id:** `<author>/<plugin>`
|
||||||
|
- [ ] New plugin
|
||||||
|
- [ ] Update to an existing plugin (version bumped in `plugin.toml`)
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
<!-- A short description, and what a user gets out of it. -->
|
||||||
|
|
||||||
|
## External dependencies
|
||||||
|
|
||||||
|
<!-- Any command or service the plugin shells out to, and why. List them in `dependencies` in plugin.toml too.
|
||||||
|
Write "None" if it is self-contained. -->
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!-- How you exercised it: entries opened, IPC messages sent, settings toggled. -->
|
||||||
|
|
||||||
|
- [ ] Tested on Niri
|
||||||
|
- [ ] Tested on Hyprland
|
||||||
|
- [ ] Tested on Sway
|
||||||
|
- [ ] Tested on another compositor:
|
||||||
|
- **Noctalia version tested against:** <!-- must be >= min_noctalia in plugin.toml -->
|
||||||
|
|
||||||
|
## Screenshots / Videos
|
||||||
|
|
||||||
|
<!-- Show the plugin running. Required for anything with a visual surface. -->
|
||||||
|
|
||||||
|
## 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`.
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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 "<author>/<plugin>", 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 '<author>/{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>/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:]))
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Editor type definitions, fetched from official-plugins (see README).
|
||||||
|
/noctalia.d.luau
|
||||||
|
|
||||||
|
/.github/workflows/__pycache__
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"languageMode": "nonstrict",
|
||||||
|
"lint": {
|
||||||
|
"FunctionUnused": false
|
||||||
|
},
|
||||||
|
"lintErrors": false
|
||||||
|
}
|
||||||
Vendored
+8
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 `<author>/<plugin>`. 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
|
||||||
|
`<plugin>/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).
|
||||||
|
|||||||
Reference in New Issue
Block a user