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: 2 additions & 0 deletions Normal.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,15 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CustomDomainFilter.swift,
DailyLimitEnforcement.swift,
DataExtension.swift,
DomainNormalizer.swift,
ManagedSettingsUtil.swift,
SharedConstants.swift,
SharedDTO.swift,
SharedStore.swift,
SharedStoreProviding.swift,
UsageDTO.swift,
);
target = 2F4718642F5569C90013F94D /* NormalMonitor */;
};
Expand Down
3 changes: 3 additions & 0 deletions Normal/App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ struct AppContainer: View {
@State private var appReviewService: AppReviewService
@State private var emergencyUnblockService: EmergencyUnblockService
@State private var donationService = DonationService()
@State private var usageLimitService: UsageLimitService

init(services: AppServices) {
_screenTimeService = State(initialValue: services.screenTime)
_timedUnblockService = State(initialValue: services.timedUnblock)
_scheduleService = State(initialValue: services.schedule)
_appReviewService = State(initialValue: services.appReview)
_emergencyUnblockService = State(initialValue: services.emergencyUnblock)
_usageLimitService = State(initialValue: services.usageLimit)
}

var body: some View {
Expand All @@ -36,6 +38,7 @@ struct AppContainer: View {
.environment(appReviewService)
.environment(emergencyUnblockService)
.environment(donationService)
.environment(usageLimitService)
.task { mirrorCustomDomainsEnabled() }
.onChange(of: allSettings.first?.enableCustomDomains ?? false) { _, enabled in
scheduleService.mirrorCustomDomainsEnabled(enabled)
Expand Down
7 changes: 7 additions & 0 deletions Normal/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ final class AppServices {
let schedule: ScheduleService
let appReview: AppReviewService
let emergencyUnblock: EmergencyUnblockService
let usageLimit: UsageLimitService

init() {
let screenTime = ScreenTimeService()
Expand All @@ -27,11 +28,17 @@ final class AppServices {
defaults: UserDefaults(suiteName: "uitest-review-\(UUID().uuidString)")!
)
emergencyUnblock = EmergencyUnblockService(ledger: InMemoryEmergencyUnblockLedger())
usageLimit = UsageLimitService(
activityCenter: center,
sharedStore: store,
ledger: InMemoryUsageLimitLedger()
)
} else {
timedUnblock = TimedUnblockService(onExpiration: { screenTime.notifyUpdate() })
schedule = ScheduleService()
appReview = AppReviewService()
emergencyUnblock = EmergencyUnblockService(ledger: KeychainEmergencyUnblockLedger())
usageLimit = UsageLimitService()
}
}
}
21 changes: 18 additions & 3 deletions Normal/App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ struct ContentView: View {
@Environment(TimedUnblockService.self) private var timedUnblockService
@Environment(ScheduleService.self) private var scheduleService
@Environment(EmergencyUnblockService.self) private var emergencyUnblockService
@Environment(UsageLimitService.self) private var usageLimitService
@Environment(\.scenePhase) private var scenePhase
@Query private var allSettings: [Settings]
@Query private var schedules: [BlockSchedule]
@Query private var usageLimits: [UsageLimit]

@State private var selectedTab: AppTab = .home
@State private var navigationCoordinator = NavigationCoordinator()
Expand All @@ -33,12 +35,16 @@ struct ContentView: View {
if let newTab { selectedTab = newTab }
}
.onChange(of: onboardingService.isOnboardingActive, onOnboardingCompleted)
.onChange(of: usageLimits.count) { _, _ in reregisterUsageLimits() }
.onChange(of: settings?.usageLimitConfig) { _, _ in reregisterUsageLimits() }
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
Task { await screenTimeService.checkAuthorizationStatus() }
screenTimeService.notifyUpdate()
timedUnblockService.refresh(screenTimeService: screenTimeService)
reregisterSchedules()
// The monitor extension may have spent a limit while we were away.
usageLimitService.notifyUpdate()
reregisterActivities()
}
}
.onAppear(perform: onAppear)
Expand All @@ -59,14 +65,23 @@ struct ContentView: View {
await screenTimeService.checkAuthorizationStatus()
if settings?.hasCompletedOnboarding == true {
_ = await screenTimeService.ensureAuthorized()
reregisterSchedules()
reregisterActivities()
}
}
}

private func reregisterSchedules() {
/// Re-registers every `DeviceActivity` we depend on. Usage limits ride along
/// with schedules: their threshold events use `includesPastActivity`, so
/// re-registering mid-day cannot hand back an allowance already spent.
private func reregisterActivities() {
guard settings?.hasCompletedOnboarding == true else { return }
scheduleService.registerAll(schedules, screenTimeService: screenTimeService)
reregisterUsageLimits()
}

private func reregisterUsageLimits() {
guard let settings, settings.hasCompletedOnboarding else { return }
usageLimitService.registerAll(usageLimits, config: settings.usageLimitConfig)
}

private func onOnboardingCompleted(_: Bool, _ isActive: Bool) {
Expand Down
1 change: 1 addition & 0 deletions Normal/App/NormalApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct NormalApp: App {
Key.self,
BlockSchedule.self,
Settings.self,
UsageLimit.self,
])
let configuration = ModelConfiguration(
schema: schema,
Expand Down
6 changes: 1 addition & 5 deletions Normal/Models/BlockSchedule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,7 @@ final class BlockSchedule: Identifiable {
}

var formattedDuration: String {
let hours = durationMinutes / 60
let minutes = durationMinutes % 60
if hours == 0 { return "\(minutes)m" }
if minutes == 0 { return "\(hours)h" }
return "\(hours)h \(minutes)m"
DurationFormat.compact(minutes: durationMinutes)
}

var weekdayLabels: [String] {
Expand Down
8 changes: 8 additions & 0 deletions Normal/Models/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ final class Settings {
var showTimedUnblockLiveActivity: Bool = false
var enableCustomDomains: Bool = false
var skipBlockWithoutKeyConfirmation: Bool = false
var dailyLimitResetMinutes: Int = 0

static let maxEmergencyUnblocks = 3
private static let emergencyWindowDays = 180
Expand All @@ -38,6 +39,13 @@ final class Settings {
func recordEmergencyUnblock() {
emergencyUnblockDates.append(.now)
}

var usageLimitConfig: UsageLimitConfig {
UsageLimitConfig(
resetMinutes: dailyLimitResetMinutes,
preventsAppDelete: blockAllPreventsAppDelete
)
}
}

extension Array where Element == Settings {
Expand Down
59 changes: 59 additions & 0 deletions Normal/Models/UsageLimit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import FamilyControls
import Foundation
import SwiftData

/// A daily ceiling on real usage of a chosen set of apps.
///
/// The apps in one limit share its allowance, the way Screen Time's own App
/// Limits work. When it runs out they are shielded again for the rest of the
/// day, whatever else is unblocked.
@Model
final class UsageLimit: Identifiable {
@Attribute(.unique) var id: UUID

var selection: FamilyActivitySelection
var minutesPerDay: Int
var sortIndex: Int = 0
var lockAnchor: Date?

/// Shortest allowance we offer. `DeviceActivity` thresholds below a few
/// minutes fire unreliably, so the picker never goes lower.
static let minimumMinutes = 5
static let maximumMinutes = 23 * 60 + 55

init(
selection: FamilyActivitySelection = FamilyActivitySelection(),
minutesPerDay: Int,
sortIndex: Int = 0,
createdAt: Date = .now
) {
id = UUID()
self.selection = selection
self.minutesPerDay = minutesPerDay
self.sortIndex = sortIndex
lockAnchor = createdAt
}

func lockState(now: Date = .now) -> UsageLimitLockState {
UsageLimitEditPolicy.lockState(anchor: lockAnchor, now: now)
}

func decide(_ edit: UsageLimitEdit, now: Date = .now) -> UsageLimitEditDecision {
UsageLimitEditPolicy.decide(edit, anchor: lockAnchor, now: now)
}

func commit(_ allowance: UsageLimitEditAllowance, now: Date = .now) {
lockAnchor = UsageLimitEditPolicy.anchor(after: allowance, current: lockAnchor, now: now)
}

var lockRefreshDates: [Date] {
UsageLimitEditPolicy.refreshDates(anchor: lockAnchor)
}

/// Flattens for the monitor extension, which must be able to re-shield
/// without the app running.
func toDTO() -> UsageLimitDTO? {
guard let data = try? selection.toData() else { return nil }
return UsageLimitDTO(id: id, selectionData: data, minutesPerDay: minutesPerDay)
}
}
94 changes: 94 additions & 0 deletions Normal/Models/UsageLimitEditPolicy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Foundation

/// A proposed change to the usage limits, classified by direction.
///
/// Only *loosening* changes are rate limited — tightening is always immediate,
/// so the commitment device never stands between you and more discipline.
nonisolated enum UsageLimitEdit: Equatable, Sendable {
case create(minutes: Int)
/// `dropsCoverage` is true when apps were removed from what the limit
/// meters — less watched is less restrictive, so it counts as loosening
/// even if the allowance itself shrank.
case adjust(from: Int, to: Int, dropsCoverage: Bool)
case delete(minutes: Int)
case changeReset

var isLoosening: Bool {
switch self {
case .create: false
case let .adjust(from, to, dropsCoverage): to > from || dropsCoverage
case .delete, .changeReset: true
}
}
}

/// Why a change was let through. Only `cooldownElapsed` starts a new week.
nonisolated enum UsageLimitEditAllowance: Equatable, Sendable {
case tightening
case grace
case cooldownElapsed
}

nonisolated enum UsageLimitEditDecision: Equatable, Sendable {
case allowed(UsageLimitEditAllowance)
case locked(until: Date)

var isAllowed: Bool {
if case .allowed = self { return true }
return false
}
}

/// What the Usage screen shows before any change is proposed.
nonisolated enum UsageLimitLockState: Equatable, Sendable {
case unlocked
case grace(until: Date)
case locked(until: Date)

var lockedUntil: Date? {
if case let .locked(until) = self { return until }
return nil
}
}

nonisolated enum UsageLimitEditPolicy {
static let graceInterval: TimeInterval = .minutes(10)
static let cooldownInterval: TimeInterval = .days(7)
static let countdownLead: TimeInterval = .minutes(10)

static func refreshDates(anchor: Date?) -> [Date] {
guard let anchor else { return [] }
let unlock = anchor + cooldownInterval
return [anchor + graceInterval, unlock - countdownLead, unlock]
}

static func lockState(anchor: Date?, now: Date = .now) -> UsageLimitLockState {
guard let anchor else { return .unlocked }
let graceEnds = anchor + graceInterval
if now < graceEnds { return .grace(until: graceEnds) }
let unlock = anchor + cooldownInterval
return now < unlock ? .locked(until: unlock) : .unlocked
}

static func decide(
_ edit: UsageLimitEdit,
anchor: Date?,
now: Date = .now
) -> UsageLimitEditDecision {
guard edit.isLoosening else { return .allowed(.tightening) }
switch lockState(anchor: anchor, now: now) {
case .unlocked: return .allowed(.cooldownElapsed)
case .grace: return .allowed(.grace)
case let .locked(until): return .locked(until: until)
}
}

/// The anchor to persist after a change was applied.
static func anchor(
after allowance: UsageLimitEditAllowance,
current: Date?,
now: Date = .now
) -> Date? {
allowance == .cooldownElapsed ? now : current
}
}
Loading
Loading