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, )