diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 238c847..ce78b24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/Docs/AGENT_SWIFT_GUIDELINES.md b/Docs/AGENT_SWIFT_GUIDELINES.md index d59a044..89b947a 100644 --- a/Docs/AGENT_SWIFT_GUIDELINES.md +++ b/Docs/AGENT_SWIFT_GUIDELINES.md @@ -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: diff --git a/OpenAppLock/Logic/RulePolicy.swift b/OpenAppLock/Logic/RulePolicy.swift index aaefd4d..a04005b 100644 --- a/OpenAppLock/Logic/RulePolicy.swift +++ b/OpenAppLock/Logic/RulePolicy.swift @@ -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( @@ -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 @@ -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`. diff --git a/OpenAppLock/Models/RuleDraft.swift b/OpenAppLock/Models/RuleDraft.swift index bf41384..031f27e 100644 --- a/OpenAppLock/Models/RuleDraft.swift +++ b/OpenAppLock/Models/RuleDraft.swift @@ -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 diff --git a/OpenAppLock/Services/RuleEnforcer.swift b/OpenAppLock/Services/RuleEnforcer.swift index 99ccc9e..7b94fd4 100644 --- a/OpenAppLock/Services/RuleEnforcer.swift +++ b/OpenAppLock/Services/RuleEnforcer.swift @@ -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 @@ -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 @@ -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) @@ -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 @@ -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, diff --git a/OpenAppLock/Services/RuleScheduler.swift b/OpenAppLock/Services/RuleScheduler.swift index edb67b1..c3143e9 100644 --- a/OpenAppLock/Services/RuleScheduler.swift +++ b/OpenAppLock/Services/RuleScheduler.swift @@ -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) } @@ -437,13 +424,7 @@ nonisolated final class DeviceActivityCenterMonitor: ActivityMonitoring, @unchec } func startOneShotMonitoring(name: String, from start: Date, to end: Date) throws { - let calendar = Calendar.current - let components: Set = [.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) } @@ -451,28 +432,9 @@ nonisolated final class DeviceActivityCenterMonitor: ActivityMonitoring, @unchec name: String, from start: Date, to end: Date, selectionData: Data?, eventMinutes: [String: Int] ) throws { - let calendar = Calendar.current - let components: Set = [.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) } diff --git a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift index 7df55c2..c1dd5c0 100644 --- a/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift +++ b/OpenAppLockMonitor/DeviceActivityMonitorExtension.swift @@ -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 @@ -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 } @@ -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 @@ -152,25 +153,10 @@ final class DeviceActivityMonitorExtension: DeviceActivityMonitor { Diag.log(.scheduler, "self-arm \(nextName): already armed, skipping") return } - let components: Set = [.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) diff --git a/OpenAppLockShieldAction/ShieldActionExtension.swift b/OpenAppLockShieldAction/ShieldActionExtension.swift index e85efc2..5d7d31e 100644 --- a/OpenAppLockShieldAction/ShieldActionExtension.swift +++ b/OpenAppLockShieldAction/ShieldActionExtension.swift @@ -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(), @@ -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 = [.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 diff --git a/OpenAppLockTests/DiagnosticLogTests.swift b/OpenAppLockTests/DiagnosticLogTests.swift index 8d437c3..6953e0b 100644 --- a/OpenAppLockTests/DiagnosticLogTests.swift +++ b/OpenAppLockTests/DiagnosticLogTests.swift @@ -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") } diff --git a/Shared/DTOs/RuleSnapshotDTO.swift b/Shared/DTOs/RuleSnapshotDTO.swift index 1da5296..51ee40c 100644 --- a/Shared/DTOs/RuleSnapshotDTO.swift +++ b/Shared/DTOs/RuleSnapshotDTO.swift @@ -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 { diff --git a/Shared/Diagnostics/LogEntry.swift b/Shared/Diagnostics/LogEntry.swift index d7c88d3..fddb577 100644 --- a/Shared/Diagnostics/LogEntry.swift +++ b/Shared/Diagnostics/LogEntry.swift @@ -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. @@ -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 @@ -64,6 +70,8 @@ nonisolated enum LogTimestamp { /// Builds and parses per-process daily log filenames: `-.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)" @@ -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) } diff --git a/Shared/Enforcement/LimitEnforcement.swift b/Shared/Enforcement/LimitEnforcement.swift index ab18748..d48934c 100644 --- a/Shared/Enforcement/LimitEnforcement.swift +++ b/Shared/Enforcement/LimitEnforcement.swift @@ -22,11 +22,11 @@ struct LimitEnforcement { /// proactively shielded on enabled days so the shield can count opens; /// time-limit rules start the day unshielded. func handleDayStart(ruleID: UUID, now: Date = .now, calendar: Calendar = .current) { - let rid = ruleID.uuidString.prefix(8) + let ruleTag = ruleID.logTag guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, !snapshot.isPaused(at: now) else { - Diag.log(.dayStart, "dayStart rule-\(rid): skipped (missing/disabled/paused)") + Diag.log(.dayStart, "dayStart rule-\(ruleTag): skipped (missing/disabled/paused)") return } confirmDayStart(ruleID: ruleID, kind: snapshot.kind, now: now, calendar: calendar) @@ -37,18 +37,18 @@ struct LimitEnforcement { break case .openLimit: if scheduledToday { - Diag.log(.dayStart, .event, "dayStart rule-\(rid) openLimit: shield (proactive gate, scheduled today)") + Diag.log(.dayStart, .event, "dayStart rule-\(ruleTag) openLimit: shield (proactive gate, scheduled today)") shield(snapshot) } else { - Diag.log(.dayStart, "dayStart rule-\(rid) openLimit: clear (not scheduled today)") + Diag.log(.dayStart, "dayStart rule-\(ruleTag) openLimit: clear (not scheduled today)") shields.clearShield(ruleID: ruleID) } case .timeLimit: if snapshot.limitReached(given: usage, at: now), scheduledToday { - Diag.log(.dayStart, .event, "dayStart rule-\(rid) timeLimit: shield (limit already reached, used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes))") + Diag.log(.dayStart, .event, "dayStart rule-\(ruleTag) timeLimit: shield (limit already reached, used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes))") shield(snapshot) } else { - Diag.log(.dayStart, "dayStart rule-\(rid) timeLimit: clear (used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes) scheduledToday=\(scheduledToday))") + Diag.log(.dayStart, "dayStart rule-\(ruleTag) timeLimit: clear (used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes) scheduledToday=\(scheduledToday))") shields.clearShield(ruleID: ruleID) } } @@ -67,13 +67,13 @@ struct LimitEnforcement { // was correctly skipped (vs the new-day zero below). Diag.log( .dayStart, - "confirm rule-\(ruleID.uuidString.prefix(8)): skipped (same-day re-fire, start already \(LogTimestamp.string(from: today)))") + "confirm rule-\(ruleID.logTag): skipped (same-day re-fire, start already \(LogTimestamp.string(from: today)))") return } dayStarts.setConfirmedStart(today, for: ruleID) Diag.log( .dayStart, .event, - "confirm rule-\(ruleID.uuidString.prefix(8)) start=\(LogTimestamp.string(from: today))" + "confirm rule-\(ruleID.logTag) start=\(LogTimestamp.string(from: today))" + (kind == .timeLimit ? " (zeroed today's ledger)" : "")) if kind == .timeLimit { ledger.setUsage(RuleUsageDTO(), for: ruleID, onDayContaining: now, calendar: calendar) @@ -91,12 +91,12 @@ struct LimitEnforcement { _ minutes: Int, ruleID: UUID, activityDayKey: String? = nil, now: Date = .now, calendar: Calendar = .current ) { - let rid = ruleID.uuidString.prefix(8) + let ruleTag = ruleID.logTag let today = UsageLedger.dayKey(for: now, calendar: calendar) if let activityDayKey, activityDayKey != today { Diag.log( .usage, - "drop rule-\(rid): stale day-keyed flush (activity=\(activityDayKey) today=\(today))") + "drop rule-\(ruleTag): stale day-keyed flush (activity=\(activityDayKey) today=\(today))") return } // A `minutes-k` checkpoint reports k minutes of *today's* usage, which @@ -110,31 +110,29 @@ struct LimitEnforcement { now.timeIntervalSince(calendar.startOfDay(for: now)) / 60) Diag.log( .usage, .event, - "usageEvent rule-\(rid) minutes=\(minutes) sinceMidnight=\(minutesSinceMidnight)") + "usageEvent rule-\(ruleTag) minutes=\(minutes) sinceMidnight=\(minutesSinceMidnight)") guard minutes <= minutesSinceMidnight else { Diag.log( .usage, - "drop rule-\(rid): stale checkpoint minutes=\(minutes) > sinceMidnight=\(minutesSinceMidnight) (late cross-midnight flush)") + "drop rule-\(ruleTag): stale checkpoint minutes=\(minutes) > sinceMidnight=\(minutesSinceMidnight) (late cross-midnight flush)") return } // Reject events that arrive before today's interval boundary has been // observed — yesterday's batched checkpoints flushed late across midnight. guard dayStarts.hasConfirmedStart(for: ruleID, onDayContaining: now, calendar: calendar) else { - Diag.log(.usage, "drop rule-\(rid): no confirmed day-start yet (pre-boundary flush)") + Diag.log(.usage, "drop rule-\(ruleTag): no confirmed day-start yet (pre-boundary flush)") return } // Record only for a rule that can actually be active today, so a stale or // irrelevant event can't corrupt today's ledger for a rule that isn't. - guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, - snapshot.kind == .timeLimit, - !snapshot.isPaused(at: now), - snapshot.isScheduledToday(at: now, calendar: calendar) + guard let snapshot = snapshots.snapshot(for: ruleID), + snapshot.isEligible(kind: .timeLimit, at: now, calendar: calendar) else { Diag.log( .usage, - "drop rule-\(rid): not eligible today (enabled/timeLimit/unpaused/scheduledToday check failed)") + "drop rule-\(ruleTag): not eligible today (enabled/timeLimit/unpaused/scheduledToday check failed)") return } ledger.recordMinutesUsed(minutes, for: ruleID, onDayContaining: now, calendar: calendar) @@ -142,7 +140,7 @@ struct LimitEnforcement { let reached = snapshot.limitReached(given: usage, at: now) Diag.log( .usage, .event, - "record rule-\(rid) used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes) limitReached=\(reached)") + "record rule-\(ruleTag) used=\(usage.minutesUsed)/\(snapshot.dailyLimitMinutes) limitReached=\(reached)") if reached { shield(snapshot) } @@ -154,17 +152,15 @@ struct LimitEnforcement { ruleID: UUID, now: Date = .now, calendar: Calendar = .current ) { sessions.endSession(for: ruleID) - guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, - snapshot.kind == .openLimit, - !snapshot.isPaused(at: now), - snapshot.isScheduledToday(at: now, calendar: calendar) + guard let snapshot = snapshots.snapshot(for: ruleID), + snapshot.isEligible(kind: .openLimit, at: now, calendar: calendar) else { Diag.log( .session, - "openSessionEnded rule-\(ruleID.uuidString.prefix(8)): no re-shield (ineligible)") + "openSessionEnded rule-\(ruleID.logTag): no re-shield (ineligible)") return } - Diag.log(.session, .event, "openSessionEnded rule-\(ruleID.uuidString.prefix(8)): re-shield") + Diag.log(.session, .event, "openSessionEnded rule-\(ruleID.logTag): re-shield") shield(snapshot) } @@ -176,18 +172,17 @@ struct LimitEnforcement { func handlePauseEnded( ruleID: UUID, now: Date = .now, calendar: Calendar = .current ) { - let rid = ruleID.uuidString.prefix(8) - guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, - snapshot.kind == .timeLimit, !snapshot.isPaused(at: now), - snapshot.isScheduledToday(at: now, calendar: calendar), + let ruleTag = ruleID.logTag + guard let snapshot = snapshots.snapshot(for: ruleID), + snapshot.isEligible(kind: .timeLimit, at: now, calendar: calendar), snapshot.limitReached( given: ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar), at: now) else { - Diag.log(.scheduler, "pauseEnded rule-\(rid): clear (ineligible/under budget/still paused)") + Diag.log(.scheduler, "pauseEnded rule-\(ruleTag): clear (ineligible/under budget/still paused)") shields.clearShield(ruleID: ruleID) return } - Diag.log(.scheduler, .event, "pauseEnded rule-\(rid): re-shield (budget spent)") + Diag.log(.scheduler, .event, "pauseEnded rule-\(ruleTag): re-shield (budget spent)") shield(snapshot) } @@ -197,19 +192,19 @@ struct LimitEnforcement { func handleOpenRequest( ruleID: UUID, now: Date = .now, calendar: Calendar = .current ) -> Bool { - let rid = ruleID.uuidString.prefix(8) + let ruleTag = ruleID.logTag guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.isEnabled, snapshot.kind == .openLimit, !snapshot.isPaused(at: now) else { - Diag.log(.session, "openRequest rule-\(rid): denied (ineligible)") + Diag.log(.session, "openRequest rule-\(ruleTag): denied (ineligible)") return false } let usage = ledger.usage(for: ruleID, onDayContaining: now, calendar: calendar) guard !snapshot.limitReached(given: usage, at: now) else { Diag.log( .session, .event, - "openRequest rule-\(rid): denied (opens spent \(usage.opensUsed)/\(snapshot.maxOpens))") + "openRequest rule-\(ruleTag): denied (opens spent \(usage.opensUsed)/\(snapshot.maxOpens))") return false } let updated = ledger.recordOpen(for: ruleID, onDayContaining: now, calendar: calendar) @@ -223,7 +218,7 @@ struct LimitEnforcement { } Diag.log( .session, .event, - "openRequest rule-\(rid): granted (open \(updated.opensUsed)/\(snapshot.maxOpens), ~\(MonitoringPlan.openSessionMinutes)m session)") + "openRequest rule-\(ruleTag): granted (open \(updated.opensUsed)/\(snapshot.maxOpens), ~\(MonitoringPlan.openSessionMinutes)m session)") return true } diff --git a/Shared/Enforcement/ScheduleEnforcement.swift b/Shared/Enforcement/ScheduleEnforcement.swift index c0070cd..effd6ab 100644 --- a/Shared/Enforcement/ScheduleEnforcement.swift +++ b/Shared/Enforcement/ScheduleEnforcement.swift @@ -22,10 +22,10 @@ struct ScheduleEnforcement { guard let snapshot = snapshots.snapshot(for: ruleID), snapshot.kind == .schedule else { return } - let rid = ruleID.uuidString.prefix(8) + let ruleTag = ruleID.logTag if snapshot.isEnabled, !snapshot.isPaused(at: now), snapshot.schedule.isActive(at: now, calendar: calendar) { - Diag.log(.scheduler, .event, "schedule rule-\(rid): window active -> shield") + Diag.log(.scheduler, .event, "schedule rule-\(ruleTag): window active -> shield") shields.applyShield( ruleID: snapshot.id, selectionData: snapshot.selectionData, @@ -34,7 +34,7 @@ struct ScheduleEnforcement { } else { Diag.log( .scheduler, - "schedule rule-\(rid): window inactive -> clear (enabled=\(snapshot.isEnabled) paused=\(snapshot.isPaused(at: now)))") + "schedule rule-\(ruleTag): window inactive -> clear (enabled=\(snapshot.isEnabled) paused=\(snapshot.isPaused(at: now)))") shields.clearShield(ruleID: snapshot.id) } } diff --git a/Shared/Enforcement/ShieldController.swift b/Shared/Enforcement/ShieldController.swift index 8bbf7de..8ae283a 100644 --- a/Shared/Enforcement/ShieldController.swift +++ b/Shared/Enforcement/ShieldController.swift @@ -61,11 +61,11 @@ nonisolated final class ManagedSettingsShieldController: ShieldApplying, @unchec track(ruleID: ruleID) Diag.log( .shield, .event, - "apply rule-\(ruleID.uuidString.prefix(8)) mode=\(mode) apps=\(selection.applicationTokens.count) cats=\(selection.categoryTokens.count) web=\(selection.webDomainTokens.count)") + "apply rule-\(ruleID.logTag) mode=\(mode) apps=\(selection.applicationTokens.count) cats=\(selection.categoryTokens.count) web=\(selection.webDomainTokens.count)") } func clearShield(ruleID: UUID) { - Diag.log(.shield, .event, "clear rule-\(ruleID.uuidString.prefix(8))") + Diag.log(.shield, .event, "clear rule-\(ruleID.logTag)") store(for: ruleID).clearAllSettings() untrack(ruleID: ruleID) } diff --git a/Shared/Models/RuleActivation.swift b/Shared/Models/RuleActivation.swift index eed231d..4279041 100644 --- a/Shared/Models/RuleActivation.swift +++ b/Shared/Models/RuleActivation.swift @@ -41,21 +41,20 @@ nonisolated extension RuleSnapshotDTO { ) -> RuleActivation { guard isEnabled else { return .inactive(nextStart: nil) } - // When would this rule's current block end, if it is blocking now? - let blockEnd: Date? + let currentBlockEnd: Date? switch kind { case .schedule: - blockEnd = schedule.activeWindow(containing: now, calendar: calendar)?.end + currentBlockEnd = schedule.activeWindow(containing: now, calendar: calendar)?.end case .timeLimit, .openLimit: if let usage, isScheduledToday(at: now, calendar: calendar), limitReached(given: usage, at: now) { - blockEnd = calendar.nextMidnight(after: now) + currentBlockEnd = calendar.nextMidnight(after: now) } else { - blockEnd = nil + currentBlockEnd = nil } } - guard let end = blockEnd else { + guard let end = currentBlockEnd else { return .inactive(nextStart: schedule.nextStart(after: now, calendar: calendar)) } // A pause only surfaces when the rule would otherwise be blocking, and diff --git a/Shared/Platform/DeviceActivityFactory.swift b/Shared/Platform/DeviceActivityFactory.swift new file mode 100644 index 0000000..5df1891 --- /dev/null +++ b/Shared/Platform/DeviceActivityFactory.swift @@ -0,0 +1,43 @@ +// +// DeviceActivityFactory.swift +// OpenAppLock +// + +import DeviceActivity +import FamilyControls +import Foundation + +/// Shared DeviceActivity construction so the app scheduler and the background +/// extensions that also start activities directly can't drift. +nonisolated enum DeviceActivityFactory { + /// A non-repeating schedule spanning `start`...`end` wall-clock. + static func nonRepeatingSchedule( + from start: Date, to end: Date, calendar: Calendar = .current + ) -> DeviceActivitySchedule { + let components: Set = [.year, .month, .day, .hour, .minute, .second] + return DeviceActivitySchedule( + intervalStart: calendar.dateComponents(components, from: start), + intervalEnd: calendar.dateComponents(components, from: end), + repeats: false) + } + + /// One `DeviceActivityEvent` per `eventMinutes` entry over `selectionData`, + /// with `includesPastActivity` so a restart backfills same-interval accrual. + static func thresholdEvents( + selectionData: Data?, eventMinutes: [String: Int] + ) -> [DeviceActivityEvent.Name: DeviceActivityEvent] { + let selection = AppSelectionCodec.decode(selectionData) + return Dictionary( + uniqueKeysWithValues: eventMinutes.map { name, minutes in + ( + DeviceActivityEvent.Name(name), + DeviceActivityEvent( + applications: selection.applicationTokens, + categories: selection.categoryTokens, + webDomains: selection.webDomainTokens, + threshold: DateComponents(minute: minutes), + includesPastActivity: true) + ) + }) + } +} diff --git a/Shared/Platform/UsageReportFormatter.swift b/Shared/Platform/UsageReportFormatter.swift index 23aea1d..5e2acec 100644 --- a/Shared/Platform/UsageReportFormatter.swift +++ b/Shared/Platform/UsageReportFormatter.swift @@ -53,11 +53,7 @@ nonisolated enum UsageReportFormatter { let rows = secondsByName .filter { $0.value > 0 } .map { AppUsageRow(name: $0.key, seconds: $0.value) } - .sorted { lhs, rhs in - lhs.seconds != rhs.seconds - ? lhs.seconds > rhs.seconds // heaviest app first - : lhs.name < rhs.name // stable tiebreak - } + .sorted(by: AppUsageRow.heaviestUsageFirst) return RuleUsageReportData(total: total, apps: rows) } } @@ -77,4 +73,8 @@ nonisolated struct AppUsageRow: Identifiable, Equatable { let name: String let seconds: Double var durationLabel: String { UsageReportFormatter.durationLabel(seconds: seconds) } + + static func heaviestUsageFirst(_ lhs: AppUsageRow, _ rhs: AppUsageRow) -> Bool { + lhs.seconds != rhs.seconds ? lhs.seconds > rhs.seconds : lhs.name < rhs.name + } } diff --git a/Shared/Stores/UsageLedger.swift b/Shared/Stores/UsageLedger.swift index c6bc123..b5dbef6 100644 --- a/Shared/Stores/UsageLedger.swift +++ b/Shared/Stores/UsageLedger.swift @@ -60,7 +60,7 @@ nonisolated final class UsageLedger: UsageReading, @unchecked Sendable { setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) Diag.log( .usage, - "ledger.minutes rule-\(ruleID.uuidString.prefix(8)) \(prior)->\(usage.minutesUsed) (event=\(minutes))") + "ledger.minutes rule-\(ruleID.logTag) \(prior)->\(usage.minutesUsed) (event=\(minutes))") } @discardableResult @@ -71,7 +71,7 @@ nonisolated final class UsageLedger: UsageReading, @unchecked Sendable { usage.opensUsed += 1 setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar) Diag.log( - .session, "ledger.open rule-\(ruleID.uuidString.prefix(8)) opens=\(usage.opensUsed)") + .session, "ledger.open rule-\(ruleID.logTag) opens=\(usage.opensUsed)") return usage }