add Screen Time API app blocking (FamilyControls + DeviceActivityMonitor)
这个提交包含在:
@@ -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>
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
在新工单中引用
屏蔽一个用户