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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/test_validate_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,5 +220,75 @@ def test_requires_conditional_sections(self) -> None:
self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings)))


class SettingUsageTests(unittest.TestCase):
"""plugin.toml and the Luau sources must agree on which settings exist."""

def validate(self, manifest: dict, sources: dict[str, str]) -> list[str]:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plugin_dir = root / "example"
plugin_dir.mkdir()
for name, contents in sources.items():
path = plugin_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(contents, encoding="utf-8")
validator = validate_plugins.Validator(root)
validator.validate_setting_usage(plugin_dir / "plugin.toml", plugin_dir, manifest)
return validator.errors

def test_accepts_matching_manifest_and_source(self) -> None:
manifest = {"setting": [{"key": "interval"}, {"key": "notify"}]}
source = 'local a = noctalia.getConfig("interval")\nlocal b = noctalia.getConfig("notify")\n'
self.assertEqual(self.validate(manifest, {"main.luau": source}), [])

def test_rejects_reading_an_undeclared_setting(self) -> None:
manifest = {"setting": [{"key": "interval"}]}
source = 'local a = noctalia.getConfig("interval")\nlocal b = noctalia.getConfig("intervl")\n'
errors = self.validate(manifest, {"main.luau": source})
self.assertTrue(any("intervl" in error for error in errors))

def test_rejects_a_declared_setting_nothing_reads(self) -> None:
manifest = {"setting": [{"key": "interval"}, {"key": "orphan"}]}
source = 'local a = noctalia.getConfig("interval")\n'
errors = self.validate(manifest, {"main.luau": source})
self.assertTrue(any("orphan" in error for error in errors))

def test_collects_entry_level_settings(self) -> None:
manifest = {"widget": [{"id": "w", "entry": "w.luau", "setting": [{"key": "bar_width"}]}]}
source = 'local w = noctalia.getConfig("bar_width")\n'
self.assertEqual(self.validate(manifest, {"w.luau": source}), [])

def test_reads_across_multiple_sources(self) -> None:
manifest = {"setting": [{"key": "a"}, {"key": "b"}]}
sources = {"one.luau": 'noctalia.getConfig("a")\n', "two.luau": 'noctalia.getConfig("b")\n'}
self.assertEqual(self.validate(manifest, sources), [])

def test_ignores_getconfig_inside_comments(self) -> None:
manifest = {"setting": [{"key": "interval"}]}
source = 'noctalia.getConfig("interval")\n-- noctalia.getConfig("ghost")\n'
self.assertEqual(self.validate(manifest, {"main.luau": source}), [])

def test_ignores_getconfig_inside_block_comments(self) -> None:
manifest = {"setting": [{"key": "interval"}]}
source = 'noctalia.getConfig("interval")\n--[[ noctalia.getConfig("ghost") ]]\n'
self.assertEqual(self.validate(manifest, {"main.luau": source}), [])

def test_skips_unused_check_when_a_key_is_dynamic(self) -> None:
# A key read through a variable cannot be seen, so "declared but unread" would be a
# false positive. The undeclared-read check still applies.
manifest = {"setting": [{"key": "interval"}, {"key": "maybe_used"}]}
source = 'local k = "interval"\nnoctalia.getConfig(k)\n'
self.assertEqual(self.validate(manifest, {"main.luau": source}), [])

def test_still_flags_undeclared_reads_when_a_key_is_dynamic(self) -> None:
manifest = {"setting": [{"key": "interval"}]}
source = 'noctalia.getConfig(k)\nnoctalia.getConfig("ghost")\n'
errors = self.validate(manifest, {"main.luau": source})
self.assertTrue(any("ghost" in error for error in errors))

def test_accepts_a_plugin_with_no_settings(self) -> None:
self.assertEqual(self.validate({}, {"main.luau": "local x = 1\n"}), [])


if __name__ == "__main__":
unittest.main()
88 changes: 83 additions & 5 deletions .github/workflows/validate-plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@
OBSOLETE_CONFIG_ACCESSOR_RE = re.compile(
r"\b(barWidget|desktopWidget|panel|launcher)\s*\.\s*getConfig\b"
)
# getConfig("key") / getConfig('key') — the argument must be a plain literal to be read.
CONFIG_KEY_LITERAL_RE = re.compile(r"\bgetConfig\s*\(\s*[\"']([^\"']+)[\"']\s*\)")
# Any getConfig call whose argument is not a bare literal (a variable, a concatenation).
CONFIG_KEY_DYNAMIC_RE = re.compile(r"\bgetConfig\s*\(\s*(?![\"'][^\"']*[\"']\s*\))")


def is_non_empty_string(value: Any) -> bool:
Expand Down Expand Up @@ -257,8 +261,13 @@ def section_body(markdown: str, headings: list[tuple[int, str, int, int]], index
return markdown[body_start:body_end].strip()


def obsolete_config_accessors(source: str) -> list[tuple[str, int]]:
"""Find removed entry-specific getConfig aliases outside Luau comments and strings."""
def mask_luau(source: str, *, mask_strings: bool) -> str:
"""Blank out Luau comments, and optionally string literals, preserving offsets.

Newlines survive so line numbers still line up. Callers that only need to spot code
pass mask_strings=True; callers that need to read a literal argument pass False and
keep comments masked, so a getConfig call inside a comment is still ignored.
"""
visible = list(source)
length = len(source)
index = 0
Expand Down Expand Up @@ -286,7 +295,8 @@ def mask(start: int, end: int) -> None:
if source.startswith("[[", index):
end = source.find("]]", index + 2)
end = length if end == -1 else end + 2
mask(index, end)
if mask_strings:
mask(index, end)
index = end
continue

Expand All @@ -300,19 +310,37 @@ def mask(start: int, end: int) -> None:
end += 1
if source[end - 1] == quote:
break
mask(index, end)
if mask_strings:
mask(index, end)
index = end
continue

index += 1

code = "".join(visible)
return "".join(visible)


def obsolete_config_accessors(source: str) -> list[tuple[str, int]]:
"""Find removed entry-specific getConfig aliases outside Luau comments and strings."""
code = mask_luau(source, mask_strings=True)
return [
(f"{match.group(1)}.getConfig", code.count("\n", 0, match.start()) + 1)
for match in OBSOLETE_CONFIG_ACCESSOR_RE.finditer(code)
]


def config_keys_read(source: str) -> tuple[set[str], bool]:
"""Setting keys read via noctalia.getConfig, and whether any call is not a literal.

A non-literal call — getConfig(key), getConfig("a" .. b) — means the set is not the
whole story, so callers must not conclude a declared setting is unused.
"""
code = mask_luau(source, mask_strings=False)
keys = set(CONFIG_KEY_LITERAL_RE.findall(code))
dynamic = CONFIG_KEY_DYNAMIC_RE.search(code) is not None
return keys, dynamic


def webp_dimensions(header: bytes) -> tuple[int, int] | None:
"""Width and height from a WebP header, or None if it is not one we can read.

Expand Down Expand Up @@ -1122,6 +1150,55 @@ def validate_luau_api(self, plugin_dir: Path) -> None:
f"'{accessor}' on line {line} was removed; use noctalia.getConfig",
)

def declared_setting_keys(self, manifest: dict[str, Any]) -> set[str]:
keys: set[str] = set()
for setting in manifest.get("setting") or []:
if isinstance(setting, dict) and is_non_empty_string(setting.get("key")):
keys.add(setting["key"])
for kind in ENTRY_TYPES:
for entry in manifest.get(kind) or []:
if not isinstance(entry, dict):
continue
for setting in entry.get("setting") or []:
if isinstance(setting, dict) and is_non_empty_string(setting.get("key")):
keys.add(setting["key"])
return keys

def validate_setting_usage(self, manifest_path: Path, plugin_dir: Path, manifest: dict[str, Any]) -> None:
"""Keep plugin.toml and the Luau sources agreed on which settings exist.

Reading an undeclared key is a silent failure at runtime: the host only resolves
manifest-declared keys, so a typo yields nil rather than an error. The reverse —
a declared setting nothing reads — puts a dead control in the user's settings UI.
"""
declared = self.declared_setting_keys(manifest)

read: set[str] = set()
dynamic = False
for source_path in sorted(plugin_dir.rglob("*.luau")):
try:
source = source_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue # already reported by validate_luau_api
source_keys, source_dynamic = config_keys_read(source)
read |= source_keys
dynamic = dynamic or source_dynamic

for key in sorted(read - declared):
self.add_error(
manifest_path,
f"getConfig('{key}') reads a setting that is not declared in plugin.toml",
)

# Only meaningful when every call site is a literal; otherwise a key may well be
# read through a variable and flagging it would be a false positive.
if not dynamic:
for key in sorted(declared - read):
self.add_error(
manifest_path,
f"setting '{key}' is declared but never read via noctalia.getConfig",
)

def validate_no_symlinks(self, manifest_path: Path, plugin_dir: Path) -> None:
for path in plugin_dir.rglob("*"):
if path.is_symlink():
Expand All @@ -1140,6 +1217,7 @@ def validate_manifest(self, manifest_path: Path) -> None:
self.validate_thumbnail(manifest_path, plugin_dir)
self.validate_readme(plugin_dir, manifest)
self.validate_luau_api(plugin_dir)
self.validate_setting_usage(manifest_path, plugin_dir, manifest)
self.validate_no_symlinks(manifest_path, plugin_dir)

if "setting" in manifest:
Expand Down
58 changes: 58 additions & 0 deletions cpu-bars/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# CPU Bars

Shows the load on every logical CPU core at a glance, as a row of thin vertical bars
that fill from the bottom. Each bar is one core, so you can see an unbalanced load or a
single pegged thread that an averaged CPU percentage would hide.

## Plugin

| Field | Value |
| --- | --- |
| ID | `ioandev/cpu-bars` |
| Entries | Bar widget: `cpu-bars` |

## Requirements

Noctalia with plugin API 5 or newer, for `noctalia.systemStats()`. Reads per-core usage
from `/proc/stat`, so it is Linux-only.

## Usage

Add the **CPU Bars** widget to a bar section in Settings → Bar. One bar is drawn per
logical core, in `/proc/stat` order — `cpu0` is leftmost. Bar height is that core's share
of busy time over the last second, and a core at or above the warning threshold turns red.

Hover the widget for a tooltip listing total CPU usage and a per-core breakdown.

The widget updates once a second, aligned to the top of the second. On a vertical bar it
rotates automatically: cores stack downward and each bar fills from the left.

## Settings

| Setting | Type | Default | Description |
| --- | --- | --- | --- |
| `bar_width` | `int` | `3` | Thickness of each core's bar, in pixels. |
| `bar_gap` | `int` | `2` | Space between bars, in pixels. |
| `bar_height` | `int` | `16` | Length of each bar in pixels — height on a horizontal bar, width on a vertical one. |
| `warn_threshold` | `int` | `90` | A core at or above this percentage is drawn in the warning colour. |
| `normal_color` | `color` | `primary` | Colour of a core below the threshold. Accepts a theme role or a hex value. |
| `warn_color` | `color` | `error` | Colour of a core at or above the threshold. |
| `track_color` | `color` | `on_surface` | Colour of the unfilled part of each bar, drawn at 15% opacity. |
| `show_glyph` | `bool` | `true` | Show a CPU icon to the left of the bars. |

Colours accept either a theme role (`primary`, `error`, `tertiary`, …) or a hex literal
such as `#57ff57`. Theme roles follow your colour scheme, including light/dark switches.

## Notes

On a machine with many cores the widget is as wide as `cores × (bar_width + bar_gap)` —
about 120px for 24 cores at the defaults. Reduce `bar_width` and `bar_gap` to fit a
narrower bar.

Bar height is strictly proportional to usage, with no minimum: an idle core shows an empty
track. Since each pixel is `100 / bar_height` percent, usage below half a pixel — about 3%
at the default 16px — rounds away to nothing.

Per-core sampling is opt-in host-side: it runs only while a plugin is asking for it, and
costs one extra `/proc/stat` read per second. The plugin makes no network calls, spawns no
processes, and writes no files.
Loading