Files
SideStore/SideStoreApp/Sources/SideStoreAppKit/App IDs/AppIDsViewController.swift

419 lines
16 KiB
Swift
Raw Normal View History

//
// AppIDsViewController.swift
// AltStore
//
// Created by Riley Testut on 1/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
2023-03-01 00:48:36 -05:00
import SideStoreCore
2023-03-01 14:36:52 -05:00
import RoxasUIKit
import Down
2023-03-01 00:48:36 -05:00
final class AppIDsViewController: UICollectionViewController {
private lazy var dataSource = self.makeDataSource()
2023-03-01 00:48:36 -05:00
private var didInitialFetch = false
private var isLoading = false {
didSet {
2023-03-01 00:48:36 -05:00
update()
}
}
2023-03-01 00:48:36 -05:00
@IBOutlet var activityIndicatorBarButtonItem: UIBarButtonItem!
2023-03-01 00:48:36 -05:00
override func viewDidLoad() {
super.viewDidLoad()
2023-03-01 00:48:36 -05:00
collectionView.dataSource = dataSource
activityIndicatorBarButtonItem.isIndicatingActivity = true
let refreshControl = UIRefreshControl()
refreshControl.addTarget(self, action: #selector(AppIDsViewController.fetchAppIDs), for: .primaryActionTriggered)
2023-03-01 00:48:36 -05:00
collectionView.refreshControl = refreshControl
}
2023-03-01 00:48:36 -05:00
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
2023-03-01 00:48:36 -05:00
if !didInitialFetch {
fetchAppIDs()
}
}
}
2023-03-01 00:48:36 -05:00
private extension AppIDsViewController {
func makeDataSource() -> RSTFetchedResultsCollectionViewDataSource<AppID> {
let fetchRequest = AppID.fetchRequest() as NSFetchRequest<AppID>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AppID.name, ascending: true),
NSSortDescriptor(keyPath: \AppID.bundleIdentifier, ascending: true),
NSSortDescriptor(keyPath: \AppID.expirationDate, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
2023-03-01 00:48:36 -05:00
if let team = DatabaseManager.shared.activeTeam() {
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(AppID.team), team)
2023-03-01 00:48:36 -05:00
} else {
fetchRequest.predicate = NSPredicate(value: false)
}
2023-03-01 00:48:36 -05:00
let dataSource = RSTFetchedResultsCollectionViewDataSource<AppID>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.proxy = self
2023-03-01 00:48:36 -05:00
dataSource.cellConfigurationHandler = { cell, appID, _ in
let tintColor = UIColor.altPrimary
2023-03-01 00:48:36 -05:00
let cell = cell as! BannerCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.tintColor = tintColor
2023-03-01 00:48:36 -05:00
cell.bannerView.iconImageView.isHidden = true
cell.bannerView.button.isIndicatingActivity = false
2023-03-01 00:48:36 -05:00
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
let attributedAccessibilityLabel = NSMutableAttributedString(string: appID.name + ". ")
2023-03-01 00:48:36 -05:00
if let expirationDate = appID.expirationDate {
cell.bannerView.button.isHidden = false
cell.bannerView.button.isUserInteractionEnabled = false
2023-03-01 00:48:36 -05:00
cell.bannerView.buttonLabel.isHidden = false
2023-03-01 00:48:36 -05:00
let currentDate = Date()
2023-03-01 00:48:36 -05:00
let numberOfDays = expirationDate.numberOfCalendarDays(since: currentDate)
2020-08-27 15:23:21 -07:00
let numberOfDaysText = (numberOfDays == 1) ? NSLocalizedString("1 day", comment: "") : String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
attributedAccessibilityLabel.mutableString.append(String(format: NSLocalizedString("Expires in %@.", comment: ""), numberOfDaysText) + " ")
2023-03-01 00:48:36 -05:00
} else {
cell.bannerView.button.isHidden = true
2020-08-27 15:23:21 -07:00
cell.bannerView.button.isUserInteractionEnabled = true
2023-03-01 00:48:36 -05:00
cell.bannerView.buttonLabel.isHidden = true
}
2023-03-01 00:48:36 -05:00
cell.bannerView.titleLabel.text = appID.name
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
cell.bannerView.subtitleLabel.numberOfLines = 2
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
2023-03-01 00:48:36 -05:00
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased()), #available(iOS 13, *) {
2020-08-27 15:23:21 -07:00
// Prefer to speak the team ID one character at a time.
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
attributedBundleIdentifier.addAttributes([.accessibilitySpeechSpellOut: true], range: nsRange)
}
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
attributedAccessibilityLabel.append(attributedBundleIdentifier)
cell.bannerView.accessibilityAttributedLabel = attributedAccessibilityLabel
2023-03-01 00:48:36 -05:00
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
}
2023-03-01 00:48:36 -05:00
return dataSource
}
2023-03-01 00:48:36 -05:00
@objc func fetchAppIDs() {
guard !isLoading else { return }
isLoading = true
AppManager.shared.fetchAppIDs { result in
do {
let (_, context) = try result.get()
try context.save()
2023-03-01 00:48:36 -05:00
} catch {
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
self.isLoading = false
}
}
}
2023-03-01 00:48:36 -05:00
func update() {
if !isLoading {
collectionView.refreshControl?.endRefreshing()
activityIndicatorBarButtonItem.isIndicatingActivity = false
}
}
}
2023-03-01 00:48:36 -05:00
extension AppIDsViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
CGSize(width: collectionView.bounds.width, height: 80)
}
2023-03-01 00:48:36 -05:00
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
let indexPath = IndexPath(row: 0, section: section)
let headerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionHeader, at: indexPath)
2023-03-01 00:48:36 -05:00
// Use this view to calculate the optimal size based on the collection view's width
let size = headerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
withHorizontalFittingPriority: .required, // Width is fixed
verticalFittingPriority: .fittingSizeLevel) // Height can be as large as needed
return size
}
2023-03-01 00:48:36 -05:00
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection _: Int) -> CGSize {
CGSize(width: collectionView.bounds.width, height: 50)
}
2023-03-01 00:48:36 -05:00
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
switch kind {
case UICollectionView.elementKindSectionHeader:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Header", for: indexPath) as! TextCollectionReusableView
2023-03-01 00:48:36 -05:00
headerView.layoutMargins.left = view.layoutMargins.left
headerView.layoutMargins.right = view.layoutMargins.right
if let activeTeam = DatabaseManager.shared.activeTeam(), activeTeam.type == .free {
2023-03-01 14:36:52 -05:00
let text = NSLocalizedString(
"""
Each app and app extension installed with SideStore must register an App ID with Apple. Apple limits non-developer Apple IDs to 10 App IDs at a time.
**App IDs can't be deleted**, but they do expire after one week. SideStore will automatically renew App IDs for all active apps once they've expired.
""", comment: "")
2023-03-01 00:48:36 -05:00
2023-03-01 14:36:52 -05:00
let mdown = Down(markdownString: text)
let labelFont: DownFont = headerView.textLabel.font
let fonts: FontCollection = StaticFontCollection(
heading1: labelFont,
heading2: labelFont,
heading3: labelFont,
heading4: labelFont,
heading5: labelFont,
heading6: labelFont,
body: labelFont,
code: labelFont,
listItemPrefix: labelFont)
let config: DownStylerConfiguration = .init(fonts: fonts)
let styler: Styler = DownStyler.init(configuration: config)
let options: DownOptions = .default
headerView.textLabel.attributedText = try? mdown.toAttributedString(options,
styler: styler) ?? NSAttributedString(string: text)
} else {
headerView.textLabel.text = NSLocalizedString("""
Each app and app extension installed with SideStore must register an App ID with Apple.
2023-03-01 00:48:36 -05:00
App IDs for paid developer accounts never expire, and there is no limit to how many you can create.
""", comment: "")
}
2023-03-01 00:48:36 -05:00
return headerView
2023-03-01 00:48:36 -05:00
case UICollectionView.elementKindSectionFooter:
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView
2023-03-01 00:48:36 -05:00
let count = dataSource.itemCount
if count == 1 {
footerView.textLabel.text = NSLocalizedString("1 App ID", comment: "")
2023-03-01 00:48:36 -05:00
} else {
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs", comment: ""), NSNumber(value: count))
}
2023-03-01 00:48:36 -05:00
return footerView
2023-03-01 00:48:36 -05:00
default: fatalError()
}
}
}
2023-03-01 14:36:52 -05:00
fileprivate enum MarkdownStyledBlock: Equatable {
case generic
case headline(Int)
case paragraph
case unorderedListElement
case orderedListElement(Int)
case blockquote
case code(String?)
}
// MARK: -
@available(iOS 15, *)
extension AttributedString {
init(styledMarkdown markdownString: String, fontSize: CGFloat = UIFont.preferredFont(forTextStyle: .body).pointSize) throws {
var s = try AttributedString(markdown: markdownString, options: .init(allowsExtendedAttributes: true, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: "en"), baseURL: nil)
// Looking at the AttributedStrings raw structure helps with understanding the following code.
print(s)
// Set base font and paragraph style for the whole string
s.font = .systemFont(ofSize: fontSize)
s.paragraphStyle = defaultParagraphStyle
// Will respect dark mode automatically
s.foregroundColor = .label
// MARK: Inline Intents
let inlineIntents: [InlinePresentationIntent] = [.emphasized, .stronglyEmphasized, .code, .strikethrough, .softBreak, .lineBreak, .inlineHTML, .blockHTML]
for inlineIntent in inlineIntents {
var sourceAttributeContainer = AttributeContainer()
sourceAttributeContainer.inlinePresentationIntent = inlineIntent
var targetAttributeContainer = AttributeContainer()
switch inlineIntent {
case .emphasized:
targetAttributeContainer.font = .italicSystemFont(ofSize: fontSize)
case .stronglyEmphasized:
targetAttributeContainer.font = .systemFont(ofSize: fontSize, weight: .bold)
case .code:
targetAttributeContainer.font = .monospacedSystemFont(ofSize: fontSize, weight: .regular)
targetAttributeContainer.backgroundColor = .secondarySystemBackground
case .strikethrough:
targetAttributeContainer.strikethroughStyle = .single
case .softBreak:
break // TODO: Implement
case .lineBreak:
break // TODO: Implement
case .inlineHTML:
break // TODO: Implement
case .blockHTML:
break // TODO: Implement
default:
break
}
s = s.replacingAttributes(sourceAttributeContainer, with: targetAttributeContainer)
}
// MARK: Blocks
// Accessing via dynamic lookup key path (\.presentationIntent) triggers a warning on Xcode 13.1, so we use the verbose way: AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self
// We use .reversed() iteration to be able to add characters to the string without breaking ranges.
var previousListID = 0
for (intentBlock, intentRange) in s.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
var block: MarkdownStyledBlock = .generic
var currentElementOrdinal: Int = 0
var currentListID = 0
for intent in intentBlock.components {
switch intent.kind {
case .paragraph:
if block == .generic {
block = .paragraph
}
case .header(level: let level):
block = .headline(level)
case .orderedList:
block = .orderedListElement(currentElementOrdinal)
currentListID = intent.identity
case .unorderedList:
block = .unorderedListElement
currentListID = intent.identity
case .listItem(ordinal: let ordinal):
currentElementOrdinal = ordinal
if block != .unorderedListElement {
block = .orderedListElement(ordinal)
}
case .codeBlock(languageHint: let languageHint):
block = .code(languageHint)
case .blockQuote:
block = .blockquote
case .thematicBreak:
break // This is ---- in Markdown.
case .table(columns: _):
break
case .tableHeaderRow:
break
case .tableRow(rowIndex: _):
break
case .tableCell(columnIndex: _):
break
@unknown default:
break
}
}
switch block {
case .generic:
assertionFailure(intentBlock.debugDescription)
case .headline(let level):
switch level {
case 1:
s[intentRange].font = .systemFont(ofSize: 30, weight: .heavy)
case 2:
s[intentRange].font = .systemFont(ofSize: 20, weight: .heavy)
case 3:
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
default:
// TODO: Handle H4 to H6
s[intentRange].font = .systemFont(ofSize: 15, weight: .heavy)
}
case .paragraph:
break
case .unorderedListElement:
s.characters.insert(contentsOf: "\t", at: intentRange.lowerBound)
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
case .orderedListElement(let ordinal):
s.characters.insert(contentsOf: "\(ordinal).\t", at: intentRange.lowerBound)
s[intentRange].paragraphStyle = previousListID == currentListID ? listParagraphStyle : lastElementListParagraphStyle
case .blockquote:
s[intentRange].paragraphStyle = defaultParagraphStyle
s[intentRange].foregroundColor = .secondaryLabel
case .code:
s[intentRange].font = .monospacedSystemFont(ofSize: 13, weight: .regular)
s[intentRange].paragraphStyle = codeParagraphStyle
}
// Remember the list ID so we can check if its identical in the next block
previousListID = currentListID
// MARK: Add line breaks to separate blocks
if intentRange.lowerBound != s.startIndex {
s.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
}
}
self = s
}
}
fileprivate let defaultParagraphStyle: NSParagraphStyle = {
var paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.paragraphSpacing = 10.0
paragraphStyle.minimumLineHeight = 20.0
return paragraphStyle
}()
fileprivate let listParagraphStyle: NSMutableParagraphStyle = {
var paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
paragraphStyle.headIndent = 20
paragraphStyle.minimumLineHeight = 20.0
return paragraphStyle
}()
fileprivate let lastElementListParagraphStyle: NSMutableParagraphStyle = {
var paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: 20)]
paragraphStyle.headIndent = 20
paragraphStyle.minimumLineHeight = 20.0
paragraphStyle.paragraphSpacing = 20.0 // The last element in a list needs extra paragraph spacing
return paragraphStyle
}()
fileprivate let codeParagraphStyle: NSParagraphStyle = {
var paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.minimumLineHeight = 20.0
paragraphStyle.firstLineHeadIndent = 20
paragraphStyle.headIndent = 20
return paragraphStyle
}()