Signed-off-by: Joseph Mattello <mail@joemattiello.com>
This commit is contained in:
Joseph Mattello
2023-04-02 02:28:12 -04:00
parent 2c829895c9
commit c4c2d17ffc
126 changed files with 1639 additions and 124 deletions

View File

@@ -0,0 +1,418 @@
//
// AppIDsViewController.swift
// AltStore
//
// Created by Riley Testut on 1/27/20.
// Copyright © 2020 Riley Testut. All rights reserved.
//
import UIKit
import SideStoreCore
import RoxasUIKit
import Down
final class AppIDsViewController: UICollectionViewController {
private lazy var dataSource = self.makeDataSource()
private var didInitialFetch = false
private var isLoading = false {
didSet {
update()
}
}
@IBOutlet var activityIndicatorBarButtonItem: UIBarButtonItem!
override func viewDidLoad() {
super.viewDidLoad()
collectionView.dataSource = dataSource
activityIndicatorBarButtonItem.isIndicatingActivity = true
let refreshControl = UIRefreshControl()
refreshControl.addTarget(self, action: #selector(AppIDsViewController.fetchAppIDs), for: .primaryActionTriggered)
collectionView.refreshControl = refreshControl
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !didInitialFetch {
fetchAppIDs()
}
}
}
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
if let team = DatabaseManager.shared.activeTeam() {
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(AppID.team), team)
} else {
fetchRequest.predicate = NSPredicate(value: false)
}
let dataSource = RSTFetchedResultsCollectionViewDataSource<AppID>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.proxy = self
dataSource.cellConfigurationHandler = { cell, appID, _ in
let tintColor = UIColor.altPrimary
let cell = cell as! BannerCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.tintColor = tintColor
cell.bannerView.iconImageView.isHidden = true
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
let attributedAccessibilityLabel = NSMutableAttributedString(string: appID.name + ". ")
if let expirationDate = appID.expirationDate {
cell.bannerView.button.isHidden = false
cell.bannerView.button.isUserInteractionEnabled = false
cell.bannerView.buttonLabel.isHidden = false
let currentDate = Date()
let numberOfDays = expirationDate.numberOfCalendarDays(since: currentDate)
let numberOfDaysText = (numberOfDays == 1) ? NSLocalizedString("1 day", comment: "") : String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
attributedAccessibilityLabel.mutableString.append(String(format: NSLocalizedString("Expires in %@.", comment: ""), numberOfDaysText) + " ")
} else {
cell.bannerView.button.isHidden = true
cell.bannerView.button.isUserInteractionEnabled = true
cell.bannerView.buttonLabel.isHidden = true
}
cell.bannerView.titleLabel.text = appID.name
cell.bannerView.subtitleLabel.text = appID.bundleIdentifier
cell.bannerView.subtitleLabel.numberOfLines = 2
let attributedBundleIdentifier = NSMutableAttributedString(string: appID.bundleIdentifier.lowercased(), attributes: [.accessibilitySpeechPunctuation: true])
if let team = appID.team, let range = attributedBundleIdentifier.string.range(of: team.identifier.lowercased()), #available(iOS 13, *) {
// Prefer to speak the team ID one character at a time.
let nsRange = NSRange(range, in: attributedBundleIdentifier.string)
attributedBundleIdentifier.addAttributes([.accessibilitySpeechSpellOut: true], range: nsRange)
}
attributedAccessibilityLabel.append(attributedBundleIdentifier)
cell.bannerView.accessibilityAttributedLabel = attributedAccessibilityLabel
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
}
return dataSource
}
@objc func fetchAppIDs() {
guard !isLoading else { return }
isLoading = true
AppManager.shared.fetchAppIDs { result in
do {
let (_, context) = try result.get()
try context.save()
} catch {
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
DispatchQueue.main.async {
self.isLoading = false
}
}
}
func update() {
if !isLoading {
collectionView.refreshControl?.endRefreshing()
activityIndicatorBarButtonItem.isIndicatingActivity = false
}
}
}
extension AppIDsViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
CGSize(width: collectionView.bounds.width, height: 80)
}
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)
// 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
}
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection _: Int) -> CGSize {
CGSize(width: collectionView.bounds.width, height: 50)
}
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
headerView.layoutMargins.left = view.layoutMargins.left
headerView.layoutMargins.right = view.layoutMargins.right
if let activeTeam = DatabaseManager.shared.activeTeam(), activeTeam.type == .free {
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: "")
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.
App IDs for paid developer accounts never expire, and there is no limit to how many you can create.
""", comment: "")
}
return headerView
case UICollectionView.elementKindSectionFooter:
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "Footer", for: indexPath) as! TextCollectionReusableView
let count = dataSource.itemCount
if count == 1 {
footerView.textLabel.text = NSLocalizedString("1 App ID", comment: "")
} else {
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs", comment: ""), NSNumber(value: count))
}
return footerView
default: fatalError()
}
}
}
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
}()