934 lines
17 KiB
Python
934 lines
17 KiB
Python
import json
|
|
import socket
|
|
|
|
|
|
SOCKET_PATH = "/run/parental-control.sock"
|
|
|
|
|
|
WEEKDAY_NAMES = (
|
|
"sunday",
|
|
"monday",
|
|
"tuesday",
|
|
"wednesday",
|
|
"thursday",
|
|
"friday",
|
|
"saturday",
|
|
)
|
|
|
|
|
|
class IPCError(Exception):
|
|
pass
|
|
|
|
|
|
class AuthorizationError(IPCError):
|
|
pass
|
|
|
|
|
|
class DatabaseError(IPCError):
|
|
pass
|
|
|
|
|
|
def _gui_to_named_weekdays(
|
|
data,
|
|
):
|
|
"""
|
|
Convert the original GUI's numeric weekday
|
|
keys into the named weekday format used by
|
|
the daemon/configuration.
|
|
|
|
GUI:
|
|
0 = sunday
|
|
1 = monday
|
|
2 = tuesday
|
|
3 = wednesday
|
|
4 = thursday
|
|
5 = friday
|
|
6 = saturday
|
|
|
|
Config:
|
|
sunday
|
|
monday
|
|
...
|
|
saturday
|
|
"""
|
|
|
|
if not isinstance(
|
|
data,
|
|
dict,
|
|
):
|
|
return {}
|
|
|
|
converted = {}
|
|
|
|
for key, value in data.items():
|
|
|
|
try:
|
|
numeric_key = int(
|
|
key
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
numeric_key = None
|
|
|
|
if (
|
|
numeric_key is not None
|
|
and 0 <= numeric_key < len(
|
|
WEEKDAY_NAMES
|
|
)
|
|
):
|
|
weekday = WEEKDAY_NAMES[
|
|
numeric_key
|
|
]
|
|
|
|
else:
|
|
weekday = str(
|
|
key
|
|
).lower().strip()
|
|
|
|
converted[
|
|
weekday
|
|
] = value
|
|
|
|
return converted
|
|
|
|
|
|
def _time_to_minutes(
|
|
value,
|
|
):
|
|
"""
|
|
Convert a time value into minutes since
|
|
midnight.
|
|
|
|
Accepted formats include:
|
|
|
|
"16:00" -> 960
|
|
"21:00" -> 1260
|
|
|
|
Integer values are treated as minutes.
|
|
"""
|
|
|
|
if isinstance(
|
|
value,
|
|
str,
|
|
):
|
|
value = value.strip()
|
|
|
|
parts = value.split(
|
|
":"
|
|
)
|
|
|
|
if len(parts) == 2:
|
|
try:
|
|
hours = int(
|
|
parts[0]
|
|
)
|
|
minutes = int(
|
|
parts[1]
|
|
)
|
|
|
|
if (
|
|
0 <= hours <= 23
|
|
and 0 <= minutes <= 59
|
|
):
|
|
return (
|
|
hours * 60
|
|
+ minutes
|
|
)
|
|
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
pass
|
|
|
|
try:
|
|
return int(
|
|
value
|
|
)
|
|
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
return None
|
|
|
|
try:
|
|
return int(
|
|
value
|
|
)
|
|
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
return None
|
|
|
|
|
|
def _minutes_to_time(
|
|
value,
|
|
):
|
|
"""
|
|
Convert minutes since midnight into the
|
|
HH:MM string format used by the backend.
|
|
"""
|
|
|
|
try:
|
|
total_minutes = int(
|
|
value
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
return None
|
|
|
|
if not (
|
|
0 <= total_minutes < 24 * 60
|
|
):
|
|
return None
|
|
|
|
hours = (
|
|
total_minutes // 60
|
|
)
|
|
|
|
minutes = (
|
|
total_minutes % 60
|
|
)
|
|
|
|
return (
|
|
f"{hours:02d}:"
|
|
f"{minutes:02d}"
|
|
)
|
|
|
|
|
|
def _gui_to_named_access_windows(
|
|
data,
|
|
):
|
|
"""
|
|
Convert access windows from the format
|
|
used by the original GUI into the format
|
|
expected by the daemon.
|
|
|
|
GUI format:
|
|
|
|
{
|
|
4: [
|
|
[960, 1260]
|
|
]
|
|
}
|
|
|
|
Backend format:
|
|
|
|
{
|
|
"thursday": [
|
|
{
|
|
"start": "16:00",
|
|
"end": "21:00"
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
|
|
if not isinstance(
|
|
data,
|
|
dict,
|
|
):
|
|
return {}
|
|
|
|
converted = {}
|
|
|
|
for key, windows in data.items():
|
|
|
|
try:
|
|
numeric_key = int(
|
|
key
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
numeric_key = None
|
|
|
|
if (
|
|
numeric_key is not None
|
|
and 0 <= numeric_key < len(
|
|
WEEKDAY_NAMES
|
|
)
|
|
):
|
|
weekday = WEEKDAY_NAMES[
|
|
numeric_key
|
|
]
|
|
|
|
else:
|
|
weekday = str(
|
|
key
|
|
).lower().strip()
|
|
|
|
if not isinstance(
|
|
windows,
|
|
list,
|
|
):
|
|
continue
|
|
|
|
normalized_windows = []
|
|
|
|
for window in 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
|
|
|
|
start_time = (
|
|
_minutes_to_time(
|
|
start
|
|
)
|
|
)
|
|
|
|
end_time = (
|
|
_minutes_to_time(
|
|
end
|
|
)
|
|
)
|
|
|
|
if (
|
|
start_time is None
|
|
or end_time is None
|
|
):
|
|
continue
|
|
|
|
normalized_windows.append(
|
|
{
|
|
"start": start_time,
|
|
"end": end_time,
|
|
}
|
|
)
|
|
|
|
if normalized_windows:
|
|
converted[
|
|
weekday
|
|
] = normalized_windows
|
|
|
|
return converted
|
|
|
|
|
|
def _named_to_gui_allowances(
|
|
data,
|
|
):
|
|
"""
|
|
Convert named weekday allowance keys from
|
|
the daemon into numeric weekday keys expected
|
|
by the original GUI.
|
|
"""
|
|
|
|
if not isinstance(
|
|
data,
|
|
dict,
|
|
):
|
|
return {}
|
|
|
|
converted = {}
|
|
|
|
for index, weekday in enumerate(
|
|
WEEKDAY_NAMES
|
|
):
|
|
|
|
value = data.get(
|
|
weekday,
|
|
0,
|
|
)
|
|
|
|
try:
|
|
value = int(
|
|
value
|
|
)
|
|
except (
|
|
TypeError,
|
|
ValueError,
|
|
):
|
|
value = 0
|
|
|
|
converted[
|
|
index
|
|
] = max(
|
|
0,
|
|
value,
|
|
)
|
|
|
|
return converted
|
|
|
|
|
|
def _named_to_gui_access_windows(
|
|
data,
|
|
):
|
|
"""
|
|
Convert access windows returned by the
|
|
daemon into the exact representation expected
|
|
by the original GUI.
|
|
|
|
Backend:
|
|
|
|
{
|
|
"thursday": [
|
|
{
|
|
"start": "16:00",
|
|
"end": "21:00"
|
|
}
|
|
]
|
|
}
|
|
|
|
GUI:
|
|
|
|
{
|
|
4: [
|
|
[960, 1260]
|
|
]
|
|
}
|
|
"""
|
|
|
|
if not isinstance(
|
|
data,
|
|
dict,
|
|
):
|
|
return {}
|
|
|
|
converted = {}
|
|
|
|
for index, weekday in enumerate(
|
|
WEEKDAY_NAMES
|
|
):
|
|
|
|
if weekday not in data:
|
|
continue
|
|
|
|
windows = data[
|
|
weekday
|
|
]
|
|
|
|
if not isinstance(
|
|
windows,
|
|
list,
|
|
):
|
|
continue
|
|
|
|
gui_windows = []
|
|
|
|
for window in windows:
|
|
|
|
if not isinstance(
|
|
window,
|
|
dict,
|
|
):
|
|
continue
|
|
|
|
start = _time_to_minutes(
|
|
window.get(
|
|
"start"
|
|
)
|
|
)
|
|
|
|
end = _time_to_minutes(
|
|
window.get(
|
|
"end"
|
|
)
|
|
)
|
|
|
|
if (
|
|
start is None
|
|
or end is None
|
|
):
|
|
continue
|
|
|
|
gui_windows.append(
|
|
[
|
|
start,
|
|
end,
|
|
]
|
|
)
|
|
|
|
if gui_windows:
|
|
converted[
|
|
index
|
|
] = gui_windows
|
|
|
|
return converted
|
|
|
|
|
|
class IPCClient:
|
|
def __init__(
|
|
self,
|
|
socket_path=SOCKET_PATH,
|
|
):
|
|
self.socket_path = socket_path
|
|
|
|
def _request(
|
|
self,
|
|
method,
|
|
args=None,
|
|
):
|
|
request = {
|
|
"method": method,
|
|
"args": args or {},
|
|
}
|
|
|
|
try:
|
|
with socket.socket(
|
|
socket.AF_UNIX,
|
|
socket.SOCK_STREAM,
|
|
) as sock:
|
|
|
|
sock.connect(
|
|
self.socket_path
|
|
)
|
|
|
|
payload = json.dumps(
|
|
request
|
|
).encode(
|
|
"utf-8"
|
|
)
|
|
|
|
sock.sendall(
|
|
payload
|
|
)
|
|
|
|
sock.shutdown(
|
|
socket.SHUT_WR
|
|
)
|
|
|
|
chunks = []
|
|
|
|
while True:
|
|
chunk = sock.recv(
|
|
4096
|
|
)
|
|
|
|
if not chunk:
|
|
break
|
|
|
|
chunks.append(
|
|
chunk
|
|
)
|
|
|
|
except OSError as exc:
|
|
raise IPCError(
|
|
str(exc)
|
|
) from exc
|
|
|
|
try:
|
|
response = json.loads(
|
|
b"".join(
|
|
chunks
|
|
).decode(
|
|
"utf-8"
|
|
)
|
|
)
|
|
|
|
except (
|
|
json.JSONDecodeError,
|
|
UnicodeDecodeError,
|
|
) as exc:
|
|
raise IPCError(
|
|
"Invalid response from "
|
|
"parental-control daemon"
|
|
) from exc
|
|
|
|
if response.get(
|
|
"ok"
|
|
):
|
|
return response.get(
|
|
"result"
|
|
)
|
|
|
|
error = response.get(
|
|
"error",
|
|
"Unknown error",
|
|
)
|
|
|
|
if response.get(
|
|
"error_type"
|
|
) == "authorization":
|
|
raise AuthorizationError(
|
|
error
|
|
)
|
|
|
|
if response.get(
|
|
"error_type"
|
|
) == "database":
|
|
raise DatabaseError(
|
|
error
|
|
)
|
|
|
|
raise IPCError(
|
|
error
|
|
)
|
|
|
|
def get_users(
|
|
self,
|
|
):
|
|
return self._request(
|
|
"get_users"
|
|
)
|
|
|
|
def get_user(
|
|
self,
|
|
user_id,
|
|
):
|
|
return self._request(
|
|
"get_user",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
},
|
|
)
|
|
|
|
def get_remaining_time(
|
|
self,
|
|
user_id,
|
|
):
|
|
return self._request(
|
|
"get_remaining_time",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
},
|
|
)
|
|
|
|
def get_allowances(
|
|
self,
|
|
user_id,
|
|
):
|
|
result = self._request(
|
|
"get_allowances",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
},
|
|
)
|
|
|
|
return _named_to_gui_allowances(
|
|
result
|
|
)
|
|
|
|
def get_access_windows(
|
|
self,
|
|
user_id,
|
|
):
|
|
result = self._request(
|
|
"get_access_windows",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
},
|
|
)
|
|
|
|
return _named_to_gui_access_windows(
|
|
result
|
|
)
|
|
|
|
def create_user(
|
|
self,
|
|
username,
|
|
enabled,
|
|
allowances,
|
|
access_windows,
|
|
):
|
|
return self._request(
|
|
"create_user",
|
|
{
|
|
"username": username,
|
|
"enabled": bool(
|
|
enabled
|
|
),
|
|
"allowances": (
|
|
_gui_to_named_weekdays(
|
|
allowances
|
|
)
|
|
),
|
|
"access_windows": (
|
|
_gui_to_named_access_windows(
|
|
access_windows
|
|
)
|
|
),
|
|
},
|
|
)
|
|
|
|
def update_user(
|
|
self,
|
|
user_id,
|
|
enabled,
|
|
allowances,
|
|
access_windows,
|
|
):
|
|
return self._request(
|
|
"update_user",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
"enabled": bool(
|
|
enabled
|
|
),
|
|
"allowances": (
|
|
_gui_to_named_weekdays(
|
|
allowances
|
|
)
|
|
),
|
|
"access_windows": (
|
|
_gui_to_named_access_windows(
|
|
access_windows
|
|
)
|
|
),
|
|
},
|
|
)
|
|
|
|
def delete_user(
|
|
self,
|
|
user_id,
|
|
):
|
|
return self._request(
|
|
"delete_user",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
},
|
|
)
|
|
|
|
def set_enabled(
|
|
self,
|
|
user_id,
|
|
enabled,
|
|
):
|
|
return self._request(
|
|
"set_enabled",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
"enabled": bool(
|
|
enabled
|
|
),
|
|
},
|
|
)
|
|
|
|
def set_allowance(
|
|
self,
|
|
user_id,
|
|
weekday,
|
|
seconds,
|
|
):
|
|
return self._request(
|
|
"set_allowance",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
"weekday": str(
|
|
weekday
|
|
).lower(),
|
|
"seconds": int(
|
|
seconds
|
|
),
|
|
},
|
|
)
|
|
|
|
def set_access_windows(
|
|
self,
|
|
user_id,
|
|
weekday,
|
|
windows,
|
|
):
|
|
return self._request(
|
|
"set_access_windows",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
"weekday": str(
|
|
weekday
|
|
).lower(),
|
|
"windows": windows,
|
|
},
|
|
)
|
|
|
|
def add_temporary_time(
|
|
self,
|
|
user_id,
|
|
seconds,
|
|
):
|
|
return self._request(
|
|
"add_temporary_time",
|
|
{
|
|
"user_id": int(
|
|
user_id
|
|
),
|
|
"seconds": int(
|
|
seconds
|
|
),
|
|
},
|
|
)
|
|
|
|
|
|
class Database:
|
|
"""
|
|
Compatibility interface for the original GUI.
|
|
|
|
The original PySide6 GUI calls Database methods
|
|
directly on the class. These methods route
|
|
operations through the parental-control daemon.
|
|
"""
|
|
|
|
_client = IPCClient()
|
|
|
|
@classmethod
|
|
def get_users(
|
|
cls,
|
|
):
|
|
return cls._client.get_users()
|
|
|
|
@classmethod
|
|
def get_user(
|
|
cls,
|
|
user_id,
|
|
):
|
|
return cls._client.get_user(
|
|
user_id
|
|
)
|
|
|
|
@classmethod
|
|
def get_remaining_time(
|
|
cls,
|
|
user_id,
|
|
):
|
|
return cls._client.get_remaining_time(
|
|
user_id
|
|
)
|
|
|
|
@classmethod
|
|
def get_allowances(
|
|
cls,
|
|
user_id,
|
|
):
|
|
return cls._client.get_allowances(
|
|
user_id
|
|
)
|
|
|
|
@classmethod
|
|
def get_access_windows(
|
|
cls,
|
|
user_id,
|
|
):
|
|
return cls._client.get_access_windows(
|
|
user_id
|
|
)
|
|
|
|
@classmethod
|
|
def create_user(
|
|
cls,
|
|
username,
|
|
enabled,
|
|
allowances,
|
|
access_windows,
|
|
):
|
|
return cls._client.create_user(
|
|
username,
|
|
enabled,
|
|
allowances,
|
|
access_windows,
|
|
)
|
|
|
|
@classmethod
|
|
def update_user(
|
|
cls,
|
|
user_id,
|
|
enabled,
|
|
allowances,
|
|
access_windows,
|
|
):
|
|
return cls._client.update_user(
|
|
user_id,
|
|
enabled,
|
|
allowances,
|
|
access_windows,
|
|
)
|
|
|
|
@classmethod
|
|
def delete_user(
|
|
cls,
|
|
user_id,
|
|
):
|
|
return cls._client.delete_user(
|
|
user_id
|
|
)
|
|
|
|
@classmethod
|
|
def set_enabled(
|
|
cls,
|
|
user_id,
|
|
enabled,
|
|
):
|
|
return cls._client.set_enabled(
|
|
user_id,
|
|
enabled,
|
|
)
|
|
|
|
@classmethod
|
|
def set_allowance(
|
|
cls,
|
|
user_id,
|
|
weekday,
|
|
seconds,
|
|
):
|
|
return cls._client.set_allowance(
|
|
user_id,
|
|
weekday,
|
|
seconds,
|
|
)
|
|
|
|
@classmethod
|
|
def set_access_windows(
|
|
cls,
|
|
user_id,
|
|
weekday,
|
|
windows,
|
|
):
|
|
return cls._client.set_access_windows(
|
|
user_id,
|
|
weekday,
|
|
windows,
|
|
)
|
|
|
|
@classmethod
|
|
def add_temporary_time(
|
|
cls,
|
|
user_id,
|
|
seconds,
|
|
):
|
|
return cls._client.add_temporary_time(
|
|
user_id,
|
|
seconds,
|
|
)
|