diff --git a/AreYouSleeping/AreYouSleeping.entitlements b/AreYouSleeping/AreYouSleeping.entitlements new file mode 100644 index 0000000..268f5fc --- /dev/null +++ b/AreYouSleeping/AreYouSleeping.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.developer.family-controls + + com.apple.security.application-groups + + group.com.areyousleeping + + + diff --git a/AreYouSleeping/AreYouSleepingApp.swift b/AreYouSleeping/AreYouSleepingApp.swift index 537ab07..563e8a8 100644 --- a/AreYouSleeping/AreYouSleepingApp.swift +++ b/AreYouSleeping/AreYouSleepingApp.swift @@ -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() diff --git a/AreYouSleeping/Services/ScreenTimeManager.swift b/AreYouSleeping/Services/ScreenTimeManager.swift new file mode 100644 index 0000000..0c29f30 --- /dev/null +++ b/AreYouSleeping/Services/ScreenTimeManager.swift @@ -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") +} diff --git a/AreYouSleeping/Store.swift b/AreYouSleeping/Store.swift index fa1f767..d3eb855 100644 --- a/AreYouSleeping/Store.swift +++ b/AreYouSleeping/Store.swift @@ -31,8 +31,9 @@ final class Store: ObservableObject { @Published var sleepStartMillis: Double = 0 @Published var lastNagMillis: Double = 0 - // MARK: - 屏蔽应用包名(iOS 不支持实际屏蔽,仅记录) - @Published var blockedPackages: Set = [] + // MARK: - 屏蔽应用(Screen Time API) + @Published var blockedAppCount: Int = 0 // 已选中的应用数 + @Published var familyControlsAuthorized: Bool = false // MARK: - 睡眠记录 @Published var recordLines: Set = [] @@ -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") } diff --git a/AreYouSleeping/Views/SettingsView.swift b/AreYouSleeping/Views/SettingsView.swift index db11178..8801232 100644 --- a/AreYouSleeping/Views/SettingsView.swift +++ b/AreYouSleeping/Views/SettingsView.swift @@ -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) + } } } diff --git a/DeviceActivityMonitor/DeviceActivityMonitor.swift b/DeviceActivityMonitor/DeviceActivityMonitor.swift new file mode 100644 index 0000000..b14ab36 --- /dev/null +++ b/DeviceActivityMonitor/DeviceActivityMonitor.swift @@ -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 + } +} diff --git a/DeviceActivityMonitor/Info.plist b/DeviceActivityMonitor/Info.plist new file mode 100644 index 0000000..62df884 --- /dev/null +++ b/DeviceActivityMonitor/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + DeviceActivityMonitor + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SleepMonitor + NSExtensionPointIdentifier + com.apple.deviceactivity.monitor + + MinimumOSVersion + 16.0 + + diff --git a/ExportOptions.plist b/ExportOptions.plist new file mode 100644 index 0000000..a149411 --- /dev/null +++ b/ExportOptions.plist @@ -0,0 +1,10 @@ + + + + + method + development + signingStyle + manual + +