updated gui and enforcement
This commit is contained in:
+569
@@ -0,0 +1,569 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CONFIG_DIR = PROJECT_ROOT / "config"
|
||||||
|
|
||||||
|
USERS_FILE = CONFIG_DIR / "users.yaml"
|
||||||
|
STATE_FILE = CONFIG_DIR / "state.json"
|
||||||
|
|
||||||
|
DAY_NAMES = (
|
||||||
|
"sunday",
|
||||||
|
"monday",
|
||||||
|
"tuesday",
|
||||||
|
"wednesday",
|
||||||
|
"thursday",
|
||||||
|
"friday",
|
||||||
|
"saturday",
|
||||||
|
)
|
||||||
|
|
||||||
|
_config_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_USERS_CONFIG = {
|
||||||
|
"users": []
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_STATE = {
|
||||||
|
"usage": {},
|
||||||
|
"temporary_grants": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_config_dir() -> None:
|
||||||
|
CONFIG_DIR.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(
|
||||||
|
path: Path,
|
||||||
|
content: str,
|
||||||
|
) -> None:
|
||||||
|
_ensure_config_dir()
|
||||||
|
|
||||||
|
directory = path.parent
|
||||||
|
|
||||||
|
fd, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=f".{path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=directory,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
temporary_path = Path(temporary_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with os.fdopen(
|
||||||
|
fd,
|
||||||
|
"w",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
file.write(content)
|
||||||
|
file.flush()
|
||||||
|
os.fsync(file.fileno())
|
||||||
|
|
||||||
|
os.replace(
|
||||||
|
temporary_path,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
temporary_path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_users_config(
|
||||||
|
data: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return copy.deepcopy(
|
||||||
|
DEFAULT_USERS_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
users = data.get("users")
|
||||||
|
|
||||||
|
if not isinstance(users, list):
|
||||||
|
users = []
|
||||||
|
|
||||||
|
normalized_users = []
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if not isinstance(user, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized = dict(user)
|
||||||
|
|
||||||
|
normalized.setdefault(
|
||||||
|
"enabled",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized.setdefault(
|
||||||
|
"daily_allowance",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized.setdefault(
|
||||||
|
"access_windows",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_users.append(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"users": normalized_users
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_state(
|
||||||
|
data: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return copy.deepcopy(
|
||||||
|
DEFAULT_STATE
|
||||||
|
)
|
||||||
|
|
||||||
|
state = dict(data)
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
state.get("usage"),
|
||||||
|
dict,
|
||||||
|
):
|
||||||
|
state["usage"] = {}
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
state.get("temporary_grants"),
|
||||||
|
list,
|
||||||
|
):
|
||||||
|
state["temporary_grants"] = []
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def load_users_config() -> dict[str, Any]:
|
||||||
|
with _config_lock:
|
||||||
|
_ensure_config_dir()
|
||||||
|
|
||||||
|
if not USERS_FILE.exists():
|
||||||
|
save_users_config(
|
||||||
|
DEFAULT_USERS_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with USERS_FILE.open(
|
||||||
|
"r",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
data = yaml.safe_load(file)
|
||||||
|
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
yaml.YAMLError,
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to read {USERS_FILE}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _normalize_users_config(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_users_config(
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
with _config_lock:
|
||||||
|
normalized = _normalize_users_config(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
content = yaml.safe_dump(
|
||||||
|
normalized,
|
||||||
|
sort_keys=False,
|
||||||
|
allow_unicode=True,
|
||||||
|
default_flow_style=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_atomic_write(
|
||||||
|
USERS_FILE,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict[str, Any]:
|
||||||
|
with _config_lock:
|
||||||
|
_ensure_config_dir()
|
||||||
|
|
||||||
|
if not STATE_FILE.exists():
|
||||||
|
save_state(
|
||||||
|
DEFAULT_STATE
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with STATE_FILE.open(
|
||||||
|
"r",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
data = json.load(file)
|
||||||
|
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to read {STATE_FILE}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _normalize_state(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
with _config_lock:
|
||||||
|
normalized = _normalize_state(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
content = json.dumps(
|
||||||
|
normalized,
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_atomic_write(
|
||||||
|
STATE_FILE,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def next_user_id(
|
||||||
|
users: list[dict[str, Any]],
|
||||||
|
) -> int:
|
||||||
|
highest = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
try:
|
||||||
|
user_id = int(
|
||||||
|
user.get("id", 0)
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
ValueError,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
highest = max(
|
||||||
|
highest,
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return highest + 1
|
||||||
|
|
||||||
|
|
||||||
|
def find_user(
|
||||||
|
user_id: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
config = load_users_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
requested_id = int(user_id)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
ValueError,
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
for user in config["users"]:
|
||||||
|
try:
|
||||||
|
current_id = int(
|
||||||
|
user.get("id")
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
ValueError,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_id == requested_id:
|
||||||
|
return user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_user_by_username(
|
||||||
|
username: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
config = load_users_config()
|
||||||
|
|
||||||
|
for user in config["users"]:
|
||||||
|
if (
|
||||||
|
str(
|
||||||
|
user.get("username", "")
|
||||||
|
)
|
||||||
|
== username
|
||||||
|
):
|
||||||
|
return user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_day_name(
|
||||||
|
weekday: int,
|
||||||
|
) -> str:
|
||||||
|
weekday = int(weekday)
|
||||||
|
|
||||||
|
if not 0 <= weekday <= 6:
|
||||||
|
raise ValueError(
|
||||||
|
"weekday must be between 0 and 6"
|
||||||
|
)
|
||||||
|
|
||||||
|
return DAY_NAMES[weekday]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_day_name(
|
||||||
|
day: str,
|
||||||
|
) -> str:
|
||||||
|
value = str(day).strip().lower()
|
||||||
|
|
||||||
|
aliases = {
|
||||||
|
"sun": "sunday",
|
||||||
|
"mon": "monday",
|
||||||
|
"tue": "tuesday",
|
||||||
|
"tues": "tuesday",
|
||||||
|
"wed": "wednesday",
|
||||||
|
"thu": "thursday",
|
||||||
|
"thur": "thursday",
|
||||||
|
"thurs": "thursday",
|
||||||
|
"fri": "friday",
|
||||||
|
"sat": "saturday",
|
||||||
|
}
|
||||||
|
|
||||||
|
return aliases.get(
|
||||||
|
value,
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def python_weekday_to_name(
|
||||||
|
python_weekday: int,
|
||||||
|
) -> str:
|
||||||
|
python_weekday = int(
|
||||||
|
python_weekday
|
||||||
|
)
|
||||||
|
|
||||||
|
if not 0 <= python_weekday <= 6:
|
||||||
|
raise ValueError(
|
||||||
|
"weekday must be between 0 and 6"
|
||||||
|
)
|
||||||
|
|
||||||
|
return DAY_NAMES[
|
||||||
|
(python_weekday + 1) % 7
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time(
|
||||||
|
value: Any,
|
||||||
|
) -> int:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return max(
|
||||||
|
0,
|
||||||
|
min(
|
||||||
|
value,
|
||||||
|
1439,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
|
||||||
|
if ":" not in text:
|
||||||
|
return max(
|
||||||
|
0,
|
||||||
|
min(
|
||||||
|
int(text),
|
||||||
|
1439,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
hours_text, minutes_text = (
|
||||||
|
text.split(
|
||||||
|
":",
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
hours = int(
|
||||||
|
hours_text
|
||||||
|
)
|
||||||
|
|
||||||
|
minutes = int(
|
||||||
|
minutes_text
|
||||||
|
)
|
||||||
|
|
||||||
|
if not 0 <= hours <= 23:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid hour: {hours}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not 0 <= minutes <= 59:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid minute: {minutes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
hours * 60
|
||||||
|
+ minutes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(
|
||||||
|
minute: int,
|
||||||
|
) -> str:
|
||||||
|
minute = int(minute)
|
||||||
|
|
||||||
|
minute = max(
|
||||||
|
0,
|
||||||
|
min(
|
||||||
|
minute,
|
||||||
|
1439,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
hours = minute // 60
|
||||||
|
minutes = minute % 60
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{hours:02d}:"
|
||||||
|
f"{minutes:02d}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_allowances(
|
||||||
|
allowances: Any,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
if not isinstance(
|
||||||
|
allowances,
|
||||||
|
dict,
|
||||||
|
):
|
||||||
|
allowances = {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for day in DAY_NAMES:
|
||||||
|
value = allowances.get(
|
||||||
|
day,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
seconds = int(value)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
ValueError,
|
||||||
|
):
|
||||||
|
seconds = 0
|
||||||
|
|
||||||
|
result[day] = max(
|
||||||
|
0,
|
||||||
|
seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_access_windows(
|
||||||
|
windows: Any,
|
||||||
|
) -> dict[str, list[dict[str, str]]]:
|
||||||
|
if not isinstance(
|
||||||
|
windows,
|
||||||
|
dict,
|
||||||
|
):
|
||||||
|
windows = {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for day in DAY_NAMES:
|
||||||
|
day_windows = windows.get(
|
||||||
|
day,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(
|
||||||
|
day_windows,
|
||||||
|
list,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
|
||||||
|
for window in day_windows:
|
||||||
|
if isinstance(
|
||||||
|
window,
|
||||||
|
dict,
|
||||||
|
):
|
||||||
|
start = window.get(
|
||||||
|
"start"
|
||||||
|
)
|
||||||
|
end = window.get(
|
||||||
|
"end"
|
||||||
|
)
|
||||||
|
|
||||||
|
elif (
|
||||||
|
isinstance(
|
||||||
|
window,
|
||||||
|
(list, tuple),
|
||||||
|
)
|
||||||
|
and len(window) >= 2
|
||||||
|
):
|
||||||
|
start = window[0]
|
||||||
|
end = window[1]
|
||||||
|
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_minute = parse_time(
|
||||||
|
start
|
||||||
|
)
|
||||||
|
end_minute = parse_time(
|
||||||
|
end
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
TypeError,
|
||||||
|
ValueError,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized.append(
|
||||||
|
{
|
||||||
|
"start": format_time(
|
||||||
|
start_minute
|
||||||
|
),
|
||||||
|
"end": format_time(
|
||||||
|
end_minute
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if normalized:
|
||||||
|
result[day] = normalized
|
||||||
|
|
||||||
|
return result
|
||||||
-105
@@ -1,105 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
DATABASE_DIR = BASE_DIR / "data"
|
|
||||||
DATABASE_PATH = DATABASE_DIR / "parental-control.db"
|
|
||||||
|
|
||||||
|
|
||||||
def initialize_database():
|
|
||||||
DATABASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with sqlite3.connect(DATABASE_PATH) as db:
|
|
||||||
db.execute("PRAGMA foreign_keys = ON")
|
|
||||||
|
|
||||||
db.executescript(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_allowances (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
weekday INTEGER NOT NULL,
|
|
||||||
allowance_seconds INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
UNIQUE(user_id, weekday),
|
|
||||||
|
|
||||||
FOREIGN KEY(user_id)
|
|
||||||
REFERENCES users(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS access_windows (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
weekday INTEGER NOT NULL,
|
|
||||||
start_minute INTEGER NOT NULL,
|
|
||||||
end_minute INTEGER NOT NULL,
|
|
||||||
|
|
||||||
FOREIGN KEY(user_id)
|
|
||||||
REFERENCES users(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS usage (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
used_seconds INTEGER NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
UNIQUE(user_id, date),
|
|
||||||
|
|
||||||
FOREIGN KEY(user_id)
|
|
||||||
REFERENCES users(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS temporary_grants (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
seconds INTEGER NOT NULL,
|
|
||||||
remaining_seconds INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
expires_at TEXT,
|
|
||||||
consumed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS events (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER,
|
|
||||||
event_type TEXT NOT NULL,
|
|
||||||
details TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
FOREIGN KEY(user_id)
|
|
||||||
REFERENCES users(id)
|
|
||||||
ON DELETE SET NULL
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def get_db():
|
|
||||||
db = sqlite3.connect(DATABASE_PATH)
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
db.execute("PRAGMA foreign_keys = ON")
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
db.commit()
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
+728
-281
File diff suppressed because it is too large
Load Diff
+659
-308
File diff suppressed because it is too large
Load Diff
+10
-11
@@ -1,25 +1,24 @@
|
|||||||
import time
|
from .ipc import IPCServer
|
||||||
|
from .scheduler import Scheduler
|
||||||
from .database import initialize_database
|
|
||||||
from .scheduler import start_scheduler, stop_scheduler
|
|
||||||
from .ipc import start_ipc_server, stop_ipc_server
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
initialize_database()
|
scheduler = Scheduler()
|
||||||
start_ipc_server()
|
ipc = IPCServer()
|
||||||
start_scheduler()
|
|
||||||
|
scheduler.start()
|
||||||
|
ipc.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(60)
|
ipc._stop_event.wait(3600)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
stop_scheduler()
|
ipc.stop()
|
||||||
stop_ipc_server()
|
scheduler.stop()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class User:
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
enabled: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DailyAllowance:
|
|
||||||
user_id: int
|
|
||||||
weekday: int
|
|
||||||
allowance_seconds: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AccessWindow:
|
|
||||||
id: int
|
|
||||||
user_id: int
|
|
||||||
weekday: int
|
|
||||||
start_minute: int
|
|
||||||
end_minute: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TemporaryGrant:
|
|
||||||
id: int
|
|
||||||
user_id: int
|
|
||||||
seconds: int
|
|
||||||
expires_at: Optional[str]
|
|
||||||
consumed: bool
|
|
||||||
+44
-126
@@ -1,33 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
import traceback
|
||||||
|
|
||||||
from .enforcement import (
|
from .enforcement import (
|
||||||
enforce_all_users,
|
enforce_all_users,
|
||||||
record_usage,
|
|
||||||
get_user_policy,
|
|
||||||
consume_grant_seconds,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CHECK_INTERVAL = 5
|
|
||||||
|
|
||||||
|
|
||||||
class Scheduler:
|
class Scheduler:
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
interval: int = CHECK_INTERVAL,
|
interval_seconds: float = 1.0,
|
||||||
):
|
):
|
||||||
self.interval = interval
|
self.interval_seconds = max(
|
||||||
|
0.1,
|
||||||
|
float(
|
||||||
|
interval_seconds
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._stop_event = (
|
||||||
|
threading.Event()
|
||||||
|
)
|
||||||
|
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._stop_event = threading.Event()
|
|
||||||
|
|
||||||
self._last_usage_update = {}
|
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self._thread is not None
|
self._thread is not None
|
||||||
and self._thread.is_alive()
|
and self._thread.is_alive()
|
||||||
@@ -45,139 +46,56 @@ class Scheduler:
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
|
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
||||||
if self._thread is not None:
|
if self._thread is not None:
|
||||||
self._thread.join(
|
self._thread.join(
|
||||||
timeout=self.interval + 2
|
timeout=3
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._thread = None
|
||||||
|
|
||||||
|
def run_once(self):
|
||||||
|
return enforce_all_users()
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
# Evaluate immediately when the
|
try:
|
||||||
# application starts.
|
self.run_once()
|
||||||
self._tick()
|
|
||||||
|
|
||||||
while not self._stop_event.wait(
|
except Exception:
|
||||||
self.interval
|
traceback.print_exc()
|
||||||
):
|
|
||||||
self._tick()
|
|
||||||
|
|
||||||
def _tick(self):
|
elapsed = (
|
||||||
|
time.monotonic()
|
||||||
now = time.monotonic()
|
- started
|
||||||
|
|
||||||
results = enforce_all_users()
|
|
||||||
|
|
||||||
for result in results:
|
|
||||||
|
|
||||||
user_id = result["user_id"]
|
|
||||||
|
|
||||||
if not result["logged_in"]:
|
|
||||||
self._last_usage_update.pop(
|
|
||||||
user_id,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not result["allowed"]:
|
|
||||||
self._last_usage_update.pop(
|
|
||||||
user_id,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
previous = (
|
|
||||||
self._last_usage_update.get(
|
|
||||||
user_id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._last_usage_update[user_id] = now
|
remaining = max(
|
||||||
|
0.0,
|
||||||
if previous is None:
|
self.interval_seconds
|
||||||
continue
|
- elapsed,
|
||||||
|
|
||||||
elapsed = int(
|
|
||||||
now - previous
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if elapsed <= 0:
|
self._stop_event.wait(
|
||||||
continue
|
remaining
|
||||||
|
|
||||||
self._record_allowed_usage(
|
|
||||||
user_id,
|
|
||||||
elapsed,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _record_allowed_usage(
|
|
||||||
self,
|
|
||||||
user_id: int,
|
|
||||||
seconds: int,
|
|
||||||
):
|
|
||||||
|
|
||||||
if seconds <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
weekday = datetime.now().weekday()
|
|
||||||
|
|
||||||
(
|
|
||||||
allowance_seconds,
|
|
||||||
usage_seconds,
|
|
||||||
windows,
|
|
||||||
grant_seconds,
|
|
||||||
) = get_user_policy(
|
|
||||||
user_id,
|
|
||||||
weekday,
|
|
||||||
)
|
|
||||||
|
|
||||||
allowance_remaining = max(
|
|
||||||
0,
|
|
||||||
allowance_seconds
|
|
||||||
- usage_seconds,
|
|
||||||
)
|
|
||||||
|
|
||||||
normal_usage = min(
|
|
||||||
seconds,
|
|
||||||
allowance_remaining,
|
|
||||||
)
|
|
||||||
|
|
||||||
grant_usage = (
|
|
||||||
seconds
|
|
||||||
- normal_usage
|
|
||||||
)
|
|
||||||
|
|
||||||
if grant_usage > grant_seconds:
|
|
||||||
grant_usage = grant_seconds
|
|
||||||
|
|
||||||
total_usage = (
|
|
||||||
normal_usage
|
|
||||||
+ grant_usage
|
|
||||||
)
|
|
||||||
|
|
||||||
if total_usage <= 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
record_usage(
|
|
||||||
user_id,
|
|
||||||
total_usage,
|
|
||||||
)
|
|
||||||
|
|
||||||
if grant_usage > 0:
|
|
||||||
|
|
||||||
consume_grant_seconds(
|
|
||||||
user_id,
|
|
||||||
grant_usage,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
scheduler = Scheduler()
|
_scheduler = Scheduler(
|
||||||
|
interval_seconds=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler():
|
def start_scheduler():
|
||||||
scheduler.start()
|
_scheduler.start()
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler():
|
def stop_scheduler():
|
||||||
scheduler.stop()
|
_scheduler.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def run_scheduler_once():
|
||||||
|
return _scheduler.run_once()
|
||||||
|
|||||||
+126
-26
@@ -1,59 +1,159 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pwd
|
import pwd
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
def linux_user_exists(username: str) -> bool:
|
def user_exists(
|
||||||
|
username: str,
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
pwd.getpwnam(username)
|
pwd.getpwnam(
|
||||||
|
username
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_uid(username: str) -> int:
|
def _run(
|
||||||
return pwd.getpwnam(username).pw_uid
|
command: list[str],
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
def is_locked(username: str) -> bool:
|
command,
|
||||||
result = subprocess.run(
|
|
||||||
["passwd", "-S", username],
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_locked(
|
||||||
|
username: str,
|
||||||
|
) -> bool:
|
||||||
|
if not user_exists(username):
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
[
|
||||||
|
"passwd",
|
||||||
|
"-S",
|
||||||
|
username,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
parts = result.stdout.split()
|
parts = result.stdout.strip().split()
|
||||||
|
|
||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return parts[1] == "L"
|
status = parts[1].upper()
|
||||||
|
|
||||||
|
return status.startswith("L")
|
||||||
|
|
||||||
|
|
||||||
def lock_user(username: str):
|
def lock_user(
|
||||||
subprocess.run(
|
username: str,
|
||||||
["loginctl", "terminate-user", username],
|
) -> None:
|
||||||
check=False,
|
if not user_exists(username):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Linux user does not exist: {username}"
|
||||||
)
|
)
|
||||||
|
|
||||||
subprocess.run(
|
result = _run(
|
||||||
["passwd", "-l", username],
|
[
|
||||||
check=True,
|
"passwd",
|
||||||
|
"-l",
|
||||||
|
username,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = (
|
||||||
|
result.stderr.strip()
|
||||||
|
or result.stdout.strip()
|
||||||
|
or "unknown error"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to lock {username}: {error}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def unlock_user(username: str):
|
def unlock_user(
|
||||||
subprocess.run(
|
username: str,
|
||||||
["passwd", "-u", username],
|
) -> None:
|
||||||
check=True,
|
if not user_exists(username):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Linux user does not exist: {username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
[
|
||||||
|
"passwd",
|
||||||
|
"-u",
|
||||||
|
username,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = (
|
||||||
|
result.stderr.strip()
|
||||||
|
or result.stdout.strip()
|
||||||
|
or "unknown error"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to unlock {username}: {error}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def terminate_user(username: str):
|
def terminate_user(
|
||||||
subprocess.run(
|
username: str,
|
||||||
["loginctl", "terminate-user", username],
|
) -> None:
|
||||||
check=False,
|
if not user_exists(username):
|
||||||
|
return
|
||||||
|
|
||||||
|
result = _run(
|
||||||
|
[
|
||||||
|
"loginctl",
|
||||||
|
"terminate-user",
|
||||||
|
username,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
error = (
|
||||||
|
result.stderr.strip()
|
||||||
|
or result.stdout.strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to terminate "
|
||||||
|
f"{username}: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_uid(
|
||||||
|
username: str,
|
||||||
|
) -> int | None:
|
||||||
|
try:
|
||||||
|
return pwd.getpwnam(
|
||||||
|
username
|
||||||
|
).pw_uid
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_home(
|
||||||
|
username: str,
|
||||||
|
) -> str | None:
|
||||||
|
try:
|
||||||
|
return pwd.getpwnam(
|
||||||
|
username
|
||||||
|
).pw_dir
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"usage": {},
|
||||||
|
"temporary_grants": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
users:
|
||||||
|
- id: 1
|
||||||
|
username: testuser1
|
||||||
|
enabled: true
|
||||||
|
daily_allowance:
|
||||||
|
sunday: 0
|
||||||
|
monday: 0
|
||||||
|
tuesday: 0
|
||||||
|
wednesday: 0
|
||||||
|
thursday: 600
|
||||||
|
friday: 0
|
||||||
|
saturday: 0
|
||||||
|
access_windows:
|
||||||
|
thursday:
|
||||||
|
- start: '16:00'
|
||||||
|
end: '21:00'
|
||||||
|
- id: 2
|
||||||
|
username: testuser2
|
||||||
|
enabled: false
|
||||||
|
daily_allowance:
|
||||||
|
sunday: 0
|
||||||
|
monday: 0
|
||||||
|
tuesday: 0
|
||||||
|
wednesday: 0
|
||||||
|
thursday: 0
|
||||||
|
friday: 0
|
||||||
|
saturday: 0
|
||||||
|
access_windows: {}
|
||||||
+823
-155
File diff suppressed because it is too large
Load Diff
+38
-40
@@ -17,7 +17,7 @@ PYTHON="$VENV_DIR/bin/python"
|
|||||||
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME"
|
SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME"
|
||||||
DESKTOP_FILE="/usr/share/applications/$DESKTOP_ID"
|
DESKTOP_FILE="/usr/share/applications/$DESKTOP_ID"
|
||||||
|
|
||||||
POLKIT_ACTION="org.sarlink.parentalcontrol.modify"
|
POLKIT_ACTION="org.parentalcontrol.modify"
|
||||||
POLKIT_SOURCE="$INSTALL_DIR/$POLKIT_ACTION.policy"
|
POLKIT_SOURCE="$INSTALL_DIR/$POLKIT_ACTION.policy"
|
||||||
POLKIT_DEST="/etc/polkit-1/actions/$POLKIT_ACTION.policy"
|
POLKIT_DEST="/etc/polkit-1/actions/$POLKIT_ACTION.policy"
|
||||||
|
|
||||||
@@ -117,16 +117,22 @@ info "Checking project files"
|
|||||||
REQUIRED_FILES=(
|
REQUIRED_FILES=(
|
||||||
"requirements.txt"
|
"requirements.txt"
|
||||||
"parental-control.service"
|
"parental-control.service"
|
||||||
|
"$POLKIT_ACTION.policy"
|
||||||
|
|
||||||
"app/__init__.py"
|
"app/__init__.py"
|
||||||
"app/main.py"
|
"app/main.py"
|
||||||
"app/database.py"
|
"app/config.py"
|
||||||
"app/enforcement.py"
|
"app/enforcement.py"
|
||||||
"app/scheduler.py"
|
"app/scheduler.py"
|
||||||
"app/users.py"
|
"app/users.py"
|
||||||
"app/ipc.py"
|
"app/ipc.py"
|
||||||
|
|
||||||
"desktop/__init__.py"
|
"desktop/__init__.py"
|
||||||
"desktop/main.py"
|
"desktop/main.py"
|
||||||
"desktop/client.py"
|
"desktop/client.py"
|
||||||
|
|
||||||
|
"config/users.yaml"
|
||||||
|
"config/state.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
for file in "${REQUIRED_FILES[@]}"; do
|
for file in "${REQUIRED_FILES[@]}"; do
|
||||||
@@ -425,26 +431,20 @@ success "Source tree owned by $INSTALL_USER."
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Prepare data directory
|
# Verify configuration
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
info "Preparing application data"
|
info "Checking application configuration"
|
||||||
|
|
||||||
mkdir -p "$INSTALL_DIR/data"
|
if [ ! -f "$INSTALL_DIR/config/users.yaml" ]; then
|
||||||
|
error "users.yaml was not found."
|
||||||
# Database is owned by the root service.
|
|
||||||
chown root:root "$INSTALL_DIR/data"
|
|
||||||
chmod 700 "$INSTALL_DIR/data"
|
|
||||||
|
|
||||||
if [ -f "$INSTALL_DIR/data/parental-control.db" ]; then
|
|
||||||
chown root:root \
|
|
||||||
"$INSTALL_DIR/data/parental-control.db"
|
|
||||||
|
|
||||||
chmod 600 \
|
|
||||||
"$INSTALL_DIR/data/parental-control.db"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
success "Data directory secured."
|
if [ ! -f "$INSTALL_DIR/config/state.json" ]; then
|
||||||
|
error "state.json was not found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
success "YAML/JSON configuration files found."
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -502,7 +502,7 @@ if [ -f "$POLKIT_SOURCE" ]; then
|
|||||||
|
|
||||||
else
|
else
|
||||||
|
|
||||||
warning "Polkit policy file is not present yet."
|
warning "Polkit policy file is not present."
|
||||||
warning "Expected: $POLKIT_SOURCE"
|
warning "Expected: $POLKIT_SOURCE"
|
||||||
warning "Skipping polkit policy installation."
|
warning "Skipping polkit policy installation."
|
||||||
|
|
||||||
@@ -678,28 +678,6 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Verify database
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
info "Checking database"
|
|
||||||
|
|
||||||
if [ -f "$INSTALL_DIR/data/parental-control.db" ]; then
|
|
||||||
|
|
||||||
DB_OWNER="$(stat -c '%U:%G' "$INSTALL_DIR/data/parental-control.db")"
|
|
||||||
DB_MODE="$(stat -c '%a' "$INSTALL_DIR/data/parental-control.db")"
|
|
||||||
|
|
||||||
echo " Owner: $DB_OWNER"
|
|
||||||
echo " Permissions: $DB_MODE"
|
|
||||||
|
|
||||||
else
|
|
||||||
|
|
||||||
warning "Database does not exist yet."
|
|
||||||
warning "The service should create it automatically."
|
|
||||||
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Final verification
|
# Final verification
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -747,6 +725,22 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if [ -f "$INSTALL_DIR/config/users.yaml" ]; then
|
||||||
|
echo " Users: OK"
|
||||||
|
else
|
||||||
|
echo " Users: FAILED"
|
||||||
|
CHECK_FAILED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
if [ -f "$INSTALL_DIR/config/state.json" ]; then
|
||||||
|
echo " State: OK"
|
||||||
|
else
|
||||||
|
echo " State: FAILED"
|
||||||
|
CHECK_FAILED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
if [ "$CHECK_FAILED" -ne 0 ]; then
|
if [ "$CHECK_FAILED" -ne 0 ]; then
|
||||||
error "One or more required installation checks failed."
|
error "One or more required installation checks failed."
|
||||||
fi
|
fi
|
||||||
@@ -780,6 +774,10 @@ echo
|
|||||||
echo "IPC:"
|
echo "IPC:"
|
||||||
echo " $SOCKET_PATH"
|
echo " $SOCKET_PATH"
|
||||||
echo
|
echo
|
||||||
|
echo "Configuration:"
|
||||||
|
echo " $INSTALL_DIR/config/users.yaml"
|
||||||
|
echo " $INSTALL_DIR/config/state.json"
|
||||||
|
echo
|
||||||
echo "Installation:"
|
echo "Installation:"
|
||||||
echo " $INSTALL_DIR"
|
echo " $INSTALL_DIR"
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE policyconfig PUBLIC
|
||||||
|
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||||
|
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
|
||||||
|
|
||||||
|
<policyconfig>
|
||||||
|
|
||||||
|
<action id="org.parentalcontrol.modify">
|
||||||
|
|
||||||
|
<description>
|
||||||
|
Modify Linux parental control settings
|
||||||
|
</description>
|
||||||
|
|
||||||
|
<message>
|
||||||
|
Authentication is required to modify parental control settings.
|
||||||
|
</message>
|
||||||
|
|
||||||
|
<defaults>
|
||||||
|
<allow_any>auth_admin</allow_any>
|
||||||
|
<allow_inactive>auth_admin</allow_inactive>
|
||||||
|
<allow_active>auth_admin</allow_active>
|
||||||
|
</defaults>
|
||||||
|
|
||||||
|
</action>
|
||||||
|
|
||||||
|
</policyconfig>
|
||||||
+2
-1
@@ -1 +1,2 @@
|
|||||||
PySide6==6.11.2
|
PySide6
|
||||||
|
PyYAML
|
||||||
|
|||||||
Reference in New Issue
Block a user