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
78 changes: 2 additions & 76 deletions .github/workflows/compliance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
63 changes: 49 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
```

Expand Down
3 changes: 2 additions & 1 deletion REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ path = [
"TAKEDOWN.md",
"REUSE.toml",
".github/**",
"schema/**"
"schema/**",
"scripts/**"
]
SPDX-FileCopyrightText = "2026 Fluxer"
SPDX-License-Identifier = "Apache-2.0"
Expand Down
30 changes: 24 additions & 6 deletions data/detectables.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
119 changes: 119 additions & 0 deletions scripts/validate_detectables.py
Original file line number Diff line number Diff line change
@@ -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())