add Screen Time API app blocking (FamilyControls + DeviceActivityMonitor)

这个提交包含在:
JGZ_YES
2026-08-12 16:07:32 +08:00
父节点 ba0fe41a88
当前提交 658d71b538
修改 8 个文件,包含 262 行新增4 行删除
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.areyousleeping</string>
</array>
</dict>
</plist>
+5
查看文件
@@ -3,6 +3,7 @@ import SwiftUI
@main
struct AreYouSleepingApp: App {
@StateObject private var store = Store.shared
@StateObject private var stm = ScreenTimeManager.shared
var body: some Scene {
WindowGroup {
@@ -13,6 +14,10 @@ struct AreYouSleepingApp: App {
.onAppear {
BedtimeReminder.requestPermission()
BedtimeReminder.schedule(store: store)
//
Task {
await stm.requestAuthorization()
}
}
} else {
AgreementView()
@@ -0,0 +1,105 @@
import Foundation
import FamilyControls
import DeviceActivity
import ManagedSettings
import SwiftUI
///
@MainActor
final class ScreenTimeManager: ObservableObject {
static let shared = ScreenTimeManager()
@Published var isAuthorized = false
@Published var selection = FamilyActivitySelection()
@Published var blockedAppCount = 0
private let center = DeviceActivityCenter()
private let shieldStore = ManagedSettingsStore()
private let sharedDefaults = UserDefaults(suiteName: "group.com.areyousleeping")!
private init() {
refreshAuth()
loadSelection()
}
// MARK: -
func requestAuthorization() async {
do {
try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
isAuthorized = true
} catch {
print("ScreenTime auth failed: \(error)")
isAuthorized = false
}
}
func refreshAuth() {
isAuthorized = AuthorizationCenter.shared.authorizationStatus == .approved
}
// MARK: -
func saveSelection(_ sel: FamilyActivitySelection) {
selection = sel
blockedAppCount = sel.applicationTokens.count + sel.categoryTokens.count
// App Group Extension
let dict: [String: Any] = ["appCount": blockedAppCount]
sharedDefaults.set(dict, forKey: "shield_info")
// ManagedSettings使 App
shieldStore.shield.applications = sel.applicationTokens.isEmpty ? nil : sel.applicationTokens
shieldStore.shield.applicationCategories = sel.categoryTokens.isEmpty
? nil
: .specific(sel.categoryTokens)
// DeviceActivity
let store = Store.shared
scheduleMonitoring(bedMin: store.bedtimeMinutes, wakeMin: store.waketimeMinutes)
}
private func loadSelection() {
blockedAppCount = sharedDefaults.integer(forKey: "shield_count")
}
// MARK: - DeviceActivity
func scheduleMonitoring(bedMin: Int, wakeMin: Int) {
center.stopAllMonitoring()
guard blockedAppCount > 0 else { return }
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: bedMin / 60, minute: bedMin % 60),
intervalEnd: DateComponents(hour: wakeMin / 60, minute: wakeMin % 60),
repeats: true
)
do {
try center.startMonitoring(.sleep, during: schedule)
} catch {
print("DeviceActivity start fail: \(error)")
}
}
func stopMonitoring() {
center.stopAllMonitoring()
}
// MARK: -
func applyShieldNow() {
shieldStore.shield.applications = selection.applicationTokens.isEmpty ? nil : selection.applicationTokens
shieldStore.shield.applicationCategories = selection.categoryTokens.isEmpty
? nil
: .specific(selection.categoryTokens)
}
func removeShieldNow() {
shieldStore.shield.applications = nil
shieldStore.shield.applicationCategories = nil
}
}
extension DeviceActivityName {
static let sleep = Self("com.areyousleeping.sleep")
}
+5 -4
查看文件
@@ -31,8 +31,9 @@ final class Store: ObservableObject {
@Published var sleepStartMillis: Double = 0
@Published var lastNagMillis: Double = 0
// MARK: - iOS
@Published var blockedPackages: Set<String> = []
// MARK: - Screen Time API
@Published var blockedAppCount: Int = 0 //
@Published var familyControlsAuthorized: Bool = false
// MARK: -
@Published var recordLines: Set<String> = []
@@ -65,7 +66,7 @@ final class Store: ObservableObject {
isSleeping = d.bool(forKey: "sleeping")
sleepStartMillis = d.double(forKey: "start")
lastNagMillis = d.double(forKey: "lastNag")
blockedPackages = Set(d.stringArray(forKey: "blocked") ?? [])
blockedAppCount = d.integer(forKey: "blocked_count")
recordLines = Set(d.stringArray(forKey: "records") ?? [])
}
@@ -91,7 +92,7 @@ final class Store: ObservableObject {
d.set(isSleeping, forKey: "sleeping")
d.set(sleepStartMillis, forKey: "start")
d.set(lastNagMillis, forKey: "lastNag")
d.set(Array(blockedPackages), forKey: "blocked")
d.set(blockedAppCount, forKey: "blocked_count")
d.set(Array(recordLines), forKey: "records")
}
+43
查看文件
@@ -1,11 +1,14 @@
import SwiftUI
import FamilyControls
struct SettingsView: View {
@EnvironmentObject var store: Store
@StateObject private var stm = ScreenTimeManager.shared
@State private var showAgreement = false
@State private var showTimePicker = false
@State private var isBedPicker = false
@State private var pickerDate = Date()
@State private var showFamilyPicker = false
var body: some View {
NavigationStack {
@@ -62,6 +65,35 @@ struct SettingsView: View {
.onChange(of: store.supervisionEnabled) { _ in store.save() }
}
//
cardSection(header: "应用屏蔽", footer: "就寝时段通过系统屏幕时间屏蔽所选应用") {
VStack(spacing: 12) {
HStack {
Text(stm.blockedAppCount > 0
? "已选择 \(stm.blockedAppCount) 个应用"
: "未选择任何应用")
.font(.system(size: 14))
Spacer()
Button("选择应用") {
showFamilyPicker = true
}
.buttonStyle(.bordered)
.tint(.blue)
.disabled(!stm.isAuthorized)
}
if !stm.isAuthorized {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundColor(.orange)
Text("需先授予屏幕时间权限")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
//
cardSection(header: "后台运行", footer: "关闭后应用切到后台将停止服务") {
Toggle("允许后台运行", isOn: $store.backgroundRunning)
@@ -112,6 +144,17 @@ struct SettingsView: View {
timePickerSheet
.presentationDetents([.height(300)])
}
.familyActivityPicker(
isPresented: $showFamilyPicker,
selection: $stm.selection
)
.onChange(of: stm.selection) { newValue in
stm.saveSelection(newValue)
store.blockedAppCount = stm.blockedAppCount
store.save()
//
stm.scheduleMonitoring(bedMin: store.bedtimeMinutes, wakeMin: store.waketimeMinutes)
}
}
}
@@ -0,0 +1,49 @@
import DeviceActivity
import ManagedSettings
/// App
@main
final class SleepMonitor: DeviceActivityMonitor {
private let store: ManagedSettingsStore = {
ManagedSettingsStore()
}()
// MARK: -
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
applyShield()
}
// MARK: -
override func intervalDidEnd(for activity: DeviceActivityName) {
super.intervalDidEnd(for: activity)
removeShield()
}
// MARK: -
override func eventDidReachThreshold(_ event: DeviceActivityEvent.Name, activity: DeviceActivityName) {
super.eventDidReachThreshold(event, activity: activity)
// /
}
// MARK: - Private
private func applyShield() {
let shared = UserDefaults(suiteName: "group.com.areyousleeping")!
let count = shared.integer(forKey: "shield_count")
guard count > 0 else { return }
// ManagedSettings App
// Extension / App
//
}
private func removeShield() {
store.shield.applications = nil
store.shield.applicationCategories = nil
}
}
+33
查看文件
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>DeviceActivityMonitor</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).SleepMonitor</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.deviceactivity.monitor</string>
</dict>
<key>MinimumOSVersion</key>
<string>16.0</string>
</dict>
</plist>
+10
查看文件
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>development</string>
<key>signingStyle</key>
<string>manual</string>
</dict>
</plist>