311 lines
10 KiB
Swift
311 lines
10 KiB
Swift
//
|
||
// AppRestrictShared.swift
|
||
// Shared between main app and Screen Time extensions.
|
||
//
|
||
|
||
import Foundation
|
||
import FamilyControls
|
||
import ManagedSettings
|
||
import UIKit
|
||
|
||
enum AppRestrictShared {
|
||
static let appGroupId = "group.cn.zuomeng.jisuloca"
|
||
static let storeName = ManagedSettingsStore.Name("appRestrict")
|
||
static let activityName = "appRestrict.monitoring"
|
||
|
||
static let selectionKey = "appRestrict.selection"
|
||
static let enabledTokensKey = "appRestrict.enabledTokens"
|
||
static let linksKey = "appRestrict.links"
|
||
static let shieldConfigKey = "appRestrict.shieldConfig"
|
||
static let customImageFileName = "shield_custom.jpg"
|
||
|
||
static var defaults: UserDefaults {
|
||
UserDefaults(suiteName: appGroupId) ?? .standard
|
||
}
|
||
|
||
static var containerURL: URL? {
|
||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId)
|
||
}
|
||
|
||
static var customImageURL: URL? {
|
||
containerURL?.appendingPathComponent(customImageFileName)
|
||
}
|
||
|
||
static func makeStore() -> ManagedSettingsStore {
|
||
ManagedSettingsStore(named: storeName)
|
||
}
|
||
}
|
||
|
||
struct AppRestrictLinkRecord: Codable, Equatable {
|
||
let catalogId: String
|
||
let tokenData: Data
|
||
let displayName: String?
|
||
let iconURL: String?
|
||
|
||
init(catalogId: String, tokenData: Data, displayName: String? = nil, iconURL: String? = nil) {
|
||
self.catalogId = catalogId
|
||
self.tokenData = tokenData
|
||
self.displayName = displayName
|
||
self.iconURL = iconURL
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
catalogId = try container.decode(String.self, forKey: .catalogId)
|
||
tokenData = try container.decode(Data.self, forKey: .tokenData)
|
||
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
|
||
iconURL = try container.decodeIfPresent(String.self, forKey: .iconURL)
|
||
}
|
||
}
|
||
|
||
struct AppRestrictShieldConfig: Codable, Equatable {
|
||
enum ImageSource: String, Codable {
|
||
case presetDefault
|
||
case presetFocus
|
||
case presetCalm
|
||
case album
|
||
}
|
||
|
||
var title: String
|
||
var subtitle: String
|
||
var primaryButtonLabel: String
|
||
var imageSource: ImageSource
|
||
|
||
static let `default` = AppRestrictShieldConfig(
|
||
title: "该应用已被锁定",
|
||
subtitle: "专注当下,稍后再回来吧",
|
||
primaryButtonLabel: "知道了",
|
||
imageSource: .presetDefault
|
||
)
|
||
}
|
||
|
||
enum AppRestrictTokenCodec {
|
||
static func encode(_ token: ApplicationToken) -> Data? {
|
||
try? PropertyListEncoder().encode(token)
|
||
}
|
||
|
||
static func decode(_ data: Data) -> ApplicationToken? {
|
||
try? PropertyListDecoder().decode(ApplicationToken.self, from: data)
|
||
}
|
||
|
||
static func decodeBase64(_ value: String) -> ApplicationToken? {
|
||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty,
|
||
let data = Data(base64Encoded: text, options: .ignoreUnknownCharacters) else {
|
||
return nil
|
||
}
|
||
return decode(data)
|
||
}
|
||
|
||
static func encodeSelection(_ selection: FamilyActivitySelection) -> Data? {
|
||
try? PropertyListEncoder().encode(selection)
|
||
}
|
||
|
||
static func decodeSelection(_ data: Data) -> FamilyActivitySelection? {
|
||
try? PropertyListDecoder().decode(FamilyActivitySelection.self, from: data)
|
||
}
|
||
|
||
static func encodeTokenSet(_ tokens: Set<ApplicationToken>) -> Data? {
|
||
let datas = tokens.compactMap { encode($0) }
|
||
return try? PropertyListEncoder().encode(datas)
|
||
}
|
||
|
||
static func decodeTokenSet(_ data: Data) -> Set<ApplicationToken> {
|
||
guard let datas = try? PropertyListDecoder().decode([Data].self, from: data) else {
|
||
return []
|
||
}
|
||
return Set(datas.compactMap { decode($0) })
|
||
}
|
||
}
|
||
|
||
enum AppRestrictSharedStore {
|
||
static var selection: FamilyActivitySelection {
|
||
get {
|
||
guard let data = AppRestrictShared.defaults.data(forKey: AppRestrictShared.selectionKey),
|
||
let value = AppRestrictTokenCodec.decodeSelection(data) else {
|
||
return FamilyActivitySelection()
|
||
}
|
||
return value
|
||
}
|
||
set {
|
||
if let data = AppRestrictTokenCodec.encodeSelection(newValue) {
|
||
AppRestrictShared.defaults.set(data, forKey: AppRestrictShared.selectionKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
static var enabledTokens: Set<ApplicationToken> {
|
||
get {
|
||
guard let data = AppRestrictShared.defaults.data(forKey: AppRestrictShared.enabledTokensKey) else {
|
||
return []
|
||
}
|
||
return AppRestrictTokenCodec.decodeTokenSet(data)
|
||
}
|
||
set {
|
||
if let data = AppRestrictTokenCodec.encodeTokenSet(newValue) {
|
||
AppRestrictShared.defaults.set(data, forKey: AppRestrictShared.enabledTokensKey)
|
||
} else {
|
||
AppRestrictShared.defaults.removeObject(forKey: AppRestrictShared.enabledTokensKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
static var links: [AppRestrictLinkRecord] {
|
||
get {
|
||
guard let data = AppRestrictShared.defaults.data(forKey: AppRestrictShared.linksKey),
|
||
let value = try? JSONDecoder().decode([AppRestrictLinkRecord].self, from: data) else {
|
||
return []
|
||
}
|
||
return value
|
||
}
|
||
set {
|
||
if let data = try? JSONEncoder().encode(newValue) {
|
||
AppRestrictShared.defaults.set(data, forKey: AppRestrictShared.linksKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
static var shieldConfig: AppRestrictShieldConfig {
|
||
get {
|
||
guard let data = AppRestrictShared.defaults.data(forKey: AppRestrictShared.shieldConfigKey),
|
||
let value = try? JSONDecoder().decode(AppRestrictShieldConfig.self, from: data) else {
|
||
return .default
|
||
}
|
||
return value
|
||
}
|
||
set {
|
||
if let data = try? JSONEncoder().encode(newValue) {
|
||
AppRestrictShared.defaults.set(data, forKey: AppRestrictShared.shieldConfigKey)
|
||
}
|
||
}
|
||
}
|
||
|
||
static func linkRecord(for token: ApplicationToken) -> AppRestrictLinkRecord? {
|
||
guard let data = AppRestrictTokenCodec.encode(token) else { return nil }
|
||
return links.first(where: { $0.tokenData == data })
|
||
}
|
||
|
||
static func catalogId(for token: ApplicationToken) -> String? {
|
||
linkRecord(for: token)?.catalogId
|
||
}
|
||
|
||
static func setLink(
|
||
catalogId: String,
|
||
token: ApplicationToken,
|
||
displayName: String? = nil,
|
||
iconURL: String? = nil
|
||
) {
|
||
guard let data = AppRestrictTokenCodec.encode(token) else { return }
|
||
var list = links.filter { $0.tokenData != data && $0.catalogId != catalogId }
|
||
list.append(AppRestrictLinkRecord(
|
||
catalogId: catalogId,
|
||
tokenData: data,
|
||
displayName: displayName,
|
||
iconURL: iconURL
|
||
))
|
||
links = list
|
||
}
|
||
|
||
static func removeLink(for token: ApplicationToken) {
|
||
guard let data = AppRestrictTokenCodec.encode(token) else { return }
|
||
links = links.filter { $0.tokenData != data }
|
||
}
|
||
|
||
static var hasPairingData: Bool {
|
||
let storedSelection = selection
|
||
return !storedSelection.applicationTokens.isEmpty
|
||
|| !storedSelection.categoryTokens.isEmpty
|
||
|| !storedSelection.webDomainTokens.isEmpty
|
||
|| !enabledTokens.isEmpty
|
||
|| !links.isEmpty
|
||
}
|
||
|
||
static func clearPairingData() {
|
||
selection = FamilyActivitySelection()
|
||
enabledTokens = []
|
||
links = []
|
||
clearShield()
|
||
}
|
||
|
||
static func applyShield(for tokens: Set<ApplicationToken>) {
|
||
let store = AppRestrictShared.makeStore()
|
||
if tokens.isEmpty {
|
||
store.clearAllSettings()
|
||
} else {
|
||
store.shield.applications = tokens
|
||
}
|
||
}
|
||
|
||
static func clearShield() {
|
||
AppRestrictShared.makeStore().clearAllSettings()
|
||
}
|
||
|
||
static func loadShieldImage() -> UIImage? {
|
||
let config = shieldConfig
|
||
switch config.imageSource {
|
||
case .album:
|
||
if let url = AppRestrictShared.customImageURL,
|
||
let data = try? Data(contentsOf: url),
|
||
let image = UIImage(data: data) {
|
||
return image
|
||
}
|
||
fallthrough
|
||
case .presetDefault:
|
||
return UIImage(named: "AppRestrict/shield_preset_default")
|
||
?? UIImage(systemName: "lock.shield.fill")
|
||
case .presetFocus:
|
||
return UIImage(named: "AppRestrict/shield_preset_focus")
|
||
?? UIImage(systemName: "brain.head.profile")
|
||
case .presetCalm:
|
||
return UIImage(named: "AppRestrict/shield_preset_calm")
|
||
?? UIImage(systemName: "leaf.fill")
|
||
}
|
||
}
|
||
|
||
static func loadShieldDisplayImage() -> UIImage? {
|
||
// 系统把 icon 画在约 100pt 方格里;画布按这个尺寸、圆角 30,屏幕上看才是 30。
|
||
loadShieldImage()?.appRestrictShieldIcon(size: 100, cornerRadius: 30)
|
||
}
|
||
|
||
static func saveCustomImage(_ image: UIImage) -> Bool {
|
||
guard let url = AppRestrictShared.customImageURL else { return false }
|
||
let resized = image.appRestrictResized(maxSide: 720)
|
||
guard let data = resized.pngData() else { return false }
|
||
do {
|
||
try data.write(to: url, options: .atomic)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
extension UIImage {
|
||
func appRestrictResized(maxSide: CGFloat) -> UIImage {
|
||
let maxCurrent = max(size.width, size.height)
|
||
guard maxCurrent > maxSide, maxCurrent > 0 else { return self }
|
||
let scale = maxSide / maxCurrent
|
||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||
return renderer.image { _ in
|
||
draw(in: CGRect(origin: .zero, size: newSize))
|
||
}
|
||
}
|
||
|
||
func appRestrictShieldIcon(size: CGFloat, cornerRadius: CGFloat) -> UIImage {
|
||
let canvas = CGSize(width: size, height: size)
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.opaque = false
|
||
format.scale = UIScreen.main.scale
|
||
let renderer = UIGraphicsImageRenderer(size: canvas, format: format)
|
||
return renderer.image { _ in
|
||
let rect = CGRect(origin: .zero, size: canvas)
|
||
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
|
||
path.addClip()
|
||
UIColor.white.setFill()
|
||
path.fill()
|
||
draw(in: rect)
|
||
}
|
||
}
|
||
}
|