Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
test:
name: Test on ${{ matrix.device }}
runs-on: macos-26
timeout-minutes: 30
timeout-minutes: 45

strategy:
# Run every device leg to completion: an iPad failure shouldn't hide the
Expand Down
13 changes: 13 additions & 0 deletions Docs/AGENT_SWIFT_GUIDELINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ Follow the [Apple API Design Guidelines](https://www.swift.org/documentation/api
- Name methods and properties for their roles, not their types.
- Use `static let` for constants over global constants.

**Prefer descriptive names over comments.** Comments should be a relative
rarity in this codebase — before writing one, try renaming instead. Naming
must be unambiguous enough that an agent reading only the identifiers, with no
surrounding comments, can correctly infer behavior. If a name needs a comment
to not be confusing, rename it rather than annotate it.

> **Project note:** this does not apply to the feature-spec doc comments
> indexed by AGENTS.md → "Rules feature map" — those `///` comments *are* the
> behavior spec, not restatements of what the code already says, and remain
> required (kept in sync with the code in the same commit, per the Workflow
> expectations in AGENTS.md). The rarity guidance targets ordinary inline/
> implementation comments, not that spec layer.

### Error handling

Use typed throws (Swift 6+) and pattern matching:
Expand Down
10 changes: 5 additions & 5 deletions OpenAppLock/Logic/RulePolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ nonisolated enum RulePolicy {
_ snapshot: RuleSnapshotDTO, usage: RuleUsageDTO? = nil,
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
snapshot.hardMode && snapshot.activation(usage: usage, at: now, calendar: calendar).isBlocking
UninstallProtectionPolicy.isHardLocked(snapshot, usage: usage, at: now, calendar: calendar)
}

static func canEdit(
Expand Down Expand Up @@ -78,9 +78,8 @@ nonisolated enum RulePolicy {
snapshots: [RuleSnapshotDTO], usageFor: (RuleSnapshotDTO) -> RuleUsageDTO? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
snapshots.contains {
isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar)
}
UninstallProtectionPolicy.isAnyHardLocked(
snapshots: snapshots, usageFor: usageFor, at: now, calendar: calendar)
}

/// App lists feed active shields, so while any hard-mode rule is actively
Expand Down Expand Up @@ -113,7 +112,8 @@ nonisolated enum RulePolicy {
usageFor: (RuleSnapshotDTO) -> RuleUsageDTO? = { _ in nil },
at now: Date = .now, calendar: Calendar = .current
) -> Bool {
enabled && isAnyHardLocked(snapshots: snapshots, usageFor: usageFor, at: now, calendar: calendar)
UninstallProtectionPolicy.shouldDenyAppRemoval(
snapshots: snapshots, enabled: enabled, usageFor: usageFor, at: now, calendar: calendar)
}

/// Temporarily pauses the rule's current block for `temporaryPauseMinutes`.
Expand Down
2 changes: 1 addition & 1 deletion OpenAppLock/Models/RuleDraft.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ struct RuleDraft: Hashable {
}
Diag.log(
.rule, .event,
"commit rule-\(rule.id.uuidString.prefix(8)) \"\(name)\" \(rule.kindRaw) hard=\(hardMode) enabled=\(rule.isEnabled) list=\(appList?.name ?? "none")")
"commit rule-\(rule.id.logTag) \"\(name)\" \(rule.kindRaw) hard=\(hardMode) enabled=\(rule.isEnabled) list=\(appList?.name ?? "none")")
}

/// Creates and inserts a new rule from this draft. The rule is inserted
Expand Down
20 changes: 10 additions & 10 deletions OpenAppLock/Services/RuleEnforcer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ final class RuleEnforcer {
private func expireStalePauseIfNeeded(_ rule: BlockingRule, at now: Date) {
guard let pausedUntil = rule.pausedUntil, pausedUntil <= now else { return }
rule.pausedUntil = nil
let rid = rule.id.uuidString.prefix(8)
Diag.log(.enforcer, "rule-\(rid): pause expired, re-armed")
let ruleTag = rule.id.logTag
Diag.log(.enforcer, "rule-\(ruleTag): pause expired, re-armed")
}

/// 4c safety net: a skipped monitor `intervalDidStart` would block usage
Expand All @@ -163,8 +163,8 @@ final class RuleEnforcer {
dayStarts.confirmedStart(for: rule.id) != calendar.startOfDay(for: now)
else { return }
dayStarts.setConfirmedStart(calendar.startOfDay(for: now), for: rule.id)
let rid = rule.id.uuidString.prefix(8)
Diag.log(.dayStart, "rule-\(rid): foreground confirmed today's start (safety net)")
let ruleTag = rule.id.logTag
Diag.log(.dayStart, "rule-\(ruleTag): foreground confirmed today's start (safety net)")
}

/// Publishes the new actively-blocking set and logs when it changes. "Blocked
Expand Down Expand Up @@ -277,10 +277,10 @@ actor RuleEnforcementEngine {
let isBlocking = status.isActive
logTimeLimitDecision(snapshot, usage: usage, isBlocking: isBlocking, at: now)
guard isBlocking || shouldGateOpenLimit(snapshot, at: now, calendar: calendar) else {
let rid = snapshot.id.uuidString.prefix(8)
let ruleTag = snapshot.id.logTag
Diag.log(
.enforcer,
"rule-\(rid) \(snapshot.kindRaw): not shielded (status=\(status) enabled=\(snapshot.isEnabled))")
"rule-\(ruleTag) \(snapshot.kindRaw): not shielded (status=\(status) enabled=\(snapshot.isEnabled))")
return (isBlocking, false)
}
applyShield(for: snapshot, status: status, usage: usage, isBlocking: isBlocking)
Expand All @@ -292,10 +292,10 @@ actor RuleEnforcementEngine {
_ snapshot: RuleSnapshotDTO, usage: RuleUsageDTO?, isBlocking: Bool, at now: Date
) {
guard snapshot.kind == .timeLimit, let usage else { return }
let rid = snapshot.id.uuidString.prefix(8)
let ruleTag = snapshot.id.logTag
Diag.log(
.usage,
"timeLimit rule-\(rid) used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes) blocking=\(isBlocking)")
"timeLimit rule-\(ruleTag) used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes) blocking=\(isBlocking)")
}

/// Records the rule's shield and writes it. Allow Only is a Schedule-only
Expand All @@ -304,10 +304,10 @@ actor RuleEnforcementEngine {
private func applyShield(
for snapshot: RuleSnapshotDTO, status: RuleStatus, usage: RuleUsageDTO?, isBlocking: Bool
) {
let rid = snapshot.id.uuidString.prefix(8)
let ruleTag = snapshot.id.logTag
Diag.log(
.enforcer, .event,
"rule-\(rid) \(snapshot.kindRaw): shield (\(isBlocking ? "active status=\(status)" : "open-limit gate")\(usage.map { ", used=\($0.minutesUsed)/opens=\($0.opensUsed)" } ?? ""))")
"rule-\(ruleTag) \(snapshot.kindRaw): shield (\(isBlocking ? "active status=\(status)" : "open-limit gate")\(usage.map { ", used=\($0.minutesUsed)/opens=\($0.opensUsed)" } ?? ""))")
shields.applyShield(
ruleID: snapshot.id,
selectionData: snapshot.selectionData,
Expand Down
50 changes: 6 additions & 44 deletions OpenAppLock/Services/RuleScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -400,26 +400,13 @@ nonisolated final class DeviceActivityCenterMonitor: ActivityMonitoring, @unchec
func startDailyMonitoring(
name: String, selectionData: Data?, eventMinutes: [String: Int]
) throws {
let selection = AppSelectionCodec.decode(selectionData)
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: 0, minute: 0),
intervalEnd: DateComponents(hour: 23, minute: 59),
repeats: true
)
let events = Dictionary(
uniqueKeysWithValues: eventMinutes.map { eventName, minutes in
(
DeviceActivityEvent.Name(eventName),
DeviceActivityEvent(
applications: selection.applicationTokens,
categories: selection.categoryTokens,
webDomains: selection.webDomainTokens,
threshold: DateComponents(minute: minutes),
includesPastActivity: true
)
)
}
)
let events = DeviceActivityFactory.thresholdEvents(
selectionData: selectionData, eventMinutes: eventMinutes)
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events)
}

Expand All @@ -437,42 +424,17 @@ nonisolated final class DeviceActivityCenterMonitor: ActivityMonitoring, @unchec
}

func startOneShotMonitoring(name: String, from start: Date, to end: Date) throws {
let calendar = Calendar.current
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let schedule = DeviceActivitySchedule(
intervalStart: calendar.dateComponents(components, from: start),
intervalEnd: calendar.dateComponents(components, from: end),
repeats: false
)
let schedule = DeviceActivityFactory.nonRepeatingSchedule(from: start, to: end)
try center.startMonitoring(DeviceActivityName(name), during: schedule)
}

func startDayMonitoring(
name: String, from start: Date, to end: Date,
selectionData: Data?, eventMinutes: [String: Int]
) throws {
let calendar = Calendar.current
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let schedule = DeviceActivitySchedule(
intervalStart: calendar.dateComponents(components, from: start),
intervalEnd: calendar.dateComponents(components, from: end),
repeats: false
)
let selection = AppSelectionCodec.decode(selectionData)
let events = Dictionary(
uniqueKeysWithValues: eventMinutes.map { eventName, minutes in
(
DeviceActivityEvent.Name(eventName),
DeviceActivityEvent(
applications: selection.applicationTokens,
categories: selection.categoryTokens,
webDomains: selection.webDomainTokens,
threshold: DateComponents(minute: minutes),
includesPastActivity: true
)
)
}
)
let schedule = DeviceActivityFactory.nonRepeatingSchedule(from: start, to: end)
let events = DeviceActivityFactory.thresholdEvents(
selectionData: selectionData, eventMinutes: eventMinutes)
try center.startMonitoring(DeviceActivityName(name), during: schedule, events: events)
}

Expand Down
34 changes: 10 additions & 24 deletions OpenAppLockMonitor/DeviceActivityMonitorExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,10 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {

/// A per-day block or warn activity ended at midnight: register the same
/// activity kind for the rule's next scheduled day, so background enforcement
/// continues without a foreground sync. Best-effort — the foreground N=2
/// arming (`RuleScheduler.dayPlans`) is the safety net. See the day-keyed
/// enforcement spec §5. Device-only: the simulator delivers no callbacks.
/// continues without a foreground sync. Best-effort; `RuleScheduler.dayPlans`
/// only arms the current-or-next scheduled day (N = 1), so this self-arm is
/// what covers the day after. See the day-keyed enforcement spec §5.
/// Device-only: the simulator delivers no callbacks.
private func reArmNextScheduledDay(endedActivity name: String) {
let isWarn = MonitoringPlan.ruleID(fromWarnActivityName: name) != nil
guard
Expand Down Expand Up @@ -133,7 +134,7 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
else {
Diag.log(
.scheduler,
"self-arm rule-\(ruleID.uuidString.prefix(8)): no next scheduled day after \(endedKey)")
"self-arm rule-\(ruleID.logTag): no next scheduled day after \(endedKey)")
return
}

Expand All @@ -143,7 +144,7 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
? MonitoringPlan.warnActivityName(for: ruleID, dayKey: nextKey)
: MonitoringPlan.dailyActivityName(for: ruleID, dayKey: nextKey)
let center = DeviceActivityCenter()
// The foreground net (N=2) may already have armed the next scheduled day
// RuleScheduler.dayPlans (N = 1) may already have armed the next scheduled day
// from its own midnight; restarting it here is a needless duplicate
// start (EC7: `includesPastActivity` would backfill any midnight-to-now
// gap anyway, since `nextStart` is a round hour). Only arm when it isn't
Expand All @@ -152,25 +153,10 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor {
Diag.log(.scheduler, "self-arm \(nextName): already armed, skipping")
return
}
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let schedule = DeviceActivitySchedule(
intervalStart: calendar.dateComponents(components, from: nextStart),
intervalEnd: calendar.dateComponents(components, from: nextEnd),
repeats: false)
let selection = AppSelectionCodec.decode(snapshot.selectionData)
let deviceEvents = Dictionary(
uniqueKeysWithValues: events.map { eventName, minutes in
(
DeviceActivityEvent.Name(eventName),
DeviceActivityEvent(
applications: selection.applicationTokens,
categories: selection.categoryTokens,
webDomains: selection.webDomainTokens,
threshold: DateComponents(minute: minutes),
includesPastActivity: true
)
)
})
let schedule = DeviceActivityFactory.nonRepeatingSchedule(
from: nextStart, to: nextEnd, calendar: calendar)
let deviceEvents = DeviceActivityFactory.thresholdEvents(
selectionData: snapshot.selectionData, eventMinutes: events)
do {
try center.startMonitoring(
DeviceActivityName(nextName), during: schedule, events: deviceEvents)
Expand Down
9 changes: 2 additions & 7 deletions OpenAppLockShieldAction/ShieldActionExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ final class ShieldActionExtension: ShieldActionDelegate {
}

private func grantOpen(ruleID: UUID) -> ShieldActionResponse {
Diag.log(.session, .event, "shieldAction Open pressed rule-\(ruleID.uuidString.prefix(8))")
Diag.log(.session, .event, "shieldAction Open pressed rule-\(ruleID.logTag)")
let enforcement = LimitEnforcement(
snapshots: RuleSnapshotUserDefaultsStore(),
ledger: UsageLedger(),
Expand Down Expand Up @@ -94,12 +94,7 @@ final class ShieldActionExtension: ShieldActionDelegate {
let end = calendar.date(
byAdding: .minute, value: MonitoringPlan.openSessionMinutes + 1, to: now)
else { return }
let components: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second]
let schedule = DeviceActivitySchedule(
intervalStart: calendar.dateComponents(components, from: now),
intervalEnd: calendar.dateComponents(components, from: end),
repeats: false
)
let schedule = DeviceActivityFactory.nonRepeatingSchedule(from: now, to: end, calendar: calendar)
try? DeviceActivityCenter().startMonitoring(
DeviceActivityName(MonitoringPlan.sessionActivityName(for: ruleID)),
during: schedule
Expand Down
2 changes: 1 addition & 1 deletion OpenAppLockTests/DiagnosticLogTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ struct DiagnosticLogTests {
@Test("Timestamp prefix of a line equals the rendered timestamp")
func timestampPrefix() {
let entry = LogEntry(
date: fixedDate, level: .debug, source: .report, category: .report, message: "x",
date: fixedDate, level: .debug, source: .report, category: .monitor, message: "x",
file: "Report.swift", line: 1, function: "f()")
#expect(LogTimestamp.prefix(ofLine: entry.formatted) == "2026-06-22T14:03:11.482Z")
}
Expand Down
9 changes: 9 additions & 0 deletions Shared/DTOs/RuleSnapshotDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ nonisolated struct RuleSnapshotDTO: Codable, Equatable {
guard let pausedUntil else { return false }
return pausedUntil > now
}

/// Whether this rule is currently eligible to react to an event of the given
/// kind: enabled, matching kind, not paused, and scheduled today. Shared by
/// `LimitEnforcement`'s per-event handlers so the four-part check can't drift
/// between them.
func isEligible(kind: RuleKind, at now: Date, calendar: Calendar) -> Bool {
isEnabled && self.kind == kind && !isPaused(at: now)
&& isScheduledToday(at: now, calendar: calendar)
}
}

nonisolated extension RuleSnapshotDTO {
Expand Down
18 changes: 13 additions & 5 deletions Shared/Diagnostics/LogEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@

import Foundation

/// Short, human-scannable UUID form used to tag log lines (e.g. "rule-a1b2c3d4").
/// Not for identity — only for grepping/correlating a rule's log lines together.
nonisolated extension UUID {
var logTag: String { String(uuidString.prefix(8)) }
}

/// Severity of a diagnostic entry. `event` flags the load-bearing
/// "a block/threshold actually fired" lines for easy grepping; it maps to the
/// unified log's `.default` (notice) level.
Expand All @@ -17,8 +23,8 @@ nonisolated enum LogLevel: String, Sendable, CaseIterable {
/// The area a log entry belongs to — both the `os.Logger` category (for Console
/// filtering) and the in-line `[source/category]` tag.
nonisolated enum LogCategory: String, Sendable {
case enforcer, scheduler, shield, monitor, report
case usage, dayStart, session, appList, rule, auth, lifecycle
case enforcer, scheduler, shield, monitor
case usage, dayStart, session, appList, rule, lifecycle
}

/// Which process wrote an entry, inferred from the running bundle so no
Expand Down Expand Up @@ -64,6 +70,8 @@ nonisolated enum LogTimestamp {
/// Builds and parses per-process daily log filenames: `<source>-<YYYY-MM-DD>.log`.
enum LogFilename {
static let fileExtension = "log"
static let dayKeyLength = 10
static let dayKeySuffixLength = dayKeyLength + 1

static func make(source: String, day: String) -> String {
"\(source)-\(day).\(fileExtension)"
Expand All @@ -76,10 +84,10 @@ enum LogFilename {
let suffix = ".\(fileExtension)"
guard filename.hasSuffix(suffix) else { return nil }
let stem = String(filename.dropLast(suffix.count))
guard stem.count > 11 else { return nil } // "x-YYYY-MM-DD" is 12+
let day = String(stem.suffix(10))
guard stem.count > dayKeySuffixLength else { return nil }
let day = String(stem.suffix(dayKeyLength))
guard isDayKey(day) else { return nil }
let source = String(stem.dropLast(11)) // drop "-YYYY-MM-DD"
let source = String(stem.dropLast(dayKeySuffixLength))
guard !source.isEmpty else { return nil }
return (source, day)
}
Expand Down
Loading
Loading