From a531a62afc2242002e7a79aacdceb430cc9058e0 Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Mon, 19 Oct 2026 18:45:42 +0300 Subject: [PATCH] web: say what the guard actually applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Blocker page showed all_domains even when nothing was in force, and its copy still called a bare '*' an invalid expansion. Hostnames now comes from the live list (empty while blocking is off), the routed DNS zones get their own stat, and the banners distinguish junk labels from masks no hosts file can express — which is what the server now reports. --- frontend/src/lib/types.ts | 5 +++ frontend/src/pages/BlockerPage.tsx | 66 ++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 12 deletions(-) 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." />