"""Blocking policy. The backend decides *whether* blocking should be on right now and *what* is on the list; the desktop app is the thing that actually enforces it (it is the only piece running on your machine with the privileges to rewrite `/etc/hosts` or kill a process). Keeping the policy here means the web UI can edit the rules, and a second machine running the desktop app obeys the same list. """ from __future__ import annotations from datetime import date, datetime from typing import Any from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models import BlockEvent from app.services import pomodoro from app.settings_schema import AppSettings from app.timeutil import ensure_utc, in_window, local_date, local_time def expand_domains(settings: AppSettings) -> list[str]: """Every hostname the desktop app should point at the redirect IP. A rule like `youtube.com` is meant as "that site", so `www.` and the common subdomains come along; anything in `allowed_domains` is removed again, which is how `youtube.com` blocked + `music.youtube.com` allowed works. """ blocker = settings.blocker allowed = {entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()} hosts: list[str] = [] for raw in blocker.blocked_domains: domain = raw.strip().lower().lstrip(".") if not domain or domain in allowed: continue for candidate in (domain, f"www.{domain}", f"m.{domain}"): if candidate not in allowed and candidate not in hosts: hosts.append(candidate) return hosts def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool: now_time = local_time(settings.timezone, moment) weekday = (moment or datetime.now()).weekday() for window in settings.blocker.schedule: if weekday in window.days and in_window(now_time, window.start, window.end): return True return False def policy(session: Session, settings: AppSettings) -> dict[str, Any]: """What the desktop app polls: should I block, and what.""" blocker = settings.blocker current = pomodoro.current_session(session) active = False reason = "disabled" if blocker.enabled: if blocker.mode == "always": active, reason = True, "always on" elif blocker.mode == "scheduled": active = schedule_active(settings) reason = "inside a blocking window" if active else "outside every window" else: # during_work if current is not None and current.kind == "work" and current.status in ("running", "paused"): active, reason = True, "a work interval is running" elif ( current is not None and current.kind != "work" and blocker.block_during_breaks and current.status in ("running", "paused") ): active, reason = True, "blocking stays on through breaks" else: reason = "no work interval is running" return { "active": active, "reason": reason, "mode": blocker.mode, "enabled": blocker.enabled, "strict": blocker.strict, "redirect_ip": blocker.redirect_ip, "domains": expand_domains(settings) if active else [], "apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [], "kill_apps": blocker.kill_blocked_apps, "notify_on_block": blocker.notify_on_block, "poll_seconds": blocker.app_poll_seconds, "session_id": current.id if current else None, "all_domains": expand_domains(settings), "all_apps": [app.strip() for app in blocker.blocked_apps if app.strip()], } def record_event( session: Session, settings: AppSettings, *, kind: str, target: str, action: str = "blocked", details: str | None = None, ) -> BlockEvent: current = pomodoro.current_session(session) event = BlockEvent( kind=kind, target=target.strip().lower(), action=action, details=details, local_date=local_date(settings.timezone), session_id=current.id if current else None, ) session.add(event) session.commit() return event def events_for_day(session: Session, settings: AppSettings, day: date | None = None) -> dict[str, Any]: day = day or local_date(settings.timezone) rows = list(session.scalars(select(BlockEvent).where(BlockEvent.local_date == day))) by_target = dict( session.execute( select(BlockEvent.target, func.count(BlockEvent.id)) .where(BlockEvent.local_date == day) .group_by(BlockEvent.target) .order_by(func.count(BlockEvent.id).desc()) ).all() ) return { "date": day.isoformat(), "total": len(rows), "by_target": [{"target": target, "count": count} for target, count in by_target.items()], "events": [ { "id": row.id, "kind": row.kind, "target": row.target, "action": row.action, "at": ensure_utc(row.at).isoformat(), "details": row.details, } for row in sorted(rows, key=lambda row: ensure_utc(row.at), reverse=True)[:100] ], } DEFAULT_DOMAINS = [ "youtube.com", "twitter.com", "x.com", "instagram.com", "tiktok.com", "reddit.com", "facebook.com", "vk.com", "twitch.tv", "netflix.com", "9gag.com", "pikabu.ru", ] DEFAULT_APPS = ["steam", "discord", "telegram-desktop", "spotify"]