From d280101bce17b7fe6bb5674e27c146f64a300000 Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Wed, 28 Oct 2026 09:50:58 +0300 Subject: [PATCH] disallow to disable guard during focus --- README.md | 4 + .../kadence/android/blocker/BlockerEngine.kt | 13 +- .../tech/kadence/android/data/dto/Dtos.kt | 5 +- .../android/ui/blocker/BlockerScreen.kt | 26 ++- .../tech/kadence/android/BlockerEngineTest.kt | 9 + .../kadence/android/BlockerSettingsTest.kt | 24 ++- .../fixtures/blocker_policy_active.json | 3 +- .../fixtures/blocker_policy_inactive.json | 3 +- .../src/test/resources/fixtures/settings.json | 1 - .../main/kotlin/tech/kadence/core/Blocker.kt | 15 +- .../kotlin/tech/kadence/core/BlockerTest.kt | 38 ++++ backend/app/api/routes_settings.py | 9 +- backend/app/services/blocker_service.py | 52 ++++- backend/app/services/settings_service.py | 26 ++- backend/app/settings_schema.py | 2 - backend/tests/test_api.py | 30 +++ backend/tests/test_blocker.py | 177 ++++++++++++++++++ desktop/kadence_desktop/controller.py | 31 +++ .../kadence_desktop/qml/SettingsDialog.qml | 18 +- frontend/src/lib/types.ts | 6 +- frontend/src/pages/BlockerPage.tsx | 32 +++- 21 files changed, 481 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 9579db9..e36ba13 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ agent enforces them on your machine: `*discord*`, matched against the process name and its command line. * **When** — only while a focus interval runs (the default), always, or on a 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 evening report. diff --git a/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt index ed7b415..661b7cc 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/blocker/BlockerEngine.kt @@ -62,6 +62,7 @@ import tech.kadence.core.DomainMatcher import tech.kadence.core.IntervalFacts import tech.kadence.core.evaluatePolicy import tech.kadence.core.expandDomains +import tech.kadence.core.guardLocked import tech.kadence.core.invalidExpansions import tech.kadence.core.unexpandableMasks import tech.kadence.core.zoneOrDefault @@ -83,7 +84,10 @@ data class BlockerUi( val reason: String = "", val enabled: Boolean = false, 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 notifyOnBlock: Boolean = true, /** Hostnames in the expanded set — `all_domains`, so it is non-zero even when idle. */ @@ -481,7 +485,8 @@ class BlockerEngine( reason = dto.reason, enabled = dto.enabled, mode = dto.mode, - strict = dto.strict, + locked = dto.locked, + lockReason = dto.lockReason, killApps = dto.killApps, notifyOnBlock = dto.notifyOnBlock, hostnames = dto.allDomains.size, @@ -509,7 +514,8 @@ class BlockerEngine( reason = verdict.reason, enabled = current?.enabled ?: false, mode = current?.mode ?: MODE_DURING_WORK, - strict = current?.strict ?: false, + locked = guardLocked(current ?: BlockerRules(), timerFacts()), + lockReason = "", killApps = current?.killBlockedApps ?: true, notifyOnBlock = current?.notifyOnBlock ?: true, hostnames = current?.let { expandDomains(it).size } ?: 0, @@ -648,6 +654,5 @@ fun BlockerSettingsDto.toCore(): BlockerRules = BlockerRules( blockedApps = blockedApps, killBlockedApps = killBlockedApps, schedule = schedule.map { BlockWindow(it.label, it.start, it.end, it.days) }, - strict = strict, notifyOnBlock = notifyOnBlock, ) diff --git a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt index e5027fe..84cbd71 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/data/dto/Dtos.kt @@ -140,7 +140,9 @@ data class BlockerPolicyDto( val reason: String = "", val mode: String = "during_work", 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", /** Empty whenever `active` is false — read `all_domains` instead. */ val domains: List = emptyList(), @@ -395,7 +397,6 @@ data class BlockerSettingsDto( @SerialName("blocked_apps") val blockedApps: List = emptyList(), @SerialName("kill_blocked_apps") val killBlockedApps: Boolean = true, val schedule: List = emptyList(), - val strict: Boolean = false, @SerialName("notify_on_block") val notifyOnBlock: Boolean = true, ) diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt index 26ad238..f803633 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt @@ -237,18 +237,26 @@ fun BlockerScreen( Panel(padding = 0.dp) { ToggleRow( label = "Focus guard on", - detail = if (ui.enabled) { - "Everything below applies." - } else { - "Off everywhere — the phone, the desktop app and the web UI all " + + detail = when { + ui.locked -> ui.lockReason.ifBlank { + "Finish or drop the interval to switch the guard off." + } + ui.enabled -> "Everything below applies." + else -> "Off everywhere — the phone, the desktop app and the web UI all " + "stop enforcing." }, checked = ui.enabled, - // The rules are the server's document and there is no outbox for it: - // a settings PUT is a deep merge of a shared object, and queueing one - // would mean replaying a stale version over whatever the web UI did - // meanwhile. So offline these say no, rather than appearing to work. - enabled = ui.connection == Connection.Online, + // Two reasons this says no, both said out loud rather than left as a + // grey switch: + // * offline — the rules are the server's document and there is no + // outbox for it: a settings PUT is a deep merge of a shared object, + // 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)) }, ) } diff --git a/android/app/src/test/kotlin/tech/kadence/android/BlockerEngineTest.kt b/android/app/src/test/kotlin/tech/kadence/android/BlockerEngineTest.kt index 8bec3b0..d99ff5b 100644 --- a/android/app/src/test/kotlin/tech/kadence/android/BlockerEngineTest.kt +++ b/android/app/src/test/kotlin/tech/kadence/android/BlockerEngineTest.kt @@ -75,6 +75,15 @@ class BlockerEngineTest { 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 fun setUp() { server = MockWebServer() diff --git a/android/app/src/test/kotlin/tech/kadence/android/BlockerSettingsTest.kt b/android/app/src/test/kotlin/tech/kadence/android/BlockerSettingsTest.kt index 4e70ddb..6e9cce3 100644 --- a/android/app/src/test/kotlin/tech/kadence/android/BlockerSettingsTest.kt +++ b/android/app/src/test/kotlin/tech/kadence/android/BlockerSettingsTest.kt @@ -49,7 +49,6 @@ class BlockerSettingsTest { assertEquals(listOf("*discord*", "instagram"), blocker.blockedApps) assertFalse(blocker.killBlockedApps) assertFalse(blocker.notifyOnBlock) - assertTrue(blocker.strict) assertEquals(1, blocker.schedule.size) assertEquals("Work mornings", blocker.schedule[0].label) assertEquals(listOf(0, 1, 2, 3, 4), blocker.schedule[0].days) @@ -106,7 +105,6 @@ class BlockerSettingsTest { assertTrue(blocker.blockedApps.isEmpty()) assertTrue(blocker.killBlockedApps) assertTrue(blocker.notifyOnBlock) - assertFalse(blocker.strict) assertEquals(BlockerRules.DEFAULT_SUBDOMAINS, blocker.subdomainExpansions) } @@ -118,7 +116,7 @@ class BlockerSettingsTest { {"settings":{"blocker":{ "enabled":null,"mode":null,"blocked_domains":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(), ) @@ -138,4 +136,24 @@ class BlockerSettingsTest { val inactive = KadenceJson.decodeFromString(fixture("blocker_policy_inactive.json")) assertTrue(inactive.invalidExpansions.isEmpty()) } + + @Test + fun `the policy carries whether the guard is locked on`() { + val active = KadenceJson.decodeFromString(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(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("""{"enabled":true}""") + assertFalse(policy.locked) + assertEquals("", policy.lockReason) + } } diff --git a/android/app/src/test/resources/fixtures/blocker_policy_active.json b/android/app/src/test/resources/fixtures/blocker_policy_active.json index b251479..ed09fe0 100644 --- a/android/app/src/test/resources/fixtures/blocker_policy_active.json +++ b/android/app/src/test/resources/fixtures/blocker_policy_active.json @@ -3,7 +3,8 @@ "reason": "a work interval is running", "mode": "during_work", "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", "domains": ["youtube.com", "www.youtube.com", "m.youtube.com", "reddit.com", "www.reddit.com", "m.reddit.com"], "apps": ["steam*", "*discord*"], diff --git a/android/app/src/test/resources/fixtures/blocker_policy_inactive.json b/android/app/src/test/resources/fixtures/blocker_policy_inactive.json index 16b8b48..30ea34a 100644 --- a/android/app/src/test/resources/fixtures/blocker_policy_inactive.json +++ b/android/app/src/test/resources/fixtures/blocker_policy_inactive.json @@ -3,7 +3,8 @@ "reason": "no work interval is running", "mode": "during_work", "enabled": true, - "strict": false, + "locked": false, + "lock_reason": "", "redirect_ip": "127.0.0.1", "domains": [], "apps": [], diff --git a/android/app/src/test/resources/fixtures/settings.json b/android/app/src/test/resources/fixtures/settings.json index 20bf882..f646048 100644 --- a/android/app/src/test/resources/fixtures/settings.json +++ b/android/app/src/test/resources/fixtures/settings.json @@ -27,7 +27,6 @@ "schedule": [ {"label": "Work mornings", "start": "09:00:00", "end": "12:00:00", "days": [0, 1, 2, 3, 4]} ], - "strict": true, "notify_on_block": false, "redirect_ip": "0.0.0.0", "app_poll_seconds": 5 diff --git a/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt b/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt index 1061f66..735f2c8 100644 --- a/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt +++ b/android/core/src/main/kotlin/tech/kadence/core/Blocker.kt @@ -39,7 +39,6 @@ data class BlockerRules( val blockedApps: List = emptyList(), val killBlockedApps: Boolean = true, val schedule: List = emptyList(), - val strict: Boolean = false, val notifyOnBlock: Boolean = true, ) { 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. */ fun zoneOrDefault(timezone: String?): ZoneId = runCatching { ZoneId.of(timezone?.trim().orEmpty()) }.getOrElse { ZoneId.systemDefault() } diff --git a/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt b/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt index c4353af..aa9f019 100644 --- a/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt +++ b/android/core/src/test/kotlin/tech/kadence/core/BlockerTest.kt @@ -303,4 +303,42 @@ class BlockerTest { 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)) + } } diff --git a/backend/app/api/routes_settings.py b/backend/app/api/routes_settings.py index d47d230..83f7f93 100644 --- a/backend/app/api/routes_settings.py +++ b/backend/app/api/routes_settings.py @@ -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 # take; pydantic's own `json()` renders them as text. 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"))} @router.post("/reset") def reset_settings(session: DbSession) -> dict: """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"))} diff --git a/backend/app/services/blocker_service.py b/backend/app/services/blocker_service.py index 5cd8edf..f83a090 100644 --- a/backend/app/services/blocker_service.py +++ b/backend/app/services/blocker_service.py @@ -187,12 +187,59 @@ def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bo 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]: - """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 current = pomodoro.current_session(session) active = False 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.mode == "always": @@ -218,7 +265,8 @@ def policy(session: Session, settings: AppSettings) -> dict[str, Any]: "reason": reason, "mode": blocker.mode, "enabled": blocker.enabled, - "strict": blocker.strict, + "locked": locked, + "lock_reason": GUARD_LOCK_NOTE if locked else "", "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 [], diff --git a/backend/app/services/settings_service.py b/backend/app/services/settings_service.py index a0e82a0..5f4aa0e 100644 --- a/backend/app/services/settings_service.py +++ b/backend/app/services/settings_service.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session from app.config import env_settings 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 _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: """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) 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 # 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. pomodoro.rescale_current(session, 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) diff --git a/backend/app/settings_schema.py b/backend/app/settings_schema.py index cdeed37..9cf33e4 100644 --- a/backend/app/settings_schema.py +++ b/backend/app/settings_schema.py @@ -204,8 +204,6 @@ class BlockerSettings(BaseModel): """Terminate a blocked app when it appears; otherwise only warn and log.""" 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" app_poll_seconds: int = Field(default=5, ge=1, le=60) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9efce6f..d98d69c 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -125,6 +125,36 @@ def test_invalid_settings_are_rejected(client): 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): body = client.post("/api/notifications/test", json={}).json() assert body["deliveries"][0]["channel"] == "log" diff --git a/backend/tests/test_blocker.py b/backend/tests/test_blocker.py index a9116be..272482b 100644 --- a/backend/tests/test_blocker.py +++ b/backend/tests/test_blocker.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest 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 "music.youtube.com" not in result["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 diff --git a/desktop/kadence_desktop/controller.py b/desktop/kadence_desktop/controller.py index af38f58..4bf5579 100644 --- a/desktop/kadence_desktop/controller.py +++ b/desktop/kadence_desktop/controller.py @@ -37,6 +37,14 @@ log = logging.getLogger(__name__) 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: seconds = max(0, int(seconds)) @@ -252,6 +260,20 @@ class Controller(QObject): def blockerReason(self) -> str: 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) def blockedDomains(self) -> list[str]: """Every hostname the policy wants blocked, server-expanded. @@ -447,6 +469,15 @@ class Controller(QObject): @pyqtSlot(str, "QVariant") def setConfig(self, key: str, value: Any) -> None: """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": autostart.set_autostart(bool(value), minimized=self._config.start_minimized) elif key == "service_enabled": diff --git a/desktop/kadence_desktop/qml/SettingsDialog.qml b/desktop/kadence_desktop/qml/SettingsDialog.qml index dc02e1e..a2b370e 100644 --- a/desktop/kadence_desktop/qml/SettingsDialog.qml +++ b/desktop/kadence_desktop/qml/SettingsDialog.qml @@ -81,6 +81,13 @@ Window { property string detail: "" property bool on: false 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) Layout.fillWidth: true @@ -91,7 +98,7 @@ Window { spacing: 1 Label { text: toggleRow.label - color: toggleRow.available ? Theme.ink : Theme.faint + color: toggleRow.settable ? Theme.ink : Theme.faint font.pixelSize: 13 font.family: Theme.sansFamily } @@ -107,7 +114,7 @@ Window { } Switch { checked: toggleRow.on - enabled: toggleRow.available + enabled: toggleRow.settable onToggled: toggleRow.toggledTo(checked) } } @@ -264,11 +271,16 @@ Window { visible: controller.blockerError !== "" text: controller.blockerError } + Banner { + visible: controller.blockerLocked + text: controller.blockerLockNote + } Toggle { label: "Enforce the server's rules here" detail: "Off, this machine is a timer and nothing else." on: dialog.config.enforce_blocking + lockedOn: controller.blockerLocked onToggledTo: (value) => controller.setConfig("enforce_blocking", value) } Toggle { @@ -276,6 +288,7 @@ Window { detail: "The managed region of /etc/hosts, plus a local DNS stub for the masks it cannot express." on: dialog.config.block_hosts_file available: dialog.config.enforce_blocking + lockedOn: controller.blockerLocked onToggledTo: (value) => controller.setConfig("block_hosts_file", value) } Toggle { @@ -283,6 +296,7 @@ Window { detail: "/proc is scanned every few seconds and matches are asked to quit, then made to." on: dialog.config.block_apps available: dialog.config.enforce_blocking + lockedOn: controller.blockerLocked onToggledTo: (value) => controller.setConfig("block_apps", value) } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 61f6080..693020e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -88,7 +88,10 @@ export interface BlockerPolicy { reason: string; mode: string; 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; domains: string[]; apps: string[]; @@ -209,7 +212,6 @@ export interface AppSettings { blocked_apps: string[]; kill_blocked_apps: boolean; schedule: { label: string; start: string; end: string; days: number[] }[]; - strict: boolean; redirect_ip: string; app_poll_seconds: number; notify_on_block: boolean; diff --git a/frontend/src/pages/BlockerPage.tsx b/frontend/src/pages/BlockerPage.tsx index 63cab83..22d3708 100644 --- a/frontend/src/pages/BlockerPage.tsx +++ b/frontend/src/pages/BlockerPage.tsx @@ -6,6 +6,10 @@ * 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. + * + * 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 { @@ -97,6 +101,8 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) => [unexpandable], ); 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]); return ( @@ -106,15 +112,23 @@ export default function BlockerPage({ onToast }: { onToast: (message: string) => title={policy.data?.active ? "Blocking is on" : "Blocking is off"} caption="Rules live here; the desktop app enforces them on your machine." actions={ - void patch({ enabled: event.target.checked })} - /> - } - label="Enabled" - /> + + void patch({ enabled: event.target.checked })} + /> + } + label="Enabled" + /> + {locked && ( + + {policy.data?.lock_reason} + + )} + } />