From 15707e1f1849cfb644022d88d99d1139f8cb3501 Mon Sep 17 00:00:00 2001 From: Bo Wu Date: Mon, 6 Jul 2026 12:37:12 -0400 Subject: [PATCH] Improve detectables validation docs --- .github/workflows/compliance.yaml | 78 +------------------- README.md | 63 ++++++++++++---- REUSE.toml | 3 +- data/detectables.json | 30 ++++++-- scripts/validate_detectables.py | 119 ++++++++++++++++++++++++++++++ 5 files changed, 196 insertions(+), 97 deletions(-) create mode 100644 scripts/validate_detectables.py diff --git a/.github/workflows/compliance.yaml b/.github/workflows/compliance.yaml index 699ad78..efea374 100644 --- a/.github/workflows/compliance.yaml +++ b/.github/workflows/compliance.yaml @@ -28,83 +28,9 @@ jobs: run: | python -m pip install --quiet jsonschema reuse - - name: Validate JSON schema + - name: Validate detectables metadata run: | - python <<'PY' - import json - from pathlib import Path - - from jsonschema import Draft202012Validator - - schema_path = Path("schema/detectables.schema.json") - data_path = Path("data/detectables.json") - schema = json.loads(schema_path.read_text()) - data = json.loads(data_path.read_text()) - - Draft202012Validator.check_schema(schema) - validator = Draft202012Validator(schema) - errors = sorted( - validator.iter_errors(data), - key=lambda error: error.path, - ) - - if errors: - for error in errors: - path = ".".join(str(part) for part in error.path) or "$" - print(f"{path}: {error.message}") - raise SystemExit(1) - PY - - - name: Check hostless asset references - run: | - python <<'PY' - import json - import re - from pathlib import Path - - data = json.loads(Path("data/detectables.json").read_text()) - url = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE) - bad = [] - - for app in data: - refs = [ - app["icon"], - *app.get("presence_assets", {}).values(), - ] - for ref in refs: - if url.match(ref) or ref.startswith("/"): - bad.append(f"{app['name']}: {ref}") - - if bad: - print("Asset references must be relative:") - print("\n".join(bad)) - raise SystemExit(1) - PY - - - name: Check local asset files - run: | - python <<'PY' - import json - from pathlib import Path - - data = json.loads(Path("data/detectables.json").read_text()) - missing = [] - - for app in data: - refs = [ - app["icon"], - *app.get("presence_assets", {}).values(), - ] - for ref in refs: - path = Path("assets") / ref - if not path.is_file(): - missing.append(f"{app['name']}: {path}") - - if missing: - print("Referenced asset files are missing:") - print("\n".join(missing)) - raise SystemExit(1) - PY + python scripts/validate_detectables.py - name: REUSE lint run: reuse lint diff --git a/README.md b/README.md index c80df53..4fd6b1e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,54 @@ Example: } ``` +## How Detection Works + +Fluxer clients can identify activity by comparing the user's running processes +with the executable rules in `data/detectables.json`. Each application entry +contains one or more platform-specific `executables` rules. A rule matches when +the normalized process name or path suffix matches `name` for the current `os`. + +Most rules use lowercase executable names, such as `osu!.exe`. Rules that start +with `>` are reserved for shared runtimes, such as Java, where the executable +alone is not specific enough. Those rules should also include an `arguments` +substring so clients can distinguish the intended app from other software using +the same runtime. + +When an app matches, clients can use its display `name`, optional `aliases`, +local `icon`, and optional `presence_assets` mappings. Asset values are always +local paths under `assets/`; do not add remote URLs to the data file. + +## Add a Detectable App + +1. Add a new object to `data/detectables.json`. +2. Set `name` to the display name users should see. +3. Add useful `aliases` only when alternate names improve search or matching. +4. Add an `icon` path that points to an existing PNG under `assets/`. +5. Add one or more lowercase executable rules for `win32`, `linux`, or + `darwin`. +6. Use a `>`-prefixed executable rule with `arguments` only for shared runtimes + that need disambiguation. +7. Add `presence_assets` only when the referenced local PNG files exist. +8. Run validation before opening a pull request. + +Example: + +```json +{ + "name": "Example App", + "aliases": [ + "Example" + ], + "icon": "example_app.png", + "executables": [ + { + "name": "example.exe", + "os": "win32" + } + ] +} +``` + ## Licensing - Schema, docs, workflows, and project metadata: Apache-2.0. @@ -39,20 +87,7 @@ Product and company names are used only to identify matched software. See ```sh python -m pip install jsonschema reuse - -python - <<'PY' -import json -from pathlib import Path - -from jsonschema import Draft202012Validator - -schema = json.loads(Path("schema/detectables.schema.json").read_text()) -data = json.loads(Path("data/detectables.json").read_text()) - -Draft202012Validator.check_schema(schema) -Draft202012Validator(schema).validate(data) -PY - +python scripts/validate_detectables.py reuse lint ``` diff --git a/REUSE.toml b/REUSE.toml index 2c65407..de54ff7 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -13,7 +13,8 @@ path = [ "TAKEDOWN.md", "REUSE.toml", ".github/**", - "schema/**" + "schema/**", + "scripts/**" ] SPDX-FileCopyrightText = "2026 Fluxer" SPDX-License-Identifier = "Apache-2.0" diff --git a/data/detectables.json b/data/detectables.json index a32b5e3..d98f092 100644 --- a/data/detectables.json +++ b/data/detectables.json @@ -8,8 +8,14 @@ ], "icon": "minecraft.png", "executables": [ - { "name": "minecraft.windows.exe", "os": "win32" }, - { "name": "content/minecraft.exe", "os": "win32" }, + { + "name": "minecraft.windows.exe", + "os": "win32" + }, + { + "name": "content/minecraft.exe", + "os": "win32" + }, { "name": ">javaw.exe", "os": "win32", @@ -29,12 +35,24 @@ }, { "name": "osu!", - "aliases": ["osu!(lazer)", "osu!(stable)"], + "aliases": [ + "osu!(lazer)", + "osu!(stable)" + ], "icon": "osu.png", "executables": [ - { "name": "osu!.exe", "os": "win32" }, - { "name": "osu!.app", "os": "darwin" }, - { "name": "osu!", "os": "linux" } + { + "name": "osu!.exe", + "os": "win32" + }, + { + "name": "osu!.app", + "os": "darwin" + }, + { + "name": "osu!", + "os": "linux" + } ], "presence_assets": { "mode_custom": "osu/mode_custom.png", diff --git a/scripts/validate_detectables.py b/scripts/validate_detectables.py new file mode 100644 index 0000000..fdb0ea8 --- /dev/null +++ b/scripts/validate_detectables.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Validate detectable application metadata.""" + +import json +from pathlib import Path +from typing import Any + +try: + from jsonschema import Draft202012Validator, SchemaError +except ImportError: + raise SystemExit( + "ERROR: Missing dependency: jsonschema. " + "Install it with `python -m pip install jsonschema`." + ) + + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = ROOT / "schema" / "detectables.schema.json" +DATA_PATH = ROOT / "data" / "detectables.json" +ASSETS_DIR = ROOT / "assets" + + +def load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + raise SystemExit(f"ERROR: Missing required file: {path.relative_to(ROOT)}") + except json.JSONDecodeError as error: + location = f"{path.relative_to(ROOT)}:{error.lineno}:{error.colno}" + raise SystemExit(f"ERROR: Invalid JSON at {location}: {error.msg}") + + +def asset_refs(app: dict[str, Any]) -> list[str]: + refs = [app["icon"]] + refs.extend(app.get("presence_assets", {}).values()) + return refs + + +def validate_schema(schema: dict[str, Any], data: Any) -> list[str]: + try: + Draft202012Validator.check_schema(schema) + except SchemaError as error: + return [f"schema file is invalid: {error.message}"] + + validator = Draft202012Validator(schema) + errors = sorted( + validator.iter_errors(data), + key=lambda error: [str(part) for part in error.path], + ) + + messages = [] + for error in errors: + path = ".".join(str(part) for part in error.path) or "$" + messages.append(f"schema {path}: {error.message}") + return messages + + +def validate_assets(data: list[dict[str, Any]]) -> list[str]: + errors = [] + assets_root = ASSETS_DIR.resolve() + + for app in data: + for ref in asset_refs(app): + path = (ASSETS_DIR / ref).resolve() + try: + path.relative_to(assets_root) + except ValueError: + errors.append(f"{app['name']}: asset path escapes assets/: {ref}") + continue + + if not path.is_file(): + errors.append(f"{app['name']}: missing asset file assets/{ref}") + + return errors + + +def validate_executables(data: list[dict[str, Any]]) -> list[str]: + errors = [] + + for app in data: + for executable in app["executables"]: + name = executable["name"] + if name.startswith(">"): + continue + if name != name.lower(): + errors.append( + f"{app['name']}: executable name must be lowercase: {name}" + ) + + return errors + + +def main() -> int: + schema = load_json(SCHEMA_PATH) + data = load_json(DATA_PATH) + + errors = [] + errors.extend(validate_schema(schema, data)) + + if isinstance(data, list): + errors.extend(validate_assets(data)) + errors.extend(validate_executables(data)) + + if errors: + print("Detectables validation failed:") + for error in errors: + print(f"- {error}") + return 1 + + print("Detectables validation passed.") + print(f"- Schema: {SCHEMA_PATH.relative_to(ROOT)}") + print(f"- Data: {DATA_PATH.relative_to(ROOT)}") + print("- Asset references exist under assets/") + print("- Executable names are lowercase or use the > runtime prefix") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())