commit 8b785b5480d750541c2090d5dccf5e0147b247ce Author: alex Date: Thu Sep 17 12:54:31 2026 +0500 Initial native Linux parental control application diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3ad9ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ + +data/*.db +data/*.db-* + +.env + +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5257c25 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Linux Parental Control + +A Linux parental-control system for managing Linux user access, daily time allowances, access windows, temporary grants, and automatic session enforcement. + +> **Status:** Early development + +## Features + +- Per-user daily time allowances +- Different allowances for each day of the week +- Multiple access windows per day +- Temporary time grants +- Usage tracking +- Automatic session termination +- Automatic account locking +- Automatic account unlocking +- Reboot-safe enforcement +- Web-based administration +- SQLite database +- systemd service support + +--- + +# Requirements + +The application currently targets Linux systems using `systemd`. + +You need: + +- Linux +- Python 3 +- `python-venv` +- `pip` +- `systemd` +- `sudo` +- `passwd` +- `loginctl` +- Git + +The enforcement service requires **root privileges** because it manages other Linux users and their sessions. + +--- + +# 1. Clone the Repository + +Clone the repository: + +```bash +git clone https://github.com/Alsantek-me/linux-user-timer.git +``` + +## 2. Make it executable +```bash +chmod +x install.sh +``` + +## 3. Run the install script +```bash +sudo ./install.sh +``` +### You can access it via http://127.0.0.1:8765/admin + + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..1ab22fa --- /dev/null +++ b/app/database.py @@ -0,0 +1,105 @@ +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() diff --git a/app/enforcement.py b/app/enforcement.py new file mode 100644 index 0000000..f8e9112 --- /dev/null +++ b/app/enforcement.py @@ -0,0 +1,455 @@ +from datetime import datetime +import subprocess + +from .database import get_db +from .users import ( + lock_user, + unlock_user, + terminate_user, + is_locked, +) + + +def user_has_session(username: str) -> bool: + result = subprocess.run( + [ + "loginctl", + "list-users", + "--no-legend", + ], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + return False + + for line in result.stdout.splitlines(): + parts = line.split() + + if len(parts) >= 2 and parts[1] == username: + return True + + return False + + +def current_time(): + now = datetime.now() + weekday = now.weekday() + minute = now.hour * 60 + now.minute + + return now, weekday, minute + + +def get_user_policy(user_id: int, weekday: int): + with get_db() as db: + allowance_row = db.execute( + """ + SELECT allowance_seconds + FROM daily_allowances + WHERE user_id = ? + AND weekday = ? + """, + ( + user_id, + weekday, + ), + ).fetchone() + + allowance_seconds = ( + allowance_row["allowance_seconds"] + if allowance_row + else 0 + ) + + today = datetime.now().date().isoformat() + + usage_row = db.execute( + """ + SELECT used_seconds + FROM usage + WHERE user_id = ? + AND date = ? + """, + ( + user_id, + today, + ), + ).fetchone() + + usage_seconds = ( + usage_row["used_seconds"] + if usage_row + else 0 + ) + + windows = db.execute( + """ + SELECT id, start_minute, end_minute + FROM access_windows + WHERE user_id = ? + AND weekday = ? + ORDER BY start_minute + """, + ( + user_id, + weekday, + ), + ).fetchall() + + grant_row = db.execute( + """ + SELECT COALESCE( + SUM(remaining_seconds), + 0 + ) AS total + FROM temporary_grants + WHERE user_id = ? + AND consumed = 0 + AND ( + expires_at IS NULL + OR expires_at > ? + ) + """, + ( + user_id, + datetime.now().isoformat(), + ), + ).fetchone() + + grant_seconds = grant_row["total"] + + return ( + allowance_seconds, + usage_seconds, + windows, + grant_seconds, + ) + + +def is_inside_window(windows, minute: int) -> bool: + if not windows: + return True + + for window in windows: + if ( + window["start_minute"] + <= minute + < window["end_minute"] + ): + return True + + return False + + +def get_remaining_grant_seconds(user_id: int) -> int: + now = datetime.now().isoformat() + + with get_db() as db: + row = db.execute( + """ + SELECT COALESCE( + SUM(remaining_seconds), + 0 + ) AS total + FROM temporary_grants + WHERE user_id = ? + AND consumed = 0 + AND ( + expires_at IS NULL + OR expires_at > ? + ) + """, + ( + user_id, + now, + ), + ).fetchone() + + return row["total"] + + +def consume_grant_seconds( + user_id: int, + seconds: int, +): + if seconds <= 0: + return + + now = datetime.now().isoformat() + + with get_db() as db: + grants = db.execute( + """ + SELECT id, remaining_seconds + FROM temporary_grants + WHERE user_id = ? + AND consumed = 0 + AND remaining_seconds > 0 + AND ( + expires_at IS NULL + OR expires_at > ? + ) + ORDER BY id ASC + """, + ( + user_id, + now, + ), + ).fetchall() + + remaining = seconds + + for grant in grants: + if remaining <= 0: + break + + available = grant["remaining_seconds"] + + consumed = min( + available, + remaining, + ) + + new_remaining = ( + available - consumed + ) + + db.execute( + """ + UPDATE temporary_grants + SET remaining_seconds = ?, + consumed = ? + WHERE id = ? + """, + ( + new_remaining, + 1 if new_remaining <= 0 else 0, + grant["id"], + ), + ) + + remaining -= consumed + + +def record_usage( + user_id: int, + seconds: int, +): + if seconds <= 0: + return + + today = datetime.now().date().isoformat() + + with get_db() as db: + row = db.execute( + """ + SELECT used_seconds + FROM usage + WHERE user_id = ? + AND date = ? + """, + ( + user_id, + today, + ), + ).fetchone() + + if row is None: + db.execute( + """ + INSERT INTO usage ( + user_id, + date, + used_seconds + ) + VALUES (?, ?, ?) + """, + ( + user_id, + today, + seconds, + ), + ) + else: + db.execute( + """ + UPDATE usage + SET used_seconds = + used_seconds + ? + WHERE user_id = ? + AND date = ? + """, + ( + seconds, + user_id, + today, + ), + ) + + +def record_event( + user_id: int, + event_type: str, + details: str = "", +): + with get_db() as db: + db.execute( + """ + INSERT INTO events ( + user_id, + event_type, + details + ) + VALUES (?, ?, ?) + """, + ( + user_id, + event_type, + details, + ), + ) + + +def evaluate_user( + user_id: int, + username: str, +): + now, weekday, minute = current_time() + + ( + allowance_seconds, + usage_seconds, + windows, + grant_seconds, + ) = get_user_policy( + user_id, + weekday, + ) + + inside_window = is_inside_window( + windows, + minute, + ) + + allowance_remaining = max( + 0, + allowance_seconds - usage_seconds, + ) + + total_remaining = ( + allowance_remaining + + grant_seconds + ) + + logged_in = user_has_session( + username + ) + + allowed_by_schedule = ( + inside_window + and allowance_remaining > 0 + ) + + allowed_by_grant = ( + grant_seconds > 0 + ) + + should_allow = ( + allowed_by_schedule + or allowed_by_grant + ) + + locked = is_locked(username) + + if should_allow: + if locked: + try: + unlock_user(username) + + record_event( + user_id, + "auto_unlock", + "Access became available", + ) + except Exception as exc: + record_event( + user_id, + "unlock_error", + str(exc), + ) + + else: + if logged_in: + terminate_user(username) + + record_event( + user_id, + "session_terminated", + "Access is not currently permitted", + ) + + if not locked: + try: + lock_user(username) + + record_event( + user_id, + "auto_lock", + "Access is not currently permitted", + ) + except Exception as exc: + record_event( + user_id, + "lock_error", + str(exc), + ) + + return { + "user_id": user_id, + "username": username, + "timestamp": now.isoformat(), + "weekday": weekday, + "minute": minute, + "inside_window": inside_window, + "logged_in": logged_in, + "allowance_seconds": allowance_seconds, + "usage_seconds": usage_seconds, + "allowance_remaining": allowance_remaining, + "grant_seconds": grant_seconds, + "total_remaining": total_remaining, + "allowed": should_allow, + } + + +def enforce_all_users(): + with get_db() as db: + users = db.execute( + """ + SELECT id, username, enabled + FROM users + WHERE enabled = 1 + ORDER BY id + """ + ).fetchall() + + results = [] + + for user in users: + try: + result = evaluate_user( + user["id"], + user["username"], + ) + + results.append(result) + + except Exception as exc: + record_event( + user["id"], + "enforcement_error", + str(exc), + ) + + return results diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..9bf1e58 --- /dev/null +++ b/app/main.py @@ -0,0 +1,21 @@ +import time + +from .database import initialize_database +from .scheduler import start_scheduler, stop_scheduler + + +def main(): + initialize_database() + start_scheduler() + + try: + while True: + time.sleep(60) + except KeyboardInterrupt: + pass + finally: + stop_scheduler() + + +if __name__ == "__main__": + main() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..576ca63 --- /dev/null +++ b/app/models.py @@ -0,0 +1,34 @@ +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 diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..8c7876a --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,183 @@ +import threading +import time +from datetime import datetime + +from .enforcement import ( + enforce_all_users, + record_usage, + get_user_policy, + consume_grant_seconds, +) + + +CHECK_INTERVAL = 5 + + +class Scheduler: + + def __init__( + self, + interval: int = CHECK_INTERVAL, + ): + self.interval = interval + + self._thread = None + self._stop_event = threading.Event() + + self._last_usage_update = {} + + def start(self): + + if ( + self._thread is not None + and self._thread.is_alive() + ): + return + + self._stop_event.clear() + + self._thread = threading.Thread( + target=self._run, + name="parental-control-scheduler", + daemon=True, + ) + + self._thread.start() + + def stop(self): + + self._stop_event.set() + + if self._thread is not None: + self._thread.join( + timeout=self.interval + 2 + ) + + def _run(self): + + # Evaluate immediately when the + # application starts. + self._tick() + + while not self._stop_event.wait( + self.interval + ): + self._tick() + + def _tick(self): + + now = time.monotonic() + + 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 + + if previous is None: + continue + + elapsed = int( + now - previous + ) + + if elapsed <= 0: + continue + + 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() + + +def start_scheduler(): + scheduler.start() + + +def stop_scheduler(): + scheduler.stop() diff --git a/app/users.py b/app/users.py new file mode 100644 index 0000000..bbb77b2 --- /dev/null +++ b/app/users.py @@ -0,0 +1,59 @@ +import pwd +import subprocess + + +def linux_user_exists(username: str) -> bool: + try: + pwd.getpwnam(username) + return True + except KeyError: + return False + + +def get_uid(username: str) -> int: + return pwd.getpwnam(username).pw_uid + + +def is_locked(username: str) -> bool: + result = subprocess.run( + ["passwd", "-S", username], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + return False + + parts = result.stdout.split() + + if len(parts) < 2: + return False + + return parts[1] == "L" + + +def lock_user(username: str): + subprocess.run( + ["loginctl", "terminate-user", username], + check=False, + ) + + subprocess.run( + ["passwd", "-l", username], + check=True, + ) + + +def unlock_user(username: str): + subprocess.run( + ["passwd", "-u", username], + check=True, + ) + + +def terminate_user(username: str): + subprocess.run( + ["loginctl", "terminate-user", username], + check=False, + ) diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/desktop/__init__.py b/desktop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/desktop/main.py b/desktop/main.py new file mode 100644 index 0000000..8c4a048 --- /dev/null +++ b/desktop/main.py @@ -0,0 +1,2149 @@ +import sys +import pwd +import sqlite3 + +from PySide6.QtCore import Qt, QTime +from PySide6.QtWidgets import ( + QApplication, + QCheckBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QProgressBar, + QScrollArea, + QSpinBox, + QStackedWidget, + QTimeEdit, + QVBoxLayout, + QWidget, +) + +from app.database import DATABASE_PATH + + +# ============================================================ +# Database +# ============================================================ + +class Database: + + @staticmethod + def get_connection(): + connection = sqlite3.connect(DATABASE_PATH) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + return connection + + @staticmethod + def get_users(): + if not DATABASE_PATH.exists(): + return [] + + connection = Database.get_connection() + + try: + return connection.execute( + """ + SELECT * + FROM users + ORDER BY username COLLATE NOCASE + """ + ).fetchall() + finally: + connection.close() + + @staticmethod + def get_user(user_id): + connection = Database.get_connection() + + try: + return connection.execute( + """ + SELECT * + FROM users + WHERE id = ? + """, + (user_id,), + ).fetchone() + finally: + connection.close() + + @staticmethod + def get_remaining_time(user_id): + if not DATABASE_PATH.exists(): + return 0, 0 + + connection = Database.get_connection() + + try: + allowance_row = connection.execute( + """ + SELECT COALESCE( + allowance_seconds, + 0 + ) AS total + FROM daily_allowances + WHERE user_id = ? + AND weekday = CAST( + strftime('%w', 'now', 'localtime') + AS INTEGER + ) + """, + (user_id,), + ).fetchone() + + allowance = ( + allowance_row["total"] + if allowance_row + else 0 + ) + + usage_row = connection.execute( + """ + SELECT COALESCE( + used_seconds, + 0 + ) AS total + FROM usage + WHERE user_id = ? + AND date = date('now', 'localtime') + """, + (user_id,), + ).fetchone() + + used = ( + usage_row["total"] + if usage_row + else 0 + ) + + remaining = max( + allowance - used, + 0, + ) + + return remaining, allowance + + finally: + connection.close() + + @staticmethod + def get_allowances(user_id): + connection = Database.get_connection() + + try: + rows = connection.execute( + """ + SELECT weekday, allowance_seconds + FROM daily_allowances + WHERE user_id = ? + """, + (user_id,), + ).fetchall() + + return { + row["weekday"]: row["allowance_seconds"] + for row in rows + } + + finally: + connection.close() + + @staticmethod + def get_access_windows(user_id): + connection = Database.get_connection() + + try: + rows = connection.execute( + """ + SELECT + weekday, + start_minute, + end_minute + FROM access_windows + WHERE user_id = ? + ORDER BY weekday, start_minute + """, + (user_id,), + ).fetchall() + + result = {} + + for row in rows: + result.setdefault( + row["weekday"], + [], + ).append( + ( + row["start_minute"], + row["end_minute"], + ) + ) + + return result + + finally: + connection.close() + + @staticmethod + def create_user( + username, + enabled, + allowances, + access_windows, + ): + connection = Database.get_connection() + + try: + cursor = connection.execute( + """ + INSERT INTO users ( + username, + enabled + ) + VALUES (?, ?) + """, + ( + username, + 1 if enabled else 0, + ), + ) + + user_id = cursor.lastrowid + + for weekday, seconds in allowances.items(): + connection.execute( + """ + INSERT INTO daily_allowances ( + user_id, + weekday, + allowance_seconds + ) + VALUES (?, ?, ?) + """, + ( + user_id, + weekday, + seconds, + ), + ) + + for weekday, windows in access_windows.items(): + for start_minute, end_minute in windows: + connection.execute( + """ + INSERT INTO access_windows ( + user_id, + weekday, + start_minute, + end_minute + ) + VALUES (?, ?, ?, ?) + """, + ( + user_id, + weekday, + start_minute, + end_minute, + ), + ) + + connection.commit() + + return user_id + + finally: + connection.close() + + @staticmethod + def update_user( + user_id, + enabled, + allowances, + access_windows, + ): + connection = Database.get_connection() + + try: + connection.execute( + """ + UPDATE users + SET enabled = ? + WHERE id = ? + """, + ( + 1 if enabled else 0, + user_id, + ), + ) + + for weekday, seconds in allowances.items(): + connection.execute( + """ + INSERT INTO daily_allowances ( + user_id, + weekday, + allowance_seconds + ) + VALUES (?, ?, ?) + ON CONFLICT(user_id, weekday) + DO UPDATE SET + allowance_seconds = + excluded.allowance_seconds + """, + ( + user_id, + weekday, + seconds, + ), + ) + + # Replace access windows completely. + connection.execute( + """ + DELETE FROM access_windows + WHERE user_id = ? + """, + (user_id,), + ) + + for weekday, windows in access_windows.items(): + for start_minute, end_minute in windows: + connection.execute( + """ + INSERT INTO access_windows ( + user_id, + weekday, + start_minute, + end_minute + ) + VALUES (?, ?, ?, ?) + """, + ( + user_id, + weekday, + start_minute, + end_minute, + ), + ) + + connection.commit() + + finally: + connection.close() + + @staticmethod + def delete_user(user_id): + connection = Database.get_connection() + + try: + connection.execute( + """ + DELETE FROM users + WHERE id = ? + """, + (user_id,), + ) + + connection.commit() + + finally: + connection.close() + + @staticmethod + def set_enabled(user_id, enabled): + connection = Database.get_connection() + + try: + connection.execute( + """ + UPDATE users + SET enabled = ? + WHERE id = ? + """, + ( + 1 if enabled else 0, + user_id, + ), + ) + + connection.commit() + + finally: + connection.close() + + @staticmethod + def add_temporary_time( + user_id, + seconds, + ): + connection = Database.get_connection() + + try: + connection.execute( + """ + INSERT INTO temporary_grants ( + user_id, + seconds, + remaining_seconds + ) + VALUES (?, ?, ?) + """, + ( + user_id, + seconds, + seconds, + ), + ) + + connection.commit() + + finally: + connection.close() + + +# ============================================================ +# Linux users +# ============================================================ + +def linux_user_exists(username): + try: + pwd.getpwnam(username) + return True + except KeyError: + return False + + +# ============================================================ +# Helpers +# ============================================================ + +# SQLite strftime('%w') numbering: +# +# Sunday = 0 +# Monday = 1 +# Tuesday = 2 +# Wednesday = 3 +# Thursday = 4 +# Friday = 5 +# Saturday = 6 + +WEEKDAYS = [ + (0, "Sunday"), + (1, "Monday"), + (2, "Tuesday"), + (3, "Wednesday"), + (4, "Thursday"), + (5, "Friday"), + (6, "Saturday"), +] + + +def format_seconds(seconds): + seconds = max( + int(seconds), + 0, + ) + + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + + if hours: + return f"{hours}h {minutes:02d}m" + + return f"{minutes}m" + + +# ============================================================ +# User Card +# ============================================================ + +class UserCard(QFrame): + + def __init__( + self, + user, + edit_callback, + toggle_callback, + parent=None, + ): + super().__init__(parent) + + self.user_id = user["id"] + + self.setObjectName("UserCard") + + layout = QVBoxLayout(self) + + layout.setContentsMargins( + 20, + 18, + 20, + 18, + ) + + layout.setSpacing(12) + + # ---------------------------------------------------- + # Header + # ---------------------------------------------------- + + header = QHBoxLayout() + + username = QLabel( + user["username"] + ) + + username.setObjectName( + "UserName" + ) + + header.addWidget(username) + header.addStretch() + + status = QLabel( + "Enabled" + if user["enabled"] + else "Disabled" + ) + + status.setObjectName( + "StatusEnabled" + if user["enabled"] + else "StatusDisabled" + ) + + header.addWidget(status) + + layout.addLayout(header) + + # ---------------------------------------------------- + # Remaining time + # ---------------------------------------------------- + + remaining, allowance = ( + Database.get_remaining_time( + self.user_id + ) + ) + + today_label = QLabel( + "Today's allowance" + ) + + today_label.setObjectName( + "SecondaryText" + ) + + layout.addWidget(today_label) + + time_label = QLabel( + f"{format_seconds(remaining)} remaining" + ) + + time_label.setObjectName( + "TimeRemaining" + ) + + layout.addWidget(time_label) + + progress = QProgressBar() + + progress.setTextVisible(False) + + progress.setMinimum(0) + + progress.setMaximum( + max(allowance, 1) + ) + + progress.setValue( + min( + remaining, + allowance, + ) + ) + + layout.addWidget(progress) + + # ---------------------------------------------------- + # Buttons + # ---------------------------------------------------- + + buttons = QHBoxLayout() + + manage_button = QPushButton( + "Manage" + ) + + manage_button.clicked.connect( + lambda: edit_callback( + self.user_id + ) + ) + + buttons.addWidget( + manage_button + ) + + toggle_text = ( + "Disable" + if user["enabled"] + else "Enable" + ) + + toggle_button = QPushButton( + toggle_text + ) + + toggle_button.clicked.connect( + lambda: toggle_callback( + self.user_id, + not bool( + user["enabled"] + ), + ) + ) + + buttons.addWidget( + toggle_button + ) + + layout.addLayout(buttons) + + +# ============================================================ +# Dashboard +# ============================================================ + +class DashboardPage(QWidget): + + def __init__( + self, + edit_callback, + toggle_callback, + parent=None, + ): + super().__init__(parent) + + self.edit_callback = edit_callback + self.toggle_callback = toggle_callback + + self.layout = QVBoxLayout(self) + + self.layout.setContentsMargins( + 30, + 30, + 30, + 30, + ) + + self.layout.setSpacing(20) + + title = QLabel( + "Dashboard" + ) + + title.setObjectName( + "PageTitle" + ) + + self.layout.addWidget(title) + + self.subtitle = QLabel() + + self.subtitle.setObjectName( + "SecondaryText" + ) + + self.layout.addWidget( + self.subtitle + ) + + self.scroll = QScrollArea() + + self.scroll.setWidgetResizable( + True + ) + + self.scroll.setFrameShape( + QFrame.NoFrame + ) + + self.container = QWidget() + + self.cards_layout = QVBoxLayout( + self.container + ) + + self.cards_layout.setContentsMargins( + 0, + 0, + 0, + 0, + ) + + self.cards_layout.setSpacing( + 15 + ) + + self.scroll.setWidget( + self.container + ) + + self.layout.addWidget( + self.scroll + ) + + self.refresh() + + def refresh(self): + + while self.cards_layout.count(): + + item = self.cards_layout.takeAt(0) + + widget = item.widget() + + if widget: + widget.deleteLater() + + users = Database.get_users() + + self.subtitle.setText( + f"{len(users)} managed user" + + ( + "" + if len(users) == 1 + else "s" + ) + ) + + if not users: + + empty = QLabel( + "No users are currently managed." + ) + + empty.setObjectName( + "EmptyText" + ) + + empty.setAlignment( + Qt.AlignCenter + ) + + self.cards_layout.addWidget( + empty + ) + + else: + + for user in users: + + card = UserCard( + user, + edit_callback=self.edit_callback, + toggle_callback=self.toggle_callback, + ) + + self.cards_layout.addWidget( + card + ) + + self.cards_layout.addStretch() + + +# ============================================================ +# Manage User Dialog +# ============================================================ + +class UserDialog(QDialog): + + def __init__( + self, + user=None, + parent=None, + ): + super().__init__(parent) + + self.user = user + + if user: + + self.setWindowTitle( + f"Manage User: " + f"{user['username']}" + ) + + else: + + self.setWindowTitle( + "Add User" + ) + + self.setMinimumWidth(720) + + main_layout = QVBoxLayout(self) + + main_layout.setContentsMargins( + 25, + 25, + 25, + 25, + ) + + main_layout.setSpacing(18) + + # ---------------------------------------------------- + # User information + # ---------------------------------------------------- + + form = QFormLayout() + + self.username_input = QLineEdit() + + if user: + + self.username_input.setText( + user["username"] + ) + + self.username_input.setReadOnly( + True + ) + + form.addRow( + "Linux username:", + self.username_input, + ) + + self.enabled_checkbox = QCheckBox( + "Enable user" + ) + + self.enabled_checkbox.setChecked( + bool( + user["enabled"] + ) + if user + else True + ) + + form.addRow( + "", + self.enabled_checkbox, + ) + + main_layout.addLayout( + form + ) + + # ---------------------------------------------------- + # Daily allowance + # ---------------------------------------------------- + + allowance_title = QLabel( + "Daily Allowance" + ) + + allowance_title.setObjectName( + "SectionTitle" + ) + + main_layout.addWidget( + allowance_title + ) + + allowance_description = QLabel( + "Maximum amount of computer time " + "the user can consume each day." + ) + + allowance_description.setObjectName( + "SecondaryText" + ) + + allowance_description.setWordWrap( + True + ) + + main_layout.addWidget( + allowance_description + ) + + existing_allowances = ( + Database.get_allowances( + user["id"] + ) + if user + else {} + ) + + self.day_controls = {} + + allowance_grid = QGridLayout() + + allowance_grid.setHorizontalSpacing( + 15 + ) + + allowance_grid.setVerticalSpacing( + 8 + ) + + allowance_grid.addWidget( + QLabel("Day"), + 0, + 0, + ) + + allowance_grid.addWidget( + QLabel("Hours"), + 0, + 1, + ) + + allowance_grid.addWidget( + QLabel("Minutes"), + 0, + 2, + ) + + for row, ( + weekday, + name, + ) in enumerate( + WEEKDAYS, + start=1, + ): + + allowance_grid.addWidget( + QLabel(name), + row, + 0, + ) + + hours = QSpinBox() + + hours.setRange( + 0, + 24, + ) + + minutes = QSpinBox() + + minutes.setRange( + 0, + 59, + ) + + seconds = existing_allowances.get( + weekday, + 0, + ) + + hours.setValue( + seconds // 3600 + ) + + minutes.setValue( + (seconds % 3600) // 60 + ) + + allowance_grid.addWidget( + hours, + row, + 1, + ) + + allowance_grid.addWidget( + minutes, + row, + 2, + ) + + self.day_controls[ + weekday + ] = ( + hours, + minutes, + ) + + main_layout.addLayout( + allowance_grid + ) + + # ---------------------------------------------------- + # Access windows + # ---------------------------------------------------- + + window_title = QLabel( + "Access Windows" + ) + + window_title.setObjectName( + "SectionTitle" + ) + + main_layout.addWidget( + window_title + ) + + window_description = QLabel( + "Define when the user is allowed to " + "use the computer. Disable a day " + "to prevent access on that day." + ) + + window_description.setObjectName( + "SecondaryText" + ) + + window_description.setWordWrap( + True + ) + + main_layout.addWidget( + window_description + ) + + existing_windows = ( + Database.get_access_windows( + user["id"] + ) + if user + else {} + ) + + self.window_controls = {} + + windows_grid = QGridLayout() + + windows_grid.setHorizontalSpacing( + 15 + ) + + windows_grid.setVerticalSpacing( + 8 + ) + + windows_grid.addWidget( + QLabel("Day"), + 0, + 0, + ) + + windows_grid.addWidget( + QLabel("Allowed"), + 0, + 1, + ) + + windows_grid.addWidget( + QLabel("Start"), + 0, + 2, + ) + + windows_grid.addWidget( + QLabel("End"), + 0, + 3, + ) + + for row, ( + weekday, + name, + ) in enumerate( + WEEKDAYS, + start=1, + ): + + windows_grid.addWidget( + QLabel(name), + row, + 0, + ) + + enabled = QCheckBox() + + start_time = QTimeEdit() + + start_time.setDisplayFormat( + "HH:mm" + ) + + start_time.setTime( + QTime(16, 0) + ) + + end_time = QTimeEdit() + + end_time.setDisplayFormat( + "HH:mm" + ) + + end_time.setTime( + QTime(21, 0) + ) + + existing = existing_windows.get( + weekday, + [] + ) + + if existing: + + start_minute, end_minute = ( + existing[0] + ) + + enabled.setChecked( + True + ) + + start_time.setTime( + QTime( + start_minute // 60, + start_minute % 60, + ) + ) + + end_time.setTime( + QTime( + end_minute // 60, + end_minute % 60, + ) + ) + + else: + + enabled.setChecked( + False + ) + + start_time.setEnabled( + enabled.isChecked() + ) + + end_time.setEnabled( + enabled.isChecked() + ) + + enabled.toggled.connect( + start_time.setEnabled + ) + + enabled.toggled.connect( + end_time.setEnabled + ) + + windows_grid.addWidget( + enabled, + row, + 1, + ) + + windows_grid.addWidget( + start_time, + row, + 2, + ) + + windows_grid.addWidget( + end_time, + row, + 3, + ) + + self.window_controls[ + weekday + ] = ( + enabled, + start_time, + end_time, + ) + + main_layout.addLayout( + windows_grid + ) + + # ---------------------------------------------------- + # Extra time + # ---------------------------------------------------- + + if user: + + extra_title = QLabel( + "Add Extra Time" + ) + + extra_title.setObjectName( + "SectionTitle" + ) + + main_layout.addWidget( + extra_title + ) + + extra_description = QLabel( + "Add temporary time without changing " + "the normal daily allowance." + ) + + extra_description.setObjectName( + "SecondaryText" + ) + + extra_description.setWordWrap( + True + ) + + main_layout.addWidget( + extra_description + ) + + extra_row = QHBoxLayout() + + self.extra_hours = QSpinBox() + + self.extra_hours.setRange( + 0, + 24, + ) + + self.extra_hours.setSuffix( + " h" + ) + + self.extra_minutes = QSpinBox() + + self.extra_minutes.setRange( + 0, + 59, + ) + + self.extra_minutes.setSuffix( + " min" + ) + + add_time_button = QPushButton( + "Add Time" + ) + + add_time_button.clicked.connect( + self.add_extra_time + ) + + extra_row.addWidget( + self.extra_hours + ) + + extra_row.addWidget( + self.extra_minutes + ) + + extra_row.addWidget( + add_time_button + ) + + extra_row.addStretch() + + main_layout.addLayout( + extra_row + ) + + # ---------------------------------------------------- + # Dialog buttons + # ---------------------------------------------------- + + buttons = QDialogButtonBox( + QDialogButtonBox.Save + | QDialogButtonBox.Cancel + ) + + buttons.accepted.connect( + self.save_user + ) + + buttons.rejected.connect( + self.reject + ) + + main_layout.addWidget( + buttons + ) + + def add_extra_time(self): + + hours = self.extra_hours.value() + minutes = self.extra_minutes.value() + + seconds = ( + hours * 3600 + + minutes * 60 + ) + + if seconds <= 0: + + QMessageBox.warning( + self, + "Invalid Time", + "Please enter an amount of time " + "greater than zero.", + ) + + return + + try: + + Database.add_temporary_time( + self.user["id"], + seconds, + ) + + self.extra_hours.setValue( + 0 + ) + + self.extra_minutes.setValue( + 0 + ) + + QMessageBox.information( + self, + "Time Added", + f"Added {format_seconds(seconds)} " + f"to {self.user['username']}.", + ) + + except Exception as exc: + + QMessageBox.critical( + self, + "Error", + f"Could not add time:\n\n{exc}", + ) + + def save_user(self): + + username = ( + self.username_input + .text() + .strip() + ) + + if not username: + + QMessageBox.warning( + self, + "Missing Username", + "Enter a Linux username.", + ) + + return + + if ( + not self.user + and not linux_user_exists( + username + ) + ): + + QMessageBox.warning( + self, + "User Not Found", + f"The Linux user " + f"'{username}' does not exist.", + ) + + return + + # ---------------------------------------------------- + # Allowances + # ---------------------------------------------------- + + allowances = {} + + for weekday, controls in ( + self.day_controls.items() + ): + + hours, minutes = controls + + seconds = ( + hours.value() * 3600 + + minutes.value() * 60 + ) + + allowances[ + weekday + ] = seconds + + # ---------------------------------------------------- + # Access windows + # ---------------------------------------------------- + + access_windows = {} + + for weekday, controls in ( + self.window_controls.items() + ): + + enabled, start_time, end_time = ( + controls + ) + + if not enabled.isChecked(): + continue + + start = start_time.time() + + end = end_time.time() + + start_minute = ( + start.hour() * 60 + + start.minute() + ) + + end_minute = ( + end.hour() * 60 + + end.minute() + ) + + if start_minute == end_minute: + + QMessageBox.warning( + self, + "Invalid Access Window", + "Start and end time cannot " + "be the same.", + ) + + return + + access_windows[ + weekday + ] = [ + ( + start_minute, + end_minute, + ) + ] + + # ---------------------------------------------------- + # Save + # ---------------------------------------------------- + + try: + + if self.user: + + Database.update_user( + self.user["id"], + self.enabled_checkbox.isChecked(), + allowances, + access_windows, + ) + + else: + + Database.create_user( + username, + self.enabled_checkbox.isChecked(), + allowances, + access_windows, + ) + + except sqlite3.IntegrityError: + + QMessageBox.warning( + self, + "User Already Exists", + "This user is already managed.", + ) + + return + + except Exception as exc: + + QMessageBox.critical( + self, + "Error", + f"Could not save the user:\n\n{exc}", + ) + + return + + self.accept() + + +# ============================================================ +# Users Page +# ============================================================ + +class UsersPage(QWidget): + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout(self) + + layout.setContentsMargins( + 30, + 30, + 30, + 30, + ) + + layout.setSpacing(20) + + header = QHBoxLayout() + + title = QLabel( + "Users" + ) + + title.setObjectName( + "PageTitle" + ) + + header.addWidget(title) + + header.addStretch() + + add_button = QPushButton( + "+ Add User" + ) + + add_button.clicked.connect( + lambda: self.window().add_user() + ) + + header.addWidget( + add_button + ) + + layout.addLayout( + header + ) + + description = QLabel( + "Manage Linux users and configure " + "their parental-control limits." + ) + + description.setObjectName( + "SecondaryText" + ) + + layout.addWidget( + description + ) + + self.scroll = QScrollArea() + + self.scroll.setWidgetResizable( + True + ) + + self.scroll.setFrameShape( + QFrame.NoFrame + ) + + self.container = QWidget() + + self.users_layout = QVBoxLayout( + self.container + ) + + self.users_layout.setContentsMargins( + 0, + 0, + 0, + 0, + ) + + self.users_layout.setSpacing( + 12 + ) + + self.scroll.setWidget( + self.container + ) + + layout.addWidget( + self.scroll + ) + + self.refresh() + + def refresh(self): + + while self.users_layout.count(): + + item = self.users_layout.takeAt(0) + + widget = item.widget() + + if widget: + widget.deleteLater() + + users = Database.get_users() + + for user in users: + + row = QFrame() + + row.setObjectName( + "UserRow" + ) + + row_layout = QHBoxLayout( + row + ) + + username = QLabel( + user["username"] + ) + + username.setObjectName( + "UserName" + ) + + row_layout.addWidget( + username + ) + + row_layout.addStretch() + + status = QLabel( + "Enabled" + if user["enabled"] + else "Disabled" + ) + + row_layout.addWidget( + status + ) + + manage = QPushButton( + "Manage User" + ) + + manage.clicked.connect( + lambda checked=False, + user_id=user["id"]: + self.window().edit_user( + user_id + ) + ) + + row_layout.addWidget( + manage + ) + + delete = QPushButton( + "Delete" + ) + + delete.clicked.connect( + lambda checked=False, + user_id=user["id"]: + self.window().delete_user( + user_id + ) + ) + + row_layout.addWidget( + delete + ) + + self.users_layout.addWidget( + row + ) + + self.users_layout.addStretch() + + +# ============================================================ +# Main Window +# ============================================================ + +class MainWindow(QMainWindow): + + def __init__(self): + super().__init__() + + self.setWindowTitle( + "Linux Parental Control" + ) + + self.resize( + 1100, + 750, + ) + + self.pages = QStackedWidget() + + self.dashboard = DashboardPage( + edit_callback=self.edit_user, + toggle_callback=self.toggle_user, + ) + + self.users = UsersPage() + + self.pages.addWidget( + self.dashboard + ) + + self.pages.addWidget( + self.users + ) + + self.create_sidebar() + + # -------------------------------------------------------- + # Sidebar + # -------------------------------------------------------- + + def create_sidebar(self): + + central = QWidget() + + main_layout = QHBoxLayout( + central + ) + + main_layout.setContentsMargins( + 0, + 0, + 0, + 0, + ) + + main_layout.setSpacing( + 0 + ) + + sidebar = QFrame() + + sidebar.setObjectName( + "Sidebar" + ) + + sidebar.setFixedWidth( + 220 + ) + + sidebar_layout = QVBoxLayout( + sidebar + ) + + sidebar_layout.setContentsMargins( + 15, + 20, + 15, + 20, + ) + + sidebar_layout.setSpacing( + 8 + ) + + title = QLabel( + "Parental Control" + ) + + title.setObjectName( + "SidebarTitle" + ) + + sidebar_layout.addWidget( + title + ) + + sidebar_layout.addSpacing( + 20 + ) + + dashboard_button = QPushButton( + "Dashboard" + ) + + dashboard_button.clicked.connect( + lambda: self.show_page(0) + ) + + sidebar_layout.addWidget( + dashboard_button + ) + + users_button = QPushButton( + "Users" + ) + + users_button.clicked.connect( + lambda: self.show_page(1) + ) + + sidebar_layout.addWidget( + users_button + ) + + sidebar_layout.addStretch() + + refresh_button = QPushButton( + "Refresh" + ) + + refresh_button.clicked.connect( + self.refresh_all + ) + + sidebar_layout.addWidget( + refresh_button + ) + + main_layout.addWidget( + sidebar + ) + + main_layout.addWidget( + self.pages + ) + + self.setCentralWidget( + central + ) + + def show_page(self, index): + self.pages.setCurrentIndex( + index + ) + + # -------------------------------------------------------- + # Add user + # -------------------------------------------------------- + + def add_user(self): + + dialog = UserDialog( + parent=self + ) + + if dialog.exec(): + + self.refresh_all() + + # -------------------------------------------------------- + # Manage user + # -------------------------------------------------------- + + def edit_user(self, user_id): + + user = Database.get_user( + user_id + ) + + if not user: + + QMessageBox.warning( + self, + "User Not Found", + "The selected user no longer exists.", + ) + + return + + dialog = UserDialog( + user=user, + parent=self, + ) + + if dialog.exec(): + + self.refresh_all() + + # -------------------------------------------------------- + # Enable / Disable + # -------------------------------------------------------- + + def toggle_user( + self, + user_id, + enabled, + ): + + user = Database.get_user( + user_id + ) + + if not user: + return + + action = ( + "enable" + if enabled + else "disable" + ) + + reply = QMessageBox.question( + self, + f"{action.title()} User", + f"Are you sure you want to " + f"{action} user " + f"\"{user['username']}\"?", + QMessageBox.Yes + | QMessageBox.No, + ) + + if reply != QMessageBox.Yes: + return + + try: + + Database.set_enabled( + user_id, + enabled, + ) + + except Exception as exc: + + QMessageBox.critical( + self, + "Error", + f"Could not update the user:\n\n{exc}", + ) + + return + + self.refresh_all() + + # -------------------------------------------------------- + # Delete user + # -------------------------------------------------------- + + def delete_user(self, user_id): + + user = Database.get_user( + user_id + ) + + if not user: + return + + reply = QMessageBox.question( + self, + "Delete Managed User", + f"Remove " + f"\"{user['username']}\" " + f"from parental control?\n\n" + f"This will NOT delete the Linux account.", + QMessageBox.Yes + | QMessageBox.No, + ) + + if reply != QMessageBox.Yes: + return + + try: + + Database.delete_user( + user_id + ) + + except Exception as exc: + + QMessageBox.critical( + self, + "Error", + f"Could not delete the user:\n\n{exc}", + ) + + return + + self.refresh_all() + + # -------------------------------------------------------- + # Refresh + # -------------------------------------------------------- + + def refresh_all(self): + + self.dashboard.refresh() + self.users.refresh() + + +# ============================================================ +# Theme +# ============================================================ + +def apply_theme(app): + + app.setStyleSheet( + """ + QWidget { + font-family: "Noto Sans"; + font-size: 14px; + } + + QMainWindow { + background: #f5f5f5; + } + + QFrame#Sidebar { + background: #202124; + } + + QLabel#SidebarTitle { + color: white; + font-size: 20px; + font-weight: bold; + } + + QPushButton { + padding: 9px 14px; + border-radius: 7px; + border: 1px solid #cccccc; + background: white; + } + + QPushButton:hover { + background: #eeeeee; + } + + QPushButton:pressed { + background: #dddddd; + } + + QFrame#Sidebar QPushButton { + color: white; + background: transparent; + border: none; + text-align: left; + padding: 12px; + } + + QFrame#Sidebar QPushButton:hover { + background: #303134; + } + + QLabel#PageTitle { + font-size: 28px; + font-weight: bold; + } + + QLabel#SectionTitle { + font-size: 18px; + font-weight: bold; + } + + QLabel#UserName { + font-size: 18px; + font-weight: bold; + } + + QLabel#SecondaryText { + color: #666666; + } + + QLabel#TimeRemaining { + font-size: 22px; + font-weight: bold; + } + + QLabel#StatusEnabled { + color: #188038; + font-weight: bold; + } + + QLabel#StatusDisabled { + color: #d93025; + font-weight: bold; + } + + QLabel#EmptyText { + color: #777777; + font-size: 16px; + padding: 40px; + } + + QFrame#UserCard { + background: white; + border: 1px solid #dddddd; + border-radius: 12px; + } + + QFrame#UserRow { + background: white; + border: 1px solid #dddddd; + border-radius: 8px; + } + + QProgressBar { + border: none; + border-radius: 5px; + background: #e5e5e5; + height: 10px; + } + + QProgressBar::chunk { + border-radius: 5px; + background: #4f7cff; + } + + QLineEdit, + QSpinBox, + QTimeEdit { + padding: 7px; + border: 1px solid #cccccc; + border-radius: 6px; + background: white; + } + + QCheckBox { + spacing: 8px; + } + + QScrollArea { + background: transparent; + } + """ + ) + + +# ============================================================ +# Main +# ============================================================ + +def main(): + + app = QApplication(sys.argv) + + app.setApplicationName( + "Linux Parental Control" + ) + + apply_theme(app) + + window = MainWindow() + + window.show() + + sys.exit( + app.exec() + ) + + +if __name__ == "__main__": + main() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2ab8fd5 --- /dev/null +++ b/install.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +set -e + +# ========================================== +# Linux Parental Control Installer +# ========================================== + +if [ "$EUID" -ne 0 ]; then + echo "Please run with sudo:" + echo " sudo ./install.sh" + exit 1 +fi + +INSTALL_DIR="$(cd "$(dirname "$0")" && pwd)" + +APP_NAME="Parental Control" +DESKTOP_FILE="/usr/share/applications/parental-control.desktop" +SERVICE_FILE="/etc/systemd/system/parental-control.service" + +echo +echo "==========================================" +echo " Linux Parental Control Installer" +echo "==========================================" +echo +echo "Installing from:" +echo " $INSTALL_DIR" +echo + +# ------------------------------------------ +# 1. System dependencies +# ------------------------------------------ + +echo "[1/6] Installing system dependencies" + +pacman -S --needed --noconfirm \ + python \ + python-pip + +echo +echo "System dependencies installed." + +# ------------------------------------------ +# 2. Python virtual environment +# ------------------------------------------ + +echo +echo "[2/6] Creating Python virtual environment" + +if [ ! -d "$INSTALL_DIR/.venv" ]; then + python -m venv "$INSTALL_DIR/.venv" +else + echo "Virtual environment already exists." +fi + +echo +echo "Virtual environment ready." + +# ------------------------------------------ +# 3. Python dependencies +# ------------------------------------------ + +echo +echo "[3/6] Installing Python packages" + +"$INSTALL_DIR/.venv/bin/python" -m pip install \ + --upgrade pip + +"$INSTALL_DIR/.venv/bin/python" -m pip install \ + -r "$INSTALL_DIR/requirements.txt" + +echo +echo "Python packages installed." + +# ------------------------------------------ +# 4. Install systemd service +# ------------------------------------------ + +echo +echo "[4/6] Installing systemd service" + +sed \ + "s|%INSTALL_DIR%|$INSTALL_DIR|g" \ + "$INSTALL_DIR/parental-control.service" \ + > "$SERVICE_FILE" + +chmod 644 "$SERVICE_FILE" + +systemctl daemon-reload +systemctl enable parental-control.service +systemctl restart parental-control.service + +echo +echo "Systemd service installed and started." + +# ------------------------------------------ +# 5. Install desktop application +# ------------------------------------------ + +echo +echo "[5/6] Installing application launcher" + +cat > "$DESKTOP_FILE" </dev/null 2>&1; then + update-desktop-database /usr/share/applications +else + echo "update-desktop-database is not installed." + echo "Skipping desktop database update." +fi + +# ------------------------------------------ +# Verify installation +# ------------------------------------------ + +echo +echo "==========================================" +echo " Installation complete" +echo "==========================================" +echo +echo "Application:" +echo " $APP_NAME" +echo +echo "Launch it from your application menu." +echo "On your system:" +echo " Super + D" +echo +echo "Service:" +echo " systemctl status parental-control" +echo +echo "==========================================" diff --git a/parental-control.service b/parental-control.service new file mode 100644 index 0000000..0fa0ef7 --- /dev/null +++ b/parental-control.service @@ -0,0 +1,22 @@ +[Unit] +Description=Linux Parental Control +After=network.target + +[Service] +Type=simple + +WorkingDirectory=%INSTALL_DIR% + +Environment="PATH=%INSTALL_DIR%/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin" +Environment="PARENTAL_CONTROL_ENFORCEMENT=1" + +ExecStart=%INSTALL_DIR%/.venv/bin/python -m app.main + +Restart=always +RestartSec=5 + +User=root +Group=root + +[Install] +WantedBy=multi-user.target diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..318d753 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PySide6==6.11.2 +PySide6_Addons==6.11.2 +PySide6_Essentials==6.11.2 +shiboken6==6.11.2