A hosts file cannot express a wildcard, so '*.youtube.com' was only ever the
base domain plus the guessed labels. The patterns the server now hands over
as unexpandable are answered by a stub on 127.0.0.1, and the helper owns one
systemd-resolved drop-in that points just those zones at it:
* the stub is listening before the drop-in is written, and the drop-in is
removed before the stub stops, so a zone's queries never land on a dead
port;
* stub and route live as long as the app, not the interval: between
intervals they forward everything, instead of restarting resolved (and
dropping its cache) on every pomodoro;
* a crashed run leaves a world-readable drop-in the next start adopts, and
the stub comes back before anything else;
* masks with nothing routable after their last '*' ('*gram*') are reported
as unenforceable, not silently dropped.
The Blocker page now reports what is really in place — the hosts region, the
routed zones, and the masks this machine cannot do — rather than the whole
expansion. 39 desktop tests, including the ordering invariants.
361 lines
15 KiB
QML
361 lines
15 KiB
QML
// 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, and the patterns that leaves over
|
||
// are answered by the local DNS stub for the zones it can route. The stats show
|
||
// what is *really* in place on this computer — the hosts region and the routed
|
||
// zones — and every banner says plainly what could not be enforced here.
|
||
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
|
||
import "."
|
||
import "components"
|
||
|
||
ScrollView {
|
||
id: page
|
||
contentWidth: availableWidth
|
||
clip: true
|
||
|
||
ColumnLayout {
|
||
width: page.availableWidth - 56
|
||
x: 28
|
||
y: 26
|
||
spacing: 22
|
||
|
||
PageHeader {
|
||
eyebrow: controller.blockerReason
|
||
title: controller.blockerActive ? "Blocking is on" : "Blocking is off"
|
||
caption: "Rules are shared with the web app; this machine enforces them."
|
||
|
||
Switch {
|
||
text: "Enabled"
|
||
checked: controller.settings.blocker ? controller.settings.blocker.enabled : false
|
||
onToggled: controller.patchSettings({ blocker: { enabled: checked } })
|
||
}
|
||
}
|
||
|
||
Banner {
|
||
visible: !controller.helperInstalled
|
||
text: "The privileged helper is not installed, so websites cannot be blocked. "
|
||
+ "Run: sudo desktop/helper/install.sh"
|
||
}
|
||
Banner {
|
||
visible: controller.blockerError !== ""
|
||
text: controller.blockerError
|
||
}
|
||
Banner {
|
||
visible: controller.invalidExpansions.length > 0
|
||
text: controller.invalidExpansions.join(", ")
|
||
+ " is not a subdomain label, so it was ignored. Each entry is one "
|
||
+ "label — web, m, mobile. A bare `*` is kept and means every subdomain."
|
||
}
|
||
Banner {
|
||
visible: controller.blockerActive && controller.unroutableDomains.length > 0
|
||
text: controller.unroutableDomains.join(", ")
|
||
+ " cannot be blocked on this computer: no DNS zone can be routed for "
|
||
+ "a mask with nothing after its last `*`. The phone still matches it "
|
||
+ "against the URLs you open."
|
||
}
|
||
Banner {
|
||
visible: controller.blockerActive && controller.blockedDomains.length > 0
|
||
&& controller.appliedDomains.length === 0
|
||
text: "Blocking is on, but this machine's hosts file has none of the "
|
||
+ controller.blockedDomains.length + " hostnames in it yet. Either the "
|
||
+ "write has not run, or it was refused — a message above would say which."
|
||
}
|
||
|
||
Panel {
|
||
padding: 0
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 0
|
||
Repeater {
|
||
model: [
|
||
{ label: "Hostnames",
|
||
value: "" + controller.appliedDomains.length,
|
||
caption: controller.config.enforce_blocking && controller.config.block_hosts_file
|
||
? "in /etc/hosts"
|
||
: "not written (enforcement off)" },
|
||
{ label: "Zones",
|
||
value: "" + controller.routedZones.length,
|
||
caption: "routed to the stub" },
|
||
{ label: "Apps",
|
||
value: "" + controller.blockedApps.length,
|
||
caption: !controller.config.enforce_blocking || !controller.config.block_apps
|
||
? "not enforced"
|
||
: (controller.settings.blocker
|
||
&& controller.settings.blocker.kill_blocked_apps
|
||
? "closed on sight" : "warned only") },
|
||
{ label: "Attempts today",
|
||
value: controller.stats.today ? "" + controller.stats.today.blocked_attempts : "0",
|
||
caption: "turned away" },
|
||
{ label: "Enforcing",
|
||
value: controller.config.enforce_blocking ? "Yes" : "No",
|
||
caption: "on this computer" }
|
||
]
|
||
delegate: RowLayout {
|
||
required property int index
|
||
required property var modelData
|
||
Layout.fillWidth: true
|
||
spacing: 0
|
||
Rectangle {
|
||
visible: index > 0
|
||
width: 1
|
||
Layout.preferredHeight: 70
|
||
color: Theme.rule
|
||
}
|
||
Stat {
|
||
Layout.fillWidth: true
|
||
Layout.margins: 18
|
||
label: modelData.label
|
||
value: modelData.value
|
||
caption: modelData.caption
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 12
|
||
SectionTitle { text: "When" }
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 16
|
||
ComboBox {
|
||
Layout.preferredWidth: 240
|
||
font.pixelSize: 13
|
||
model: ["While a focus interval runs", "Always", "On a schedule"]
|
||
currentIndex: {
|
||
var mode = controller.settings.blocker ? controller.settings.blocker.mode : "during_work"
|
||
return mode === "always" ? 1 : (mode === "scheduled" ? 2 : 0)
|
||
}
|
||
onActivated: function (index) {
|
||
var modes = ["during_work", "always", "scheduled"]
|
||
controller.patchSettings({ blocker: { mode: modes[index] } })
|
||
}
|
||
}
|
||
Switch {
|
||
text: "Through breaks"
|
||
checked: controller.settings.blocker ? controller.settings.blocker.block_during_breaks : false
|
||
onToggled: controller.patchSettings({ blocker: { block_during_breaks: checked } })
|
||
}
|
||
Switch {
|
||
text: "Close blocked apps"
|
||
checked: controller.settings.blocker ? controller.settings.blocker.kill_blocked_apps : true
|
||
onToggled: controller.patchSettings({ blocker: { kill_blocked_apps: checked } })
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 12
|
||
SectionTitle { text: "Masks" }
|
||
Panel {
|
||
spacing: 0
|
||
Repeater {
|
||
model: [
|
||
{ pattern: "youtube.com", meaning: "the site itself, plus www. and m." },
|
||
{ pattern: "*.google.com", meaning: "the bare domain plus every subdomain in the expansion list" },
|
||
{ pattern: "*gram*", meaning: "matches apps, and URLs on the phone; no DNS zone can be routed for it" },
|
||
{ pattern: "* in the list below", meaning: "makes *.site cover every subdomain — answered by the DNS stub" }
|
||
]
|
||
delegate: RowLayout {
|
||
required property var modelData
|
||
Layout.fillWidth: true
|
||
spacing: 16
|
||
Label {
|
||
text: modelData.pattern
|
||
color: Theme.ink
|
||
font.pixelSize: 12
|
||
font.family: Theme.monoFamily
|
||
Layout.preferredWidth: 140
|
||
}
|
||
Label {
|
||
text: modelData.meaning
|
||
color: Theme.muted
|
||
font.pixelSize: 12
|
||
font.family: Theme.sansFamily
|
||
Layout.fillWidth: true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
GridLayout {
|
||
Layout.fillWidth: true
|
||
columns: page.width > 900 ? 3 : 1
|
||
columnSpacing: 18
|
||
rowSpacing: 18
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 8
|
||
SectionTitle { text: "Blocked websites" }
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 160
|
||
radius: 10
|
||
color: Theme.surface
|
||
border.width: 1
|
||
border.color: domainsArea.activeFocus ? Theme.ember : Theme.rule
|
||
|
||
ScrollView {
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
clip: true
|
||
|
||
TextArea {
|
||
id: domainsArea
|
||
background: null
|
||
wrapMode: TextArea.NoWrap
|
||
font.family: Theme.monoFamily
|
||
font.pixelSize: 12
|
||
color: Theme.ink
|
||
placeholderText: "youtube.com\n*.reddit.com"
|
||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_domains : []).join("
|
||
")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 8
|
||
SectionTitle { text: "Exceptions" }
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 160
|
||
radius: 10
|
||
color: Theme.surface
|
||
border.width: 1
|
||
border.color: allowArea.activeFocus ? Theme.ember : Theme.rule
|
||
|
||
ScrollView {
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
clip: true
|
||
|
||
TextArea {
|
||
id: allowArea
|
||
background: null
|
||
wrapMode: TextArea.NoWrap
|
||
font.family: Theme.monoFamily
|
||
font.pixelSize: 12
|
||
color: Theme.ink
|
||
placeholderText: "music.youtube.com\n*.docs.google.com"
|
||
text: (controller.settings.blocker ? controller.settings.blocker.allowed_domains : []).join("
|
||
")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 8
|
||
SectionTitle { text: "Blocked applications" }
|
||
Rectangle {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: 160
|
||
radius: 10
|
||
color: Theme.surface
|
||
border.width: 1
|
||
border.color: appsArea.activeFocus ? Theme.ember : Theme.rule
|
||
|
||
ScrollView {
|
||
anchors.fill: parent
|
||
anchors.margins: 8
|
||
clip: true
|
||
|
||
TextArea {
|
||
id: appsArea
|
||
background: null
|
||
wrapMode: TextArea.NoWrap
|
||
font.family: Theme.monoFamily
|
||
font.pixelSize: 12
|
||
color: Theme.ink
|
||
placeholderText: "steam*\n*discord*"
|
||
text: (controller.settings.blocker ? controller.settings.blocker.blocked_apps : []).join("
|
||
")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 12
|
||
TextField {
|
||
id: subdomainField
|
||
Layout.preferredWidth: 420
|
||
placeholderText: "*.site expands to: www, m, web… (`*` = every subdomain)"
|
||
text: (controller.settings.blocker && controller.settings.blocker.subdomain_expansions
|
||
? controller.settings.blocker.subdomain_expansions : []).join(", ")
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
PrimaryButton {
|
||
text: "Save lists"
|
||
onClicked: {
|
||
function split(text, sep) {
|
||
return text.split(sep).map(function (s) { return s.trim() })
|
||
.filter(function (s) { return s.length > 0 })
|
||
}
|
||
controller.patchSettings({
|
||
blocker: {
|
||
blocked_domains: split(domainsArea.text, "\n"),
|
||
allowed_domains: split(allowArea.text, "\n"),
|
||
blocked_apps: split(appsArea.text, "\n"),
|
||
subdomain_expansions: split(subdomainField.text, ",")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
ColumnLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 8
|
||
SectionTitle { text: "Today's temptations" }
|
||
Repeater {
|
||
model: controller.stats.today ? controller.stats.today.blocked_top : []
|
||
delegate: RowLayout {
|
||
required property var modelData
|
||
Layout.fillWidth: true
|
||
Label {
|
||
text: modelData.target
|
||
color: Theme.ink
|
||
font.pixelSize: 12
|
||
font.family: Theme.monoFamily
|
||
Layout.fillWidth: true
|
||
}
|
||
Label {
|
||
text: "× " + modelData.count
|
||
color: Theme.muted
|
||
font.pixelSize: 12
|
||
font.family: Theme.sansFamily
|
||
}
|
||
}
|
||
}
|
||
Label {
|
||
visible: !controller.stats.today || controller.stats.today.blocked_top.length === 0
|
||
text: "Nothing yet — either the blocker is off, or you are behaving."
|
||
color: Theme.faint
|
||
font.pixelSize: 12
|
||
font.family: Theme.sansFamily
|
||
}
|
||
}
|
||
|
||
Item { Layout.preferredHeight: 10 }
|
||
}
|
||
|
||
Component.onCompleted: controller.loadSettings()
|
||
}
|