jsdw_ios/AppRestrictShared/AppRestrictShared.swift

311 lines
10 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
}
}