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)
|
||||
|
||||
- **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.
|
||||
These three are non-negotiable defaults — follow them on every task, not only
|
||||
when reminded:
|
||||
|
||||
- **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
|
||||
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,
|
||||
|
||||
@@ -9,28 +9,29 @@ import UIKit
|
||||
|
||||
/// 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;
|
||||
/// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
override func configuration(
|
||||
shielding application: Application, in category: ActivityCategory
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
override func configuration(shielding webDomain: WebDomain) -> ShieldConfiguration {
|
||||
blockedConfiguration(ruleName: nil)
|
||||
configuration(for: .blocked)
|
||||
}
|
||||
|
||||
override func configuration(
|
||||
shielding webDomain: WebDomain, in category: ActivityCategory
|
||||
) -> ShieldConfiguration {
|
||||
blockedConfiguration(ruleName: nil)
|
||||
configuration(for: .blocked)
|
||||
}
|
||||
|
||||
private func configuration(
|
||||
@@ -41,45 +42,26 @@ final class ShieldConfigurationExtension: ShieldConfigurationDataSource {
|
||||
let snapshot = ShieldLookup.openLimitSnapshot(
|
||||
containingApplication: token, in: snapshots)
|
||||
else {
|
||||
let name = ShieldLookup.snapshot(containingApplication: token, in: snapshots)?.name
|
||||
return blockedConfiguration(ruleName: name)
|
||||
return configuration(for: .blocked)
|
||||
}
|
||||
|
||||
let usage = UsageLedger().usage(for: snapshot.id, onDayContaining: .now)
|
||||
let remaining = max(0, snapshot.maxOpens - usage.opensUsed)
|
||||
guard remaining > 0 else {
|
||||
return ShieldConfiguration(
|
||||
backgroundBlurStyle: .systemMaterial,
|
||||
title: .init(text: snapshot.name, color: .label),
|
||||
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)
|
||||
)
|
||||
return configuration(
|
||||
for: .openLimit(
|
||||
opensUsed: usage.opensUsed,
|
||||
maxOpens: snapshot.maxOpens,
|
||||
sessionMinutes: MonitoringPlan.openSessionMinutes))
|
||||
}
|
||||
|
||||
private func blockedConfiguration(ruleName: String?) -> ShieldConfiguration {
|
||||
private func configuration(for presentation: ShieldPresentation) -> ShieldConfiguration {
|
||||
ShieldConfiguration(
|
||||
backgroundBlurStyle: .systemMaterial,
|
||||
title: .init(text: ruleName ?? "Blocked", color: .label),
|
||||
subtitle: .init(text: "This app is blocked by OpenAppLock.", color: .secondaryLabel),
|
||||
title: .init(text: presentation.title, color: .label),
|
||||
subtitle: .init(text: presentation.subtitle, color: .secondaryLabel),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
foreground loop establishes the turnstile for newly created rules and never
|
||||
re-locks an app mid-session.
|
||||
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): open-limit
|
||||
shields show "Opened X of N times today" with an "Open (Y left)" secondary
|
||||
button; other shields show the blocking rule's name.
|
||||
- **`OpenAppLockShieldConfig`** (ShieldConfiguration extension): every shield
|
||||
carries the same generic **"App Blocked"** title — rule names are never shown,
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user