disallow to disable guard during focus
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s
docker / image (push) Failing after 24s
lint / ruff (push) Failing after 28s
tests / backend (push) Successful in 37s
tests / desktop (push) Successful in 22s
tests / android-core (push) Successful in 49s
tests / web (push) Successful in 1m20s
android / apk (push) Successful in 8m49s
This commit is contained in:
21 files changed
+481
-43
No files matched your search
@@ -77,6 +77,10 @@ agent enforces them on your machine:
|
|||||||
`*discord*`, matched against the process name and its command line.
|
`*discord*`, matched against the process name and its command line.
|
||||||
* **When** — only while a focus interval runs (the default), always, or on a
|
* **When** — only while a focus interval runs (the default), always, or on a
|
||||||
schedule. Breaks can keep the block on or let it go.
|
schedule. Breaks can keep the block on or let it go.
|
||||||
|
* **One-way while you work** — the guard can be switched on whenever you like, but
|
||||||
|
not off while an interval is live, breaks and pauses included; the desktop app's
|
||||||
|
local enforcement switches lock the same way. Finishing or dropping the interval
|
||||||
|
frees it. A guard you can disable the moment it starts to bite is decoration.
|
||||||
* Every attempt that gets turned away is recorded — and teased about in the
|
* Every attempt that gets turned away is recorded — and teased about in the
|
||||||
evening report.
|
evening report.
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import tech.kadence.core.DomainMatcher
|
|||||||
import tech.kadence.core.IntervalFacts
|
import tech.kadence.core.IntervalFacts
|
||||||
import tech.kadence.core.evaluatePolicy
|
import tech.kadence.core.evaluatePolicy
|
||||||
import tech.kadence.core.expandDomains
|
import tech.kadence.core.expandDomains
|
||||||
|
import tech.kadence.core.guardLocked
|
||||||
import tech.kadence.core.invalidExpansions
|
import tech.kadence.core.invalidExpansions
|
||||||
import tech.kadence.core.unexpandableMasks
|
import tech.kadence.core.unexpandableMasks
|
||||||
import tech.kadence.core.zoneOrDefault
|
import tech.kadence.core.zoneOrDefault
|
||||||
@@ -83,7 +84,10 @@ data class BlockerUi(
|
|||||||
val reason: String = "",
|
val reason: String = "",
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val mode: String = MODE_DURING_WORK,
|
val mode: String = MODE_DURING_WORK,
|
||||||
val strict: Boolean = false,
|
/** The guard is locked on — an interval is live, so it cannot be switched off. */
|
||||||
|
val locked: Boolean = false,
|
||||||
|
/** The server's sentence for why, shown under the toggle. */
|
||||||
|
val lockReason: String = "",
|
||||||
val killApps: Boolean = true,
|
val killApps: Boolean = true,
|
||||||
val notifyOnBlock: Boolean = true,
|
val notifyOnBlock: Boolean = true,
|
||||||
/** Hostnames in the expanded set — `all_domains`, so it is non-zero even when idle. */
|
/** Hostnames in the expanded set — `all_domains`, so it is non-zero even when idle. */
|
||||||
@@ -481,7 +485,8 @@ class BlockerEngine(
|
|||||||
reason = dto.reason,
|
reason = dto.reason,
|
||||||
enabled = dto.enabled,
|
enabled = dto.enabled,
|
||||||
mode = dto.mode,
|
mode = dto.mode,
|
||||||
strict = dto.strict,
|
locked = dto.locked,
|
||||||
|
lockReason = dto.lockReason,
|
||||||
killApps = dto.killApps,
|
killApps = dto.killApps,
|
||||||
notifyOnBlock = dto.notifyOnBlock,
|
notifyOnBlock = dto.notifyOnBlock,
|
||||||
hostnames = dto.allDomains.size,
|
hostnames = dto.allDomains.size,
|
||||||
@@ -509,7 +514,8 @@ class BlockerEngine(
|
|||||||
reason = verdict.reason,
|
reason = verdict.reason,
|
||||||
enabled = current?.enabled ?: false,
|
enabled = current?.enabled ?: false,
|
||||||
mode = current?.mode ?: MODE_DURING_WORK,
|
mode = current?.mode ?: MODE_DURING_WORK,
|
||||||
strict = current?.strict ?: false,
|
locked = guardLocked(current ?: BlockerRules(), timerFacts()),
|
||||||
|
lockReason = "",
|
||||||
killApps = current?.killBlockedApps ?: true,
|
killApps = current?.killBlockedApps ?: true,
|
||||||
notifyOnBlock = current?.notifyOnBlock ?: true,
|
notifyOnBlock = current?.notifyOnBlock ?: true,
|
||||||
hostnames = current?.let { expandDomains(it).size } ?: 0,
|
hostnames = current?.let { expandDomains(it).size } ?: 0,
|
||||||
@@ -648,6 +654,5 @@ fun BlockerSettingsDto.toCore(): BlockerRules = BlockerRules(
|
|||||||
blockedApps = blockedApps,
|
blockedApps = blockedApps,
|
||||||
killBlockedApps = killBlockedApps,
|
killBlockedApps = killBlockedApps,
|
||||||
schedule = schedule.map { BlockWindow(it.label, it.start, it.end, it.days) },
|
schedule = schedule.map { BlockWindow(it.label, it.start, it.end, it.days) },
|
||||||
strict = strict,
|
|
||||||
notifyOnBlock = notifyOnBlock,
|
notifyOnBlock = notifyOnBlock,
|
||||||
)
|
)
|
||||||
@@ -140,7 +140,9 @@ data class BlockerPolicyDto(
|
|||||||
val reason: String = "",
|
val reason: String = "",
|
||||||
val mode: String = "during_work",
|
val mode: String = "during_work",
|
||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val strict: Boolean = false,
|
/** The guard is on and an interval is live, so it may not be switched off. */
|
||||||
|
val locked: Boolean = false,
|
||||||
|
@SerialName("lock_reason") val lockReason: String = "",
|
||||||
@SerialName("redirect_ip") val redirectIp: String = "127.0.0.1",
|
@SerialName("redirect_ip") val redirectIp: String = "127.0.0.1",
|
||||||
/** Empty whenever `active` is false — read `all_domains` instead. */
|
/** Empty whenever `active` is false — read `all_domains` instead. */
|
||||||
val domains: List<String> = emptyList(),
|
val domains: List<String> = emptyList(),
|
||||||
@@ -395,7 +397,6 @@ data class BlockerSettingsDto(
|
|||||||
@SerialName("blocked_apps") val blockedApps: List<String> = emptyList(),
|
@SerialName("blocked_apps") val blockedApps: List<String> = emptyList(),
|
||||||
@SerialName("kill_blocked_apps") val killBlockedApps: Boolean = true,
|
@SerialName("kill_blocked_apps") val killBlockedApps: Boolean = true,
|
||||||
val schedule: List<BlockWindowDto> = emptyList(),
|
val schedule: List<BlockWindowDto> = emptyList(),
|
||||||
val strict: Boolean = false,
|
|
||||||
@SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
|
@SerialName("notify_on_block") val notifyOnBlock: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -237,18 +237,26 @@ fun BlockerScreen(
|
|||||||
Panel(padding = 0.dp) {
|
Panel(padding = 0.dp) {
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Focus guard on",
|
label = "Focus guard on",
|
||||||
detail = if (ui.enabled) {
|
detail = when {
|
||||||
"Everything below applies."
|
ui.locked -> ui.lockReason.ifBlank {
|
||||||
} else {
|
"Finish or drop the interval to switch the guard off."
|
||||||
"Off everywhere — the phone, the desktop app and the web UI all " +
|
}
|
||||||
|
ui.enabled -> "Everything below applies."
|
||||||
|
else -> "Off everywhere — the phone, the desktop app and the web UI all " +
|
||||||
"stop enforcing."
|
"stop enforcing."
|
||||||
},
|
},
|
||||||
checked = ui.enabled,
|
checked = ui.enabled,
|
||||||
// The rules are the server's document and there is no outbox for it:
|
// Two reasons this says no, both said out loud rather than left as a
|
||||||
// a settings PUT is a deep merge of a shared object, and queueing one
|
// grey switch:
|
||||||
// would mean replaying a stale version over whatever the web UI did
|
// * offline — the rules are the server's document and there is no
|
||||||
// meanwhile. So offline these say no, rather than appearing to work.
|
// outbox for it: a settings PUT is a deep merge of a shared object,
|
||||||
enabled = ui.connection == Connection.Online,
|
// and queueing one would mean replaying a stale version over
|
||||||
|
// whatever the web UI did meanwhile;
|
||||||
|
// * locked — the server refuses to switch the guard off while an
|
||||||
|
// interval is running or paused, which is the whole point of it.
|
||||||
|
// Switching it *on* is never refused, which is why `locked` already has
|
||||||
|
// "and the guard is on" folded into it server-side.
|
||||||
|
enabled = ui.connection == Connection.Online && !ui.locked,
|
||||||
onChange = { value -> patch(mapOf("enabled" to value)) },
|
onChange = { value -> patch(mapOf("enabled" to value)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ class BlockerEngineTest {
|
|||||||
return engine
|
return engine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the lock reaches the screen from the server's policy`() {
|
||||||
|
// The screen never decides this itself — it renders what the server said,
|
||||||
|
// and the server has already folded "and the guard is on" into `locked`.
|
||||||
|
val ui = engine().state.value
|
||||||
|
assertTrue(ui.locked)
|
||||||
|
assertEquals("Finish or drop the interval to switch the focus guard off.", ui.lockReason)
|
||||||
|
}
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
server = MockWebServer()
|
server = MockWebServer()
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class BlockerSettingsTest {
|
|||||||
assertEquals(listOf("*discord*", "instagram"), blocker.blockedApps)
|
assertEquals(listOf("*discord*", "instagram"), blocker.blockedApps)
|
||||||
assertFalse(blocker.killBlockedApps)
|
assertFalse(blocker.killBlockedApps)
|
||||||
assertFalse(blocker.notifyOnBlock)
|
assertFalse(blocker.notifyOnBlock)
|
||||||
assertTrue(blocker.strict)
|
|
||||||
assertEquals(1, blocker.schedule.size)
|
assertEquals(1, blocker.schedule.size)
|
||||||
assertEquals("Work mornings", blocker.schedule[0].label)
|
assertEquals("Work mornings", blocker.schedule[0].label)
|
||||||
assertEquals(listOf(0, 1, 2, 3, 4), blocker.schedule[0].days)
|
assertEquals(listOf(0, 1, 2, 3, 4), blocker.schedule[0].days)
|
||||||
@@ -106,7 +105,6 @@ class BlockerSettingsTest {
|
|||||||
assertTrue(blocker.blockedApps.isEmpty())
|
assertTrue(blocker.blockedApps.isEmpty())
|
||||||
assertTrue(blocker.killBlockedApps)
|
assertTrue(blocker.killBlockedApps)
|
||||||
assertTrue(blocker.notifyOnBlock)
|
assertTrue(blocker.notifyOnBlock)
|
||||||
assertFalse(blocker.strict)
|
|
||||||
assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions)
|
assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +116,7 @@ class BlockerSettingsTest {
|
|||||||
{"settings":{"blocker":{
|
{"settings":{"blocker":{
|
||||||
"enabled":null,"mode":null,"blocked_domains":null,
|
"enabled":null,"mode":null,"blocked_domains":null,
|
||||||
"allowed_domains":null,"blocked_apps":null,"subdomain_expansions":null,
|
"allowed_domains":null,"blocked_apps":null,"subdomain_expansions":null,
|
||||||
"kill_blocked_apps":null,"notify_on_block":null,"strict":null
|
"kill_blocked_apps":null,"notify_on_block":null
|
||||||
}}}
|
}}}
|
||||||
""".trimIndent(),
|
""".trimIndent(),
|
||||||
)
|
)
|
||||||
@@ -138,4 +136,24 @@ class BlockerSettingsTest {
|
|||||||
val inactive = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_inactive.json"))
|
val inactive = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_inactive.json"))
|
||||||
assertTrue(inactive.invalidExpansions.isEmpty())
|
assertTrue(inactive.invalidExpansions.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the policy carries whether the guard is locked on`() {
|
||||||
|
val active = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_active.json"))
|
||||||
|
assertTrue(active.locked)
|
||||||
|
assertEquals("Finish or drop the interval to switch the focus guard off.", active.lockReason)
|
||||||
|
|
||||||
|
val inactive = KadenceJson.decodeFromString<BlockerPolicyDto>(fixture("blocker_policy_inactive.json"))
|
||||||
|
assertFalse(inactive.locked)
|
||||||
|
assertEquals("", inactive.lockReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a backend too old to send the lock reads as unlocked`() {
|
||||||
|
// The switch then looks operable and the server still refuses the write with a
|
||||||
|
// 409 — which is why the lock cannot live in the UI alone.
|
||||||
|
val policy = KadenceJson.decodeFromString<BlockerPolicyDto>("""{"enabled":true}""")
|
||||||
|
assertFalse(policy.locked)
|
||||||
|
assertEquals("", policy.lockReason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"reason": "a work interval is running",
|
"reason": "a work interval is running",
|
||||||
"mode": "during_work",
|
"mode": "during_work",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"strict": false,
|
"locked": true,
|
||||||
|
"lock_reason": "Finish or drop the interval to switch the focus guard off.",
|
||||||
"redirect_ip": "127.0.0.1",
|
"redirect_ip": "127.0.0.1",
|
||||||
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
|
"domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"],
|
||||||
"apps": ["steam*", "*discord*"],
|
"apps": ["steam*", "*discord*"],
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"reason": "no work interval is running",
|
"reason": "no work interval is running",
|
||||||
"mode": "during_work",
|
"mode": "during_work",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"strict": false,
|
"locked": false,
|
||||||
|
"lock_reason": "",
|
||||||
"redirect_ip": "127.0.0.1",
|
"redirect_ip": "127.0.0.1",
|
||||||
"domains": [],
|
"domains": [],
|
||||||
"apps": [],
|
"apps": [],
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
"schedule": [
|
"schedule": [
|
||||||
{"label": "Work mornings", "start": "09:00:00", "end": "12:00:00", "days": [0, 1, 2, 3, 4]}
|
{"label": "Work mornings", "start": "09:00:00", "end": "12:00:00", "days": [0, 1, 2, 3, 4]}
|
||||||
],
|
],
|
||||||
"strict": true,
|
|
||||||
"notify_on_block": false,
|
"notify_on_block": false,
|
||||||
"redirect_ip": "0.0.0.0",
|
"redirect_ip": "0.0.0.0",
|
||||||
"app_poll_seconds": 5
|
"app_poll_seconds": 5
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ data class BlockerRules(
|
|||||||
val blockedApps: List<String> = emptyList(),
|
val blockedApps: List<String> = emptyList(),
|
||||||
val killBlockedApps: Boolean = true,
|
val killBlockedApps: Boolean = true,
|
||||||
val schedule: List<BlockWindow> = emptyList(),
|
val schedule: List<BlockWindow> = emptyList(),
|
||||||
val strict: Boolean = false,
|
|
||||||
val notifyOnBlock: Boolean = true,
|
val notifyOnBlock: Boolean = true,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -246,6 +245,20 @@ fun evaluatePolicy(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The port of `guard_locked()`: whether the guard may be switched off right now.
|
||||||
|
*
|
||||||
|
* A free function rather than a field on [PolicyVerdict], because this is a verdict
|
||||||
|
* about *editing* the rules and not about whether to block — and the two answers do
|
||||||
|
* disagree: scheduled mode outside its window is `active = false`, `locked = true`.
|
||||||
|
*
|
||||||
|
* Any kind of interval counts, running or paused. Switching the guard *on* is never
|
||||||
|
* locked, which is why "and the guard is on" is folded in here rather than left to
|
||||||
|
* the screen.
|
||||||
|
*/
|
||||||
|
fun guardLocked(rules: BlockerRules, current: IntervalFacts?): Boolean =
|
||||||
|
rules.enabled && current != null && current.isActiveStatus
|
||||||
|
|
||||||
/** Convenience for callers that only have an IANA zone id string. */
|
/** Convenience for callers that only have an IANA zone id string. */
|
||||||
fun zoneOrDefault(timezone: String?): ZoneId =
|
fun zoneOrDefault(timezone: String?): ZoneId =
|
||||||
runCatching { ZoneId.of(timezone?.trim().orEmpty()) }.getOrElse { ZoneId.systemDefault() }
|
runCatching { ZoneId.of(timezone?.trim().orEmpty()) }.getOrElse { ZoneId.systemDefault() }
|
||||||
@@ -303,4 +303,42 @@ class BlockerTest {
|
|||||||
|
|
||||||
assertEquals(listOf("reddit.com", "www.reddit.com", "m.reddit.com"), expandDomains(rules))
|
assertEquals(listOf("reddit.com", "www.reddit.com", "m.reddit.com"), expandDomains(rules))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- the lock -----------------------------------------------------------
|
||||||
|
// Parity with `blocker_service.guard_locked()`.
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a live interval of any kind locks the guard on`() {
|
||||||
|
val on = BlockerRules(enabled = true)
|
||||||
|
assertTrue(guardLocked(on, working))
|
||||||
|
assertTrue(guardLocked(on, IntervalFacts("work", "paused")))
|
||||||
|
assertTrue(guardLocked(on, breaking))
|
||||||
|
assertTrue(guardLocked(on, IntervalFacts("long_break", "paused")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `nothing live leaves the guard switchable`() {
|
||||||
|
val on = BlockerRules(enabled = true)
|
||||||
|
assertFalse(guardLocked(on, null))
|
||||||
|
assertFalse(guardLocked(on, IntervalFacts("work", "completed")))
|
||||||
|
assertFalse(guardLocked(on, IntervalFacts("work", "abandoned")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a guard that is off is never locked, so it can always be switched on`() {
|
||||||
|
assertFalse(guardLocked(BlockerRules(enabled = false), working))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `scheduled mode outside its window is inactive and still locked`() {
|
||||||
|
// The distinction `active` cannot express: nothing is being blocked right now,
|
||||||
|
// and the guard still must not be switched off before the window opens.
|
||||||
|
val rules = BlockerRules(
|
||||||
|
enabled = true,
|
||||||
|
mode = "scheduled",
|
||||||
|
schedule = listOf(BlockWindow("Work hours", "09:00", "18:00", listOf(0))),
|
||||||
|
)
|
||||||
|
assertFalse(evaluatePolicy(rules, working, at("2026-10-19", "20:00")).active)
|
||||||
|
assertTrue(guardLocked(rules, working))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -38,13 +38,20 @@ def put_settings(session: DbSession, patch: dict[str, Any] = Body(...)) -> dict:
|
|||||||
# `exc.errors()` can carry exception objects in `ctx`, which JSON cannot
|
# `exc.errors()` can carry exception objects in `ctx`, which JSON cannot
|
||||||
# take; pydantic's own `json()` renders them as text.
|
# take; pydantic's own `json()` renders them as text.
|
||||||
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
|
||||||
|
except blocker_service.GuardLocked as exc:
|
||||||
|
# 409, not 422: the body is valid, the current state refuses it. A plain
|
||||||
|
# string detail is what every client's error path already surfaces.
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset")
|
@router.post("/reset")
|
||||||
def reset_settings(session: DbSession) -> dict:
|
def reset_settings(session: DbSession) -> dict:
|
||||||
"""Back to defaults, re-seeded from the environment."""
|
"""Back to defaults, re-seeded from the environment."""
|
||||||
updated = settings_service.save_settings(session, settings_service.settings_from_env())
|
try:
|
||||||
|
updated = settings_service.reset_to_env(session)
|
||||||
|
except blocker_service.GuardLocked as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
return {"settings": settings_service.mask_secrets(updated.model_dump(mode="json"))}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -187,12 +187,59 @@ def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bo
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
GUARD_LOCK_NOTE = "Finish or drop the interval to switch the focus guard off."
|
||||||
|
"""Said in one voice: the 409 detail, and the `lock_reason` every client renders."""
|
||||||
|
|
||||||
|
|
||||||
|
class GuardLocked(RuntimeError):
|
||||||
|
"""Switching the guard off was refused because an interval is live."""
|
||||||
|
|
||||||
|
|
||||||
|
def guard_locked(session: Session, settings: AppSettings) -> bool:
|
||||||
|
"""The guard is on and an interval is live, so it may not be switched off.
|
||||||
|
|
||||||
|
Any kind, running or paused — `pomodoro.current_session` is exactly that. A
|
||||||
|
break is not an escape hatch and neither is pausing the clock.
|
||||||
|
|
||||||
|
Deliberately keyed off `blocker.enabled` rather than off `policy()["active"]`:
|
||||||
|
in `scheduled` mode outside every window nothing is being blocked right now,
|
||||||
|
and the guard still must not be quietly switched off before the window opens.
|
||||||
|
"""
|
||||||
|
return settings.blocker.enabled and pomodoro.current_session(session) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_guard_not_switched_off(
|
||||||
|
session: Session, before: AppSettings, after: AppSettings
|
||||||
|
) -> None:
|
||||||
|
"""Refuse a settings write that would turn the guard off mid-interval.
|
||||||
|
|
||||||
|
Compares the two documents rather than reading the patch. The patch is a deep
|
||||||
|
merge of a shared object and arrives loosely typed — `{"enabled": "no"}`
|
||||||
|
coerces to `False`, so inspecting the raw keys would have to re-implement
|
||||||
|
pydantic to be right — and "did the stored value go from on to off" is the
|
||||||
|
only question that cannot be fooled. It also means an unrelated patch, a
|
||||||
|
full-document echo and a re-sent `enabled: false` all pass by construction.
|
||||||
|
|
||||||
|
Switching the guard *on* is never refused.
|
||||||
|
"""
|
||||||
|
if not after.blocker.enabled and guard_locked(session, before):
|
||||||
|
raise GuardLocked(GUARD_LOCK_NOTE)
|
||||||
|
|
||||||
|
|
||||||
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
|
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||||
"""What the desktop app polls: should I block, and what."""
|
"""What the desktop app polls: should I block, and what.
|
||||||
|
|
||||||
|
`active` and `locked` answer two different questions and do disagree: in
|
||||||
|
`scheduled` mode outside every window, nothing is blocked (`active` false)
|
||||||
|
while the guard is still locked on (`locked` true) because an interval runs.
|
||||||
|
"""
|
||||||
blocker = settings.blocker
|
blocker = settings.blocker
|
||||||
current = pomodoro.current_session(session)
|
current = pomodoro.current_session(session)
|
||||||
active = False
|
active = False
|
||||||
reason = "disabled"
|
reason = "disabled"
|
||||||
|
# The same rule as `guard_locked()`, reusing the row this function already
|
||||||
|
# fetched rather than querying twice — `test_blocker.py` pins the two together.
|
||||||
|
locked = blocker.enabled and current is not None
|
||||||
|
|
||||||
if blocker.enabled:
|
if blocker.enabled:
|
||||||
if blocker.mode == "always":
|
if blocker.mode == "always":
|
||||||
@@ -218,7 +265,8 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
|
|||||||
"reason": reason,
|
"reason": reason,
|
||||||
"mode": blocker.mode,
|
"mode": blocker.mode,
|
||||||
"enabled": blocker.enabled,
|
"enabled": blocker.enabled,
|
||||||
"strict": blocker.strict,
|
"locked": locked,
|
||||||
|
"lock_reason": GUARD_LOCK_NOTE if locked else "",
|
||||||
"redirect_ip": blocker.redirect_ip,
|
"redirect_ip": blocker.redirect_ip,
|
||||||
"domains": expand_domains(settings) if active else [],
|
"domains": expand_domains(settings) if active else [],
|
||||||
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
|
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import env_settings
|
from app.config import env_settings
|
||||||
from app.models import SettingsRow
|
from app.models import SettingsRow
|
||||||
from app.services import pomodoro
|
from app.services import blocker_service, pomodoro
|
||||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||||
|
|
||||||
_SETTINGS_ID = 1
|
_SETTINGS_ID = 1
|
||||||
@@ -192,10 +192,17 @@ def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str
|
|||||||
|
|
||||||
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||||
"""Merge a partial settings document (as the UI sends it) and store it."""
|
"""Merge a partial settings document (as the UI sends it) and store it."""
|
||||||
current = load_settings(session).model_dump(mode="json")
|
stored = load_settings(session)
|
||||||
|
current = stored.model_dump(mode="json")
|
||||||
patch = unmask_secrets(current, patch)
|
patch = unmask_secrets(current, patch)
|
||||||
merged = _merge(current, patch)
|
merged = _merge(current, patch)
|
||||||
updated = save_settings(session, AppSettings.model_validate(merged))
|
candidate = AppSettings.model_validate(merged)
|
||||||
|
|
||||||
|
# Validate, then refuse, then write: a malformed patch stays a 422 rather
|
||||||
|
# than becoming a 409, and nothing is stored either way.
|
||||||
|
blocker_service.ensure_guard_not_switched_off(session, stored, candidate)
|
||||||
|
|
||||||
|
updated = save_settings(session, candidate)
|
||||||
|
|
||||||
# A changed Focus/Short/Long minute (directly, or via a preset) resizes
|
# A changed Focus/Short/Long minute (directly, or via a preset) resizes
|
||||||
# whatever interval is already running or paused — see
|
# whatever interval is already running or paused — see
|
||||||
@@ -203,3 +210,16 @@ def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
|||||||
# is simplest to always run it rather than detect which keys changed.
|
# is simplest to always run it rather than detect which keys changed.
|
||||||
pomodoro.rescale_current(session, updated)
|
pomodoro.rescale_current(session, updated)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def reset_to_env(session: Session) -> AppSettings:
|
||||||
|
"""Back to the environment seed — refused if that would unguard a live interval.
|
||||||
|
|
||||||
|
`settings_from_env` never touches the `blocker` subtree, so a reset always
|
||||||
|
lands on the schema default `enabled = False`: without this check one POST
|
||||||
|
would switch the focus guard off mid-interval.
|
||||||
|
"""
|
||||||
|
stored = load_settings(session)
|
||||||
|
fresh = settings_from_env()
|
||||||
|
blocker_service.ensure_guard_not_switched_off(session, stored, fresh)
|
||||||
|
return save_settings(session, fresh)
|
||||||
@@ -204,8 +204,6 @@ class BlockerSettings(BaseModel):
|
|||||||
"""Terminate a blocked app when it appears; otherwise only warn and log."""
|
"""Terminate a blocked app when it appears; otherwise only warn and log."""
|
||||||
|
|
||||||
schedule: list[BlockWindow] = Field(default_factory=list)
|
schedule: list[BlockWindow] = Field(default_factory=list)
|
||||||
strict: bool = False
|
|
||||||
"""The desktop app refuses to switch blocking off while a work interval runs."""
|
|
||||||
|
|
||||||
redirect_ip: str = "127.0.0.1"
|
redirect_ip: str = "127.0.0.1"
|
||||||
app_poll_seconds: int = Field(default=5, ge=1, le=60)
|
app_poll_seconds: int = Field(default=5, ge=1, le=60)
|
||||||
|
|||||||
@@ -125,6 +125,36 @@ def test_invalid_settings_are_rejected(client):
|
|||||||
assert client.put("/api/settings", json={"timezone": "Mars/Olympus"}).status_code == 422
|
assert client.put("/api/settings", json={"timezone": "Mars/Olympus"}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_the_guard_off_mid_interval_is_a_409(client):
|
||||||
|
assert client.put("/api/settings", json={"blocker": {"enabled": True}}).status_code == 200
|
||||||
|
client.post("/api/timer/start", json={"kind": "work"})
|
||||||
|
|
||||||
|
assert client.get("/api/blocker/policy").json()["locked"] is True
|
||||||
|
|
||||||
|
refused = client.put("/api/settings", json={"blocker": {"enabled": False}})
|
||||||
|
assert refused.status_code == 409
|
||||||
|
assert "focus guard" in refused.json()["detail"]
|
||||||
|
|
||||||
|
# Turning it on, and editing anything else, are both still fine.
|
||||||
|
assert client.put("/api/settings", json={"blocker": {"enabled": True}}).status_code == 200
|
||||||
|
lists = client.put("/api/settings", json={"blocker": {"blocked_apps": ["steam"]}})
|
||||||
|
assert lists.status_code == 200
|
||||||
|
assert lists.json()["settings"]["blocker"]["enabled"] is True
|
||||||
|
|
||||||
|
client.post("/api/timer/complete", json={"auto_next": False})
|
||||||
|
assert client.put("/api/settings", json={"blocker": {"enabled": False}}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_resetting_the_settings_cannot_unguard_a_live_interval(client):
|
||||||
|
client.put("/api/settings", json={"blocker": {"enabled": True}})
|
||||||
|
client.post("/api/timer/start", json={"kind": "short_break"})
|
||||||
|
|
||||||
|
assert client.post("/api/settings/reset").status_code == 409
|
||||||
|
|
||||||
|
client.post("/api/timer/complete", json={"auto_next": False})
|
||||||
|
assert client.post("/api/settings/reset").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_notification_test_endpoint_falls_back_to_the_log(client):
|
def test_notification_test_endpoint_falls_back_to_the_log(client):
|
||||||
body = client.post("/api/notifications/test", json={}).json()
|
body = client.post("/api/notifications/test", json={}).json()
|
||||||
assert body["deliveries"][0]["channel"] == "log"
|
assert body["deliveries"][0]["channel"] == "log"
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
from app.services import blocker_service, pomodoro, settings_service
|
from app.services import blocker_service, pomodoro, settings_service
|
||||||
|
|
||||||
|
|
||||||
@@ -189,3 +190,179 @@ def test_the_policy_carries_the_allow_masks_for_the_stub(session, settings):
|
|||||||
assert result["allowed"] == ["music.youtube.com", "*.docs.google.com"]
|
assert result["allowed"] == ["music.youtube.com", "*.docs.google.com"]
|
||||||
assert "music.youtube.com" not in result["domains"]
|
assert "music.youtube.com" not in result["domains"]
|
||||||
assert "m.youtube.com" in result["all_domains"]
|
assert "m.youtube.com" in result["all_domains"]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# The lock: the guard will not be switched off mid-interval
|
||||||
|
#
|
||||||
|
# Only `apply_patch` and `reset_to_env` carry the check. `save_settings` stays an
|
||||||
|
# unguarded low-level writer on purpose — the env seed, the password CLI and the
|
||||||
|
# tests above all go through it — which is why these exercise the real write path.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _guarded(session, settings):
|
||||||
|
settings.blocker.enabled = True
|
||||||
|
settings_service.save_settings(session, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guard_cannot_be_switched_off_while_an_interval_runs(session, settings):
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
|
||||||
|
with pytest.raises(blocker_service.GuardLocked):
|
||||||
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||||
|
|
||||||
|
# And nothing was written on the way out.
|
||||||
|
assert settings_service.load_settings(session).blocker.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_pausing_is_not_an_escape_hatch(session, settings):
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
pomodoro.pause(session, settings)
|
||||||
|
|
||||||
|
with pytest.raises(blocker_service.GuardLocked):
|
||||||
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["short_break", "long_break"])
|
||||||
|
@pytest.mark.parametrize("pause", [False, True])
|
||||||
|
def test_a_break_is_not_an_escape_hatch_either(session, settings, kind, pause):
|
||||||
|
"""The lock is kind-blind: `current_session` is the whole rule."""
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind=kind)
|
||||||
|
if pause:
|
||||||
|
pomodoro.pause(session, settings)
|
||||||
|
|
||||||
|
with pytest.raises(blocker_service.GuardLocked):
|
||||||
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guard_can_always_be_switched_on_mid_interval(session, settings):
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": True}})
|
||||||
|
assert updated.blocker.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guard_switches_off_freely_when_nothing_is_running(session, settings):
|
||||||
|
_guarded(session, settings)
|
||||||
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||||
|
assert updated.blocker.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guard_switches_off_once_the_interval_is_over(session, settings):
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
pomodoro.complete(session, settings, auto_next=False)
|
||||||
|
|
||||||
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||||
|
assert updated.blocker.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unrelated_blocker_patch_still_saves_mid_interval(session, settings):
|
||||||
|
"""What the web app's list editor sends — the regression the check is shaped for."""
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
|
||||||
|
updated = settings_service.apply_patch(
|
||||||
|
session,
|
||||||
|
{"blocker": {"blocked_domains": ["youtube.com"], "blocked_apps": ["steam"]}},
|
||||||
|
)
|
||||||
|
assert updated.blocker.blocked_domains == ["youtube.com"]
|
||||||
|
assert updated.blocker.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_sending_an_already_off_guard_is_not_a_refusal(session, settings):
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
updated = settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||||
|
assert updated.blocker.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_document_echo_survives_a_live_interval(session, settings):
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
|
||||||
|
echo = settings_service.load_settings(session).model_dump(mode="json")
|
||||||
|
updated = settings_service.apply_patch(session, echo)
|
||||||
|
assert updated.blocker.enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_loosely_typed_off_is_still_an_off(session, settings):
|
||||||
|
"""`{"enabled": "no"}` coerces to False, which is why the check compares
|
||||||
|
validated documents rather than reading the patch's keys."""
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
|
||||||
|
with pytest.raises(blocker_service.GuardLocked):
|
||||||
|
settings_service.apply_patch(session, {"blocker": {"enabled": "no"}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_policy_reports_the_lock(session, settings):
|
||||||
|
_guarded(session, settings)
|
||||||
|
assert blocker_service.policy(session, settings)["locked"] is False
|
||||||
|
assert blocker_service.policy(session, settings)["lock_reason"] == ""
|
||||||
|
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
live = blocker_service.policy(session, settings)
|
||||||
|
assert live["locked"] is True
|
||||||
|
assert live["lock_reason"] == blocker_service.GUARD_LOCK_NOTE
|
||||||
|
|
||||||
|
pomodoro.pause(session, settings)
|
||||||
|
assert blocker_service.policy(session, settings)["locked"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_guard_that_is_off_is_never_reported_locked(session, settings):
|
||||||
|
"""`locked` has "and the guard is on" folded in, so no client re-derives it."""
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
assert blocker_service.policy(session, settings)["locked"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_policy_and_the_predicate_agree(session, settings):
|
||||||
|
"""`policy()` re-expresses `guard_locked()` to reuse the row it already has;
|
||||||
|
these two must not drift."""
|
||||||
|
for enabled in (False, True):
|
||||||
|
settings.blocker.enabled = enabled
|
||||||
|
settings_service.save_settings(session, settings)
|
||||||
|
for kind in (None, "work", "short_break"):
|
||||||
|
if kind is not None:
|
||||||
|
pomodoro.start(session, settings, kind=kind)
|
||||||
|
assert blocker_service.policy(session, settings)["locked"] is (
|
||||||
|
blocker_service.guard_locked(session, settings)
|
||||||
|
)
|
||||||
|
if kind is not None:
|
||||||
|
pomodoro.abandon(session, settings, reason="test")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scheduled_mode_outside_its_window_is_inactive_and_still_locked(session, settings):
|
||||||
|
"""The distinction `active` cannot express: nothing is blocked right now, and
|
||||||
|
the guard still must not be switched off before the window opens."""
|
||||||
|
settings.blocker.enabled = True
|
||||||
|
settings.blocker.mode = "scheduled"
|
||||||
|
settings.blocker.schedule = []
|
||||||
|
settings_service.save_settings(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
|
||||||
|
result = blocker_service.policy(session, settings)
|
||||||
|
assert result["active"] is False
|
||||||
|
assert result["locked"] is True
|
||||||
|
|
||||||
|
with pytest.raises(blocker_service.GuardLocked):
|
||||||
|
settings_service.apply_patch(session, {"blocker": {"enabled": False}})
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reset_cannot_switch_the_guard_off_mid_interval(session, settings):
|
||||||
|
"""`settings_from_env` never touches the blocker subtree, so a reset always
|
||||||
|
lands on `enabled = False` — a second, quieter off-switch."""
|
||||||
|
_guarded(session, settings)
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
|
||||||
|
with pytest.raises(blocker_service.GuardLocked):
|
||||||
|
settings_service.reset_to_env(session)
|
||||||
|
assert settings_service.load_settings(session).blocker.enabled is True
|
||||||
|
|
||||||
|
pomodoro.complete(session, settings, auto_next=False)
|
||||||
|
assert settings_service.reset_to_env(session).blocker.enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reset_is_fine_while_an_interval_runs_if_the_guard_is_off(session, settings):
|
||||||
|
pomodoro.start(session, settings, kind="work")
|
||||||
|
assert settings_service.reset_to_env(session).blocker.enabled is False
|
||||||
@@ -37,6 +37,14 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
KIND_LABELS = {"work": "Focus", "short_break": "Short break", "long_break": "Long break"}
|
KIND_LABELS = {"work": "Focus", "short_break": "Short break", "long_break": "Long break"}
|
||||||
|
|
||||||
|
#: The local switches that stop this machine enforcing. While the server reports
|
||||||
|
#: the guard locked, these only go one way — on. Turning enforcement off is what a
|
||||||
|
#: focus guard exists to make inconvenient, and this app is the thing enforcing.
|
||||||
|
_ENFORCEMENT_KEYS = ("enforce_blocking", "block_hosts_file", "block_apps")
|
||||||
|
|
||||||
|
#: Said when the policy has not been polled yet, so there is no server sentence.
|
||||||
|
_GUARD_LOCK_FALLBACK = "Finish or drop the interval to switch the focus guard off."
|
||||||
|
|
||||||
|
|
||||||
def clock(seconds: int) -> str:
|
def clock(seconds: int) -> str:
|
||||||
seconds = max(0, int(seconds))
|
seconds = max(0, int(seconds))
|
||||||
@@ -252,6 +260,20 @@ class Controller(QObject):
|
|||||||
def blockerReason(self) -> str:
|
def blockerReason(self) -> str:
|
||||||
return str(self._policy.get("reason", ""))
|
return str(self._policy.get("reason", ""))
|
||||||
|
|
||||||
|
@pyqtProperty(bool, notify=blockerChanged)
|
||||||
|
def blockerLocked(self) -> bool:
|
||||||
|
"""The server will not let the guard be switched off right now.
|
||||||
|
|
||||||
|
Not the same as [blockerActive]: in scheduled mode outside every window
|
||||||
|
nothing is being blocked and the guard is still locked on, because an
|
||||||
|
interval is running or paused.
|
||||||
|
"""
|
||||||
|
return bool(self._policy.get("locked"))
|
||||||
|
|
||||||
|
@pyqtProperty(str, notify=blockerChanged)
|
||||||
|
def blockerLockNote(self) -> str:
|
||||||
|
return str(self._policy.get("lock_reason", ""))
|
||||||
|
|
||||||
@pyqtProperty("QVariantList", notify=blockerChanged)
|
@pyqtProperty("QVariantList", notify=blockerChanged)
|
||||||
def blockedDomains(self) -> list[str]:
|
def blockedDomains(self) -> list[str]:
|
||||||
"""Every hostname the policy wants blocked, server-expanded.
|
"""Every hostname the policy wants blocked, server-expanded.
|
||||||
@@ -447,6 +469,15 @@ class Controller(QObject):
|
|||||||
@pyqtSlot(str, "QVariant")
|
@pyqtSlot(str, "QVariant")
|
||||||
def setConfig(self, key: str, value: Any) -> None:
|
def setConfig(self, key: str, value: Any) -> None:
|
||||||
"""Change a desktop-local setting and apply it immediately."""
|
"""Change a desktop-local setting and apply it immediately."""
|
||||||
|
if key in _ENFORCEMENT_KEYS and not value and self.blockerLocked:
|
||||||
|
# This machine is the one that actually enforces, so switching
|
||||||
|
# enforcement off here is the real escape hatch — the server's
|
||||||
|
# `blocker.enabled` is untouchable from this app anyway. The greyed
|
||||||
|
# switch in the dialog is not the enforcement: the policy poll can be
|
||||||
|
# seconds stale, and the tray and D-Bus come through here too.
|
||||||
|
self.toast.emit(self.blockerLockNote or _GUARD_LOCK_FALLBACK)
|
||||||
|
self.configChanged.emit() # snap the switch back to the stored value
|
||||||
|
return
|
||||||
if key == "autostart":
|
if key == "autostart":
|
||||||
autostart.set_autostart(bool(value), minimized=self._config.start_minimized)
|
autostart.set_autostart(bool(value), minimized=self._config.start_minimized)
|
||||||
elif key == "service_enabled":
|
elif key == "service_enabled":
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ Window {
|
|||||||
property string detail: ""
|
property string detail: ""
|
||||||
property bool on: false
|
property bool on: false
|
||||||
property bool available: true
|
property bool available: true
|
||||||
|
/**
|
||||||
|
* A one-way ratchet: while this is set the switch can be turned on but not
|
||||||
|
* off, so it greys only when it is *already* on. `available` greys both
|
||||||
|
* directions, which is the wrong shape for "you may not switch this off".
|
||||||
|
*/
|
||||||
|
property bool lockedOn: false
|
||||||
|
readonly property bool settable: available && !(lockedOn && on)
|
||||||
signal toggledTo(bool value)
|
signal toggledTo(bool value)
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -91,7 +98,7 @@ Window {
|
|||||||
spacing: 1
|
spacing: 1
|
||||||
Label {
|
Label {
|
||||||
text: toggleRow.label
|
text: toggleRow.label
|
||||||
color: toggleRow.available ? Theme.ink : Theme.faint
|
color: toggleRow.settable ? Theme.ink : Theme.faint
|
||||||
font.pixelSize: 13
|
font.pixelSize: 13
|
||||||
font.family: Theme.sansFamily
|
font.family: Theme.sansFamily
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,7 @@ Window {
|
|||||||
}
|
}
|
||||||
Switch {
|
Switch {
|
||||||
checked: toggleRow.on
|
checked: toggleRow.on
|
||||||
enabled: toggleRow.available
|
enabled: toggleRow.settable
|
||||||
onToggled: toggleRow.toggledTo(checked)
|
onToggled: toggleRow.toggledTo(checked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,11 +271,16 @@ Window {
|
|||||||
visible: controller.blockerError !== ""
|
visible: controller.blockerError !== ""
|
||||||
text: controller.blockerError
|
text: controller.blockerError
|
||||||
}
|
}
|
||||||
|
Banner {
|
||||||
|
visible: controller.blockerLocked
|
||||||
|
text: controller.blockerLockNote
|
||||||
|
}
|
||||||
|
|
||||||
Toggle {
|
Toggle {
|
||||||
label: "Enforce the server's rules here"
|
label: "Enforce the server's rules here"
|
||||||
detail: "Off, this machine is a timer and nothing else."
|
detail: "Off, this machine is a timer and nothing else."
|
||||||
on: dialog.config.enforce_blocking
|
on: dialog.config.enforce_blocking
|
||||||
|
lockedOn: controller.blockerLocked
|
||||||
onToggledTo: (value) => controller.setConfig("enforce_blocking", value)
|
onToggledTo: (value) => controller.setConfig("enforce_blocking", value)
|
||||||
}
|
}
|
||||||
Toggle {
|
Toggle {
|
||||||
@@ -276,6 +288,7 @@ Window {
|
|||||||
detail: "The managed region of /etc/hosts, plus a local DNS stub for the masks it cannot express."
|
detail: "The managed region of /etc/hosts, plus a local DNS stub for the masks it cannot express."
|
||||||
on: dialog.config.block_hosts_file
|
on: dialog.config.block_hosts_file
|
||||||
available: dialog.config.enforce_blocking
|
available: dialog.config.enforce_blocking
|
||||||
|
lockedOn: controller.blockerLocked
|
||||||
onToggledTo: (value) => controller.setConfig("block_hosts_file", value)
|
onToggledTo: (value) => controller.setConfig("block_hosts_file", value)
|
||||||
}
|
}
|
||||||
Toggle {
|
Toggle {
|
||||||
@@ -283,6 +296,7 @@ Window {
|
|||||||
detail: "/proc is scanned every few seconds and matches are asked to quit, then made to."
|
detail: "/proc is scanned every few seconds and matches are asked to quit, then made to."
|
||||||
on: dialog.config.block_apps
|
on: dialog.config.block_apps
|
||||||
available: dialog.config.enforce_blocking
|
available: dialog.config.enforce_blocking
|
||||||
|
lockedOn: controller.blockerLocked
|
||||||
onToggledTo: (value) => controller.setConfig("block_apps", value)
|
onToggledTo: (value) => controller.setConfig("block_apps", value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ export interface BlockerPolicy {
|
|||||||
reason: string;
|
reason: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
strict: boolean;
|
/** The guard is on and an interval is live, so it may not be switched off. Not the same as `active`: scheduled mode outside its window is inactive and still locked. */
|
||||||
|
locked: boolean;
|
||||||
|
/** Why it is locked, in the server's words — `""` when it is not. */
|
||||||
|
lock_reason: string;
|
||||||
redirect_ip: string;
|
redirect_ip: string;
|
||||||
domains: string[];
|
domains: string[];
|
||||||
apps: string[];
|
apps: string[];
|
||||||
@@ -209,7 +212,6 @@ export interface AppSettings {
|
|||||||
blocked_apps: string[];
|
blocked_apps: string[];
|
||||||
kill_blocked_apps: boolean;
|
kill_blocked_apps: boolean;
|
||||||
schedule: { label: string; start: string; end: string; days: number[] }[];
|
schedule: { label: string; start: string; end: string; days: number[] }[];
|
||||||
strict: boolean;
|
|
||||||
redirect_ip: string;
|
redirect_ip: string;
|
||||||
app_poll_seconds: number;
|
app_poll_seconds: number;
|
||||||
notify_on_block: boolean;
|
notify_on_block: boolean;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
* are answered by the desktop app's local DNS stub for the zones it can route.
|
* 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
|
* 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.
|
* off) and every banner says plainly what could not be enforced.
|
||||||
|
*
|
||||||
|
* The switch is one-way while you work: the server refuses to turn the guard off
|
||||||
|
* while an interval is running or paused, so `policy.locked` greys it — turning
|
||||||
|
* it *on* is never refused.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -97,6 +101,8 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
|
|||||||
[unexpandable],
|
[unexpandable],
|
||||||
);
|
);
|
||||||
const badExpansions = policy.data?.invalid_expansions ?? [];
|
const badExpansions = policy.data?.invalid_expansions ?? [];
|
||||||
|
/** The server has already folded "and the guard is on" in, so an off guard stays switchable on. */
|
||||||
|
const locked = policy.data?.locked ?? false;
|
||||||
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
|
const top = useMemo(() => events.data?.by_target.slice(0, 8) ?? [], [events.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,15 +112,23 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) =>
|
|||||||
title={policy.data?.active ? "Blocking is on" : "Blocking is off"}
|
title={policy.data?.active ? "Blocking is on" : "Blocking is off"}
|
||||||
caption="Rules live here; the desktop app enforces them on your machine."
|
caption="Rules live here; the desktop app enforces them on your machine."
|
||||||
actions={
|
actions={
|
||||||
<FormControlLabel
|
<Stack alignItems="flex-end">
|
||||||
control={
|
<FormControlLabel
|
||||||
<Switch
|
control={
|
||||||
checked={blocker?.enabled ?? false}
|
<Switch
|
||||||
onChange={(event) => void patch({ enabled: event.target.checked })}
|
checked={blocker?.enabled ?? false}
|
||||||
/>
|
disabled={locked}
|
||||||
}
|
onChange={(event) => void patch({ enabled: event.target.checked })}
|
||||||
label="Enabled"
|
/>
|
||||||
/>
|
}
|
||||||
|
label="Enabled"
|
||||||
|
/>
|
||||||
|
{locked && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{policy.data?.lock_reason}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user