Compare commits
2 Commits
d92a8588bb
...
0a0d00f53a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a0d00f53a | |||
| e5f571a63e |
20
AGENTS.md
20
AGENTS.md
@@ -79,10 +79,22 @@ docs/SWIFT_GUIDELINES.md Swift coding/testing/patterns/security standards
|
|||||||
|
|
||||||
## Workflow expectations (user preference)
|
## Workflow expectations (user preference)
|
||||||
|
|
||||||
- **Red-green TDD**: update `docs/RULES_FEATURE_SPEC.md` first for behavior
|
These three are non-negotiable defaults — follow them on every task, not only
|
||||||
changes, write the failing test, run it (compile failure counts as red),
|
when reminded:
|
||||||
implement, re-run focused tests, then the full suite. Run tests often and
|
|
||||||
fail fast.
|
- **Always plan before execution.** Think through and lay out the approach (a
|
||||||
|
written plan / plan mode for anything non-trivial) and confirm scope before
|
||||||
|
editing code. Do not start changing files until the plan is clear.
|
||||||
|
- **Always use red-green TDD.** Update `docs/RULES_FEATURE_SPEC.md` first for
|
||||||
|
behavior changes, write the failing test, run it (compile failure counts as
|
||||||
|
red), implement, re-run focused tests, then the full suite. Run tests often
|
||||||
|
and fail fast.
|
||||||
|
- **Always attempt to validate the UI manually before committing.** Build and
|
||||||
|
run the app (simulator/device) and visually confirm the change behaves as
|
||||||
|
intended. This step **may be skipped only when such tooling is unavailable**
|
||||||
|
(e.g. the Xcode MCP / a simulator is not reachable in the session) — in that
|
||||||
|
case, say so explicitly and hand the verification back to the user rather
|
||||||
|
than silently skipping it.
|
||||||
- Conventional commits (`feat:`, `fix:`, `refactor:` …). **Agent attribution is
|
- Conventional commits (`feat:`, `fix:`, `refactor:` …). **Agent attribution is
|
||||||
required**: every commit an agent authors or co-authors must end with a
|
required**: every commit an agent authors or co-authors must end with a
|
||||||
`Co-Authored-By:` trailer naming the specific agent/model that did the work,
|
`Co-Authored-By:` trailer naming the specific agent/model that did the work,
|
||||||
|
|||||||
@@ -9,28 +9,29 @@ import UIKit
|
|||||||
|
|
||||||
/// Customizes the system shield. Apps under an open-limit rule show how many
|
/// Customizes the system shield. Apps under an open-limit rule show how many
|
||||||
/// opens were used and offer an "Open" secondary button while opens remain;
|
/// opens were used and offer an "Open" secondary button while opens remain;
|
||||||
/// everything else gets a plain blocked shield named after its rule.
|
/// everything else gets a plain blocked shield. Shields never name a rule — the
|
||||||
|
/// text is decided by the pure `ShieldPresentation`.
|
||||||
final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
||||||
override func configuration(shielding application: Application) -> ShieldConfiguration {
|
override func configuration(shielding application: Application) -> ShieldConfiguration {
|
||||||
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
|
guard let token = application.token else { return configuration(for: .blocked) }
|
||||||
return configuration(forApplicationToken: token)
|
return configuration(forApplicationToken: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func configuration(
|
override func configuration(
|
||||||
shielding application: Application, in category: ActivityCategory
|
shielding application: Application, in category: ActivityCategory
|
||||||
) -> ShieldConfiguration {
|
) -> ShieldConfiguration {
|
||||||
guard let token = application.token else { return blockedConfiguration(ruleName: nil) }
|
guard let token = application.token else { return configuration(for: .blocked) }
|
||||||
return configuration(forApplicationToken: token)
|
return configuration(forApplicationToken: token)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
|
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
|
||||||
blockedConfiguration(ruleName: nil)
|
configuration(for: .blocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func configuration(
|
override func configuration(
|
||||||
shielding webDomain: WebDomain, in category: ActivityCategory
|
shielding webDomain: WebDomain, in category: ActivityCategory
|
||||||
) -> ShieldConfiguration {
|
) -> ShieldConfiguration {
|
||||||
blockedConfiguration(ruleName: nil)
|
configuration(for: .blocked)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configuration(
|
private func configuration(
|
||||||
@@ -41,45 +42,26 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
|||||||
let snapshot = ShieldLookup.openLimitSnapshot(
|
let snapshot = ShieldLookup.openLimitSnapshot(
|
||||||
containingApplication: token, in: snapshots)
|
containingApplication: token, in: snapshots)
|
||||||
else {
|
else {
|
||||||
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name
|
return configuration(for: .blocked)
|
||||||
return blockedConfiguration(ruleName: name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
|
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
|
||||||
let remaining = max(0, snapshot.maxOpens - usage.opensUsed)
|
return configuration(
|
||||||
guard remaining > 0 else {
|
for: .openLimit(
|
||||||
return ShieldConfiguration(
|
opensUsed: usage.opensUsed,
|
||||||
backgroundBlurStyle: .systemMaterial,
|
maxOpens: snapshot.maxOpens,
|
||||||
title: .init(text: snapshot.name, color: .label),
|
sessionMinutes: MonitoringPlan.openSessionMinutes))
|
||||||
subtitle: .init(
|
|
||||||
text: "No opens left today — the block lifts tomorrow.",
|
|
||||||
color: .secondaryLabel),
|
|
||||||
primaryButtonLabel: .init(text: "OK", color: .white),
|
|
||||||
primaryButtonBackgroundColor: .systemBlue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return ShieldConfiguration(
|
|
||||||
backgroundBlurStyle: .systemMaterial,
|
|
||||||
title: .init(text: snapshot.name, color: .label),
|
|
||||||
subtitle: .init(
|
|
||||||
text: "Opened \(usage.opensUsed) of \(snapshot.maxOpens) times today. "
|
|
||||||
+ "Each open lasts \(MonitoringPlan.openSessionMinutes) minutes.",
|
|
||||||
color: .secondaryLabel),
|
|
||||||
primaryButtonLabel: .init(text: "OK", color: .white),
|
|
||||||
primaryButtonBackgroundColor: .systemBlue,
|
|
||||||
secondaryButtonLabel: .init(
|
|
||||||
text: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)",
|
|
||||||
color: .systemBlue)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration {
|
private func configuration(for presentation: ShieldPresentation) -> ShieldConfiguration {
|
||||||
ShieldConfiguration(
|
ShieldConfiguration(
|
||||||
backgroundBlurStyle: .systemMaterial,
|
backgroundBlurStyle: .systemMaterial,
|
||||||
title: .init(text: ruleName ?? "Blocked", color: .label),
|
title: .init(text: presentation.title, color: .label),
|
||||||
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel),
|
subtitle: .init(text: presentation.subtitle, color: .secondaryLabel),
|
||||||
primaryButtonLabel: .init(text: "OK", color: .white),
|
primaryButtonLabel: .init(text: "OK", color: .white),
|
||||||
primaryButtonBackgroundColor: .systemBlue
|
primaryButtonBackgroundColor: .systemBlue,
|
||||||
|
secondaryButtonLabel: presentation.secondaryButton.map {
|
||||||
|
.init(text: $0, color: .systemBlue)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
OpenAppLockTests/ShieldPresentationTests.swift
Normal file
53
OpenAppLockTests/ShieldPresentationTests.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
//
|
||||||
|
// ShieldPresentationTests.swift
|
||||||
|
// OpenAppLockTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import OpenAppLock
|
||||||
|
|
||||||
|
@Suite("Shield presentation")
|
||||||
|
struct ShieldPresentationTests {
|
||||||
|
@Test("Fully-blocked shield is generic and names no rule")
|
||||||
|
func blockedIsGeneric() {
|
||||||
|
let presentation = ShieldPresentation.blocked
|
||||||
|
#expect(presentation.title == "App Blocked")
|
||||||
|
#expect(presentation.subtitle == "This app is blocked by OpenAppLock.")
|
||||||
|
#expect(presentation.secondaryButton == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Open-limit shield with opens remaining shows counts and an Open button")
|
||||||
|
func openLimitWithOpensRemaining() {
|
||||||
|
let presentation = ShieldPresentation.openLimit(
|
||||||
|
opensUsed: 2, maxOpens: 5, sessionMinutes: 15)
|
||||||
|
// Title is generic — never the rule name.
|
||||||
|
#expect(presentation.title == "App Blocked")
|
||||||
|
#expect(presentation.subtitle.contains("Opened 2 of 5 times today"))
|
||||||
|
#expect(presentation.subtitle.contains("15 minutes"))
|
||||||
|
#expect(presentation.secondaryButton == "Open (3 left)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A single remaining open reads in the singular")
|
||||||
|
func openLimitSingularRemaining() {
|
||||||
|
let presentation = ShieldPresentation.openLimit(
|
||||||
|
opensUsed: 4, maxOpens: 5, sessionMinutes: 15)
|
||||||
|
#expect(presentation.secondaryButton == "Open (1 left)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Spent open-limit shield drops the Open button and reads as blocked")
|
||||||
|
func openLimitExhausted() {
|
||||||
|
let presentation = ShieldPresentation.openLimit(
|
||||||
|
opensUsed: 5, maxOpens: 5, sessionMinutes: 15)
|
||||||
|
#expect(presentation.title == "App Blocked")
|
||||||
|
#expect(presentation.subtitle == "No opens left today — the block lifts tomorrow.")
|
||||||
|
#expect(presentation.secondaryButton == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Over-spending never yields a negative or non-nil Open button")
|
||||||
|
func openLimitOverSpent() {
|
||||||
|
let presentation = ShieldPresentation.openLimit(
|
||||||
|
opensUsed: 9, maxOpens: 5, sessionMinutes: 15)
|
||||||
|
#expect(presentation.secondaryButton == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,16 +29,4 @@ enum ShieldLookup {
|
|||||||
.categoryTokens.contains(token)
|
.categoryTokens.contains(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Any enabled rule whose selection includes the application (for naming
|
|
||||||
/// plain blocked shields).
|
|
||||||
static func snapshot(
|
|
||||||
containingApplication token: ApplicationToken, in snapshots: [RuleSnapshot]
|
|
||||||
) -> RuleSnapshot? {
|
|
||||||
snapshots.first { snapshot in
|
|
||||||
guard snapshot.isEnabled else { return false }
|
|
||||||
return AppSelectionCodec.decode(snapshot.selectionData)
|
|
||||||
.applicationTokens.contains(token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
47
Shared/ShieldPresentation.swift
Normal file
47
Shared/ShieldPresentation.swift
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//
|
||||||
|
// ShieldPresentation.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
/// The text a shield shows, decided independently of any UI type so it can be
|
||||||
|
/// unit tested. Shields never name the rule that blocks an app: when several
|
||||||
|
/// rules cover the same app the responsible rule can't be told apart, so every
|
||||||
|
/// shield carries the same generic "App Blocked" title. Open-limit shields add
|
||||||
|
/// their functional detail (remaining opens + an "Open" button) beneath it.
|
||||||
|
struct ShieldPresentation: Equatable {
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
/// Label for the secondary "Open" button, or `nil` when the shield offers
|
||||||
|
/// no way through (a full block, or a spent open-limit budget).
|
||||||
|
let secondaryButton: String?
|
||||||
|
|
||||||
|
static let blockedTitle = "App Blocked"
|
||||||
|
|
||||||
|
/// A plain, fully-blocked app: no counts, no way through.
|
||||||
|
static let blocked = ShieldPresentation(
|
||||||
|
title: blockedTitle,
|
||||||
|
subtitle: "This app is blocked by OpenAppLock.",
|
||||||
|
secondaryButton: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
/// An open-limit shield. While opens remain it shows the running count and
|
||||||
|
/// an "Open (N left)" button; once spent it reads like a plain block.
|
||||||
|
static func openLimit(
|
||||||
|
opensUsed: Int, maxOpens: Int, sessionMinutes: Int
|
||||||
|
) -> ShieldPresentation {
|
||||||
|
let remaining = max(0, maxOpens - opensUsed)
|
||||||
|
guard remaining > 0 else {
|
||||||
|
return ShieldPresentation(
|
||||||
|
title: blockedTitle,
|
||||||
|
subtitle: "No opens left today — the block lifts tomorrow.",
|
||||||
|
secondaryButton: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ShieldPresentation(
|
||||||
|
title: blockedTitle,
|
||||||
|
subtitle: "Opened \(opensUsed) of \(maxOpens) times today. "
|
||||||
|
+ "Each open lasts \(sessionMinutes) minutes.",
|
||||||
|
secondaryButton: remaining == 1 ? "Open (1 left)" : "Open (\(remaining) left)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -485,9 +485,14 @@ Xh", "Xh left") is **derived**, not stored.
|
|||||||
`OpenSessionStore` reports a still-running granted open for it — so the
|
`OpenSessionStore` reports a still-running granted open for it — so the
|
||||||
foreground loop establishes the turnstile for newly created rules and never
|
foreground loop establishes the turnstile for newly created rules and never
|
||||||
re-locks an app mid-session.
|
re-locks an app mid-session.
|
||||||
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
|
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): every shield
|
||||||
shields show "Opened X of N times today" with an "Open (Y left)" secondary
|
carries the same generic **"App Blocked"** title — rule names are never shown,
|
||||||
button; other shields show the blocking rule's name.
|
since the rule a shield is attributed to cannot be determined reliably when
|
||||||
|
several rules cover the same app. Open-limit shields keep their functional
|
||||||
|
detail under that title ("Opened X of N times today" with an "Open (Y left)"
|
||||||
|
secondary button while opens remain); all other shields just read "This app is
|
||||||
|
blocked by OpenAppLock." The text-only decision lives in the pure, unit-tested
|
||||||
|
`ShieldPresentation` (in `Shared/`).
|
||||||
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
|
- **`OpenAppLockShieldAction`** (ShieldAction extension): the Open press
|
||||||
spends one open in the ledger, lifts the rule's shield, records the session
|
spends one open in the ledger, lifts the rule's shield, records the session
|
||||||
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session
|
expiry in `OpenSessionStore`, and starts the ~15-minute one-shot session
|
||||||
|
|||||||
Reference in New Issue
Block a user