diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index bafc77b..61f6080 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -79,7 +79,12 @@ export interface RefreshResult { export interface BlockerPolicy { active: boolean; + /** Masks a hosts file cannot express: `*.x.com` (answered by the desktop app's routed DNS zone) and `*gram*` (no zone exists). */ unexpandable?: string[]; + /** Expansion entries that are not subdomain labels (`not a label`) — dropped. A bare `*` is kept: it means every subdomain. */ + invalid_expansions?: string[]; + /** The `allowed` masks, normalized — what the desktop app's resolver serves as an exception. */ + allowed?: string[]; reason: string; mode: string; enabled: boolean; diff --git a/frontend/src/pages/BlockerPage.tsx b/frontend/src/pages/BlockerPage.tsx index 11d3f29..2805ae8 100644 --- a/frontend/src/pages/BlockerPage.tsx +++ b/frontend/src/pages/BlockerPage.tsx @@ -2,8 +2,10 @@ * The focus guard: what is blocked, when, and what it caught. * * Lists accept masks. A hosts file has no wildcards of its own, so `*.x.com` is - * expanded server-side into real hostnames — this page shows that expansion, and - * says plainly when a pattern (`*gram*`) cannot be expressed that way. + * expanded server-side into real hostnames, and the patterns that leaves over + * are answered by the desktop app's local DNS stub for the zones it can route. + * The stats show what is really in force (`domains` is empty while blocking is + * off) and every banner says plainly what could not be enforced. */ import { useEffect, useMemo, useState } from "react"; import { @@ -28,13 +30,24 @@ import type { BlockEvents, BlockerPolicy } from "../lib/types"; const MASK_HELP = [ ["youtube.com", "the site itself, plus www. and m."], - ["*.google.com", "every subdomain in the expansion list below"], - ["*gram*", "matches apps; a hosts file cannot express it"], + ["*.google.com", "the bare domain plus every subdomain in the expansion list"], + ["*gram*", "matches apps, and URLs on the phone; no DNS zone can be routed for it"], + ["* in the list below", "makes *.site cover every subdomain — answered by the desktop app's DNS stub"], ]; +/** + * The DNS zone a mask can be routed through: what follows its last `*`, and at + * least two labels deep — `*gram*` has nothing after its last `*`, and `*.site` + * has only one label, so neither can be handed to a resolver. + */ +const zoneOf = (mask: string): string | null => { + const zone = mask.slice(mask.lastIndexOf("*") + 1).replace(/^\.+/, ""); + return zone.split(".").filter(Boolean).length >= 2 ? zone : null; +}; + export default function BlockerPage({ onToast }: { onToast: (message: string) => void }) { const { settings, defaults, save } = useSettings(); - const policy = useResource("/api/blocker/policy", 5000); + const policy = useResource("/api/blocker/policy", 5000); const events = useResource("/api/blocker/events", 20000); const [domains, setDomains] = useState(""); @@ -78,6 +91,12 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) => const blocker = settings?.blocker; const unexpandable = policy.data?.unexpandable ?? []; + const unroutable = useMemo(() => unexpandable.filter((mask) => zoneOf(mask) === null), [unexpandable]); + const zones = useMemo( + () => [...new Set(unexpandable.map(zoneOf).filter((zone): zone is string => zone !== null))], + [unexpandable], + ); + const badExpansions = policy.data?.invalid_expansions ?? []; const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]); return ( @@ -100,8 +119,22 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) => /> - - + + + - {unexpandable.length > 0 && ( + {badExpansions.length > 0 && ( + + {badExpansions.join(", ")} {badExpansions.length === 1 ? "is not" : "are not"} a subdomain label + {badExpansions.length === 1 ? "" : "s"}, so {badExpansions.length === 1 ? "it was" : "they were"} ignored. + Each entry is one label — web, m, mobile. A bare * is kept and means every subdomain. + + )} + + {policy.data?.active && unroutable.length > 0 && ( - {unexpandable.join(", ")} {unexpandable.length === 1 ? "is a pattern" : "are patterns"} a hosts file - cannot express — {unexpandable.length === 1 ? "it is" : "they are"} still matched against application - names. + {unroutable.join(", ")} cannot be routed to a DNS zone — nothing usable follows the last{" "} + *. A hosts file cannot express {unroutable.length === 1 ? "it" : "them"} either; + your phone still matches {unroutable.length === 1 ? "it" : "them"} against the URLs you open. )} @@ -249,7 +290,8 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) => setSubdomains(event.target.value); setDirty(true); }} - helperText="Comma separated. Add the subdomains you actually visit." + error={badExpansions.length > 0} + helperText="Comma separated, one label each: web, m, mobile. A bare * means every subdomain." />