mirror of
https://github.com/SideStore/SideStore.git
synced 2026-05-15 05:45:38 +02:00
Create swift package
This commit is contained in:
@@ -1,206 +0,0 @@
|
||||
//
|
||||
// ViewController.swift
|
||||
// AltBackup
|
||||
//
|
||||
// Created by Riley Testut on 5/11/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension Bundle
|
||||
{
|
||||
var appName: String? {
|
||||
let appName =
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ??
|
||||
Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String
|
||||
return appName
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewController
|
||||
{
|
||||
enum BackupOperation
|
||||
{
|
||||
case backup
|
||||
case restore
|
||||
}
|
||||
}
|
||||
|
||||
class ViewController: UIViewController
|
||||
{
|
||||
private let backupController = BackupController()
|
||||
|
||||
private var currentOperation: BackupOperation? {
|
||||
didSet {
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var textLabel: UILabel!
|
||||
private var detailTextLabel: UILabel!
|
||||
private var activityIndicatorView: UIActivityIndicatorView!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .lightContent
|
||||
}
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?)
|
||||
{
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.backup), name: AppDelegate.startBackupNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.restore), name: AppDelegate.startRestoreNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.view.backgroundColor = .altstoreBackground
|
||||
|
||||
self.textLabel = UILabel(frame: .zero)
|
||||
self.textLabel.font = UIFont.preferredFont(forTextStyle: .title2)
|
||||
self.textLabel.textColor = .altstoreText
|
||||
self.textLabel.textAlignment = .center
|
||||
self.textLabel.numberOfLines = 0
|
||||
|
||||
self.detailTextLabel = UILabel(frame: .zero)
|
||||
self.detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
self.detailTextLabel.textColor = .altstoreText
|
||||
self.detailTextLabel.textAlignment = .center
|
||||
self.detailTextLabel.numberOfLines = 0
|
||||
|
||||
self.activityIndicatorView = UIActivityIndicatorView(style: .whiteLarge)
|
||||
self.activityIndicatorView.color = .altstoreText
|
||||
self.activityIndicatorView.startAnimating()
|
||||
|
||||
#if DEBUG
|
||||
let button1 = UIButton(type: .system)
|
||||
button1.setTitle("Backup", for: .normal)
|
||||
button1.setTitleColor(.white, for: .normal)
|
||||
button1.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
button1.addTarget(self, action: #selector(ViewController.backup), for: .primaryActionTriggered)
|
||||
|
||||
let button2 = UIButton(type: .system)
|
||||
button2.setTitle("Restore", for: .normal)
|
||||
button2.setTitleColor(.white, for: .normal)
|
||||
button2.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
button2.addTarget(self, action: #selector(ViewController.restore), for: .primaryActionTriggered)
|
||||
|
||||
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!, button1, button2]
|
||||
#else
|
||||
let arrangedSubviews = [self.textLabel!, self.detailTextLabel!, self.activityIndicatorView!]
|
||||
#endif
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.spacing = 22
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
self.view.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([stackView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
|
||||
stackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor),
|
||||
stackView.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: self.view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1.0),
|
||||
self.view.safeAreaLayoutGuide.trailingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: stackView.trailingAnchor, multiplier: 1.0)])
|
||||
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ViewController
|
||||
{
|
||||
@objc func backup()
|
||||
{
|
||||
self.currentOperation = .backup
|
||||
|
||||
self.backupController.performBackup { (result) in
|
||||
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
|
||||
|
||||
let title = String(format: NSLocalizedString("%@ could not be backed up.", comment: ""), appName)
|
||||
self.process(result, errorTitle: title)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func restore()
|
||||
{
|
||||
self.currentOperation = .restore
|
||||
|
||||
self.backupController.restoreBackup { (result) in
|
||||
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
|
||||
|
||||
let title = String(format: NSLocalizedString("%@ could not be restored.", comment: ""), appName)
|
||||
self.process(result, errorTitle: title)
|
||||
}
|
||||
}
|
||||
|
||||
func update()
|
||||
{
|
||||
switch self.currentOperation
|
||||
{
|
||||
case .backup:
|
||||
self.textLabel.text = NSLocalizedString("Backing up app data…", comment: "")
|
||||
self.detailTextLabel.isHidden = true
|
||||
self.activityIndicatorView.startAnimating()
|
||||
|
||||
case .restore:
|
||||
self.textLabel.text = NSLocalizedString("Restoring app data…", comment: "")
|
||||
self.detailTextLabel.isHidden = true
|
||||
self.activityIndicatorView.startAnimating()
|
||||
|
||||
case .none:
|
||||
self.textLabel.text = String(format: NSLocalizedString("%@ is inactive.", comment: ""),
|
||||
Bundle.main.appName ?? NSLocalizedString("App", comment: ""))
|
||||
|
||||
self.detailTextLabel.text = String(format: NSLocalizedString("Refresh %@ in SideStore to continue using it.", comment: ""),
|
||||
Bundle.main.appName ?? NSLocalizedString("this app", comment: ""))
|
||||
|
||||
self.detailTextLabel.isHidden = false
|
||||
self.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ViewController
|
||||
{
|
||||
func process(_ result: Result<Void, Error>, errorTitle: String)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
switch result
|
||||
{
|
||||
case .success: break
|
||||
case .failure(let error as NSError):
|
||||
let message: String
|
||||
|
||||
if let sourceDescription = error.sourceDescription
|
||||
{
|
||||
message = error.localizedDescription + "\n\n" + sourceDescription
|
||||
}
|
||||
else
|
||||
{
|
||||
message = error.localizedDescription
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: errorTitle, message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil))
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: AppDelegate.operationDidFinishNotification, object: nil, userInfo: [AppDelegate.operationResultKey: result])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func didEnterBackground(_ notification: Notification)
|
||||
{
|
||||
// Reset UI once we've left app (but not before).
|
||||
self.currentOperation = nil
|
||||
}
|
||||
}
|
||||
19
Sources/AltPatcher/ALTAppPatcher.h
Normal file
19
Sources/AltPatcher/ALTAppPatcher.h
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// ALTAppPatcher.h
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/18/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ALTAppPatcher : NSObject
|
||||
|
||||
- (BOOL)patchAppBinaryAtURL:(NSURL *)appFileURL withBinaryAtURL:(NSURL *)patchFileURL error:(NSError *_Nullable *)error;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
143
Sources/AltPatcher/ALTAppPatcher.m
Normal file
143
Sources/AltPatcher/ALTAppPatcher.m
Normal file
@@ -0,0 +1,143 @@
|
||||
//
|
||||
// ALTAppPatcher.m
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/18/21.
|
||||
// Copied with minor modifications from sample code provided by Linus Henze.
|
||||
//
|
||||
|
||||
#import "ALTAppPatcher.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
@import RoxasUI;
|
||||
|
||||
#define CPU_SUBTYPE_PAC 0x80000000
|
||||
#define FAT_MAGIC 0xcafebabe
|
||||
|
||||
#define ROUND_TO_PAGE(val) (((val % 0x4000) == 0) ? val : (val + (0x4000 - (val & 0x3FFF))))
|
||||
|
||||
typedef struct {
|
||||
uint32_t magic;
|
||||
uint32_t cpuType;
|
||||
uint32_t cpuSubType;
|
||||
// Incomplete, we don't need anything else
|
||||
} MachOHeader;
|
||||
|
||||
typedef struct {
|
||||
uint32_t cpuType;
|
||||
uint32_t cpuSubType;
|
||||
uint32_t fileOffset;
|
||||
uint32_t size;
|
||||
uint32_t alignment;
|
||||
} FatArch;
|
||||
|
||||
typedef struct {
|
||||
uint32_t magic;
|
||||
uint32_t archCount;
|
||||
FatArch archs[0];
|
||||
} FatHeader;
|
||||
|
||||
// Given two MachO files, return a FAT file with the following properties:
|
||||
// 1. installd will still see the original MachO and validate it's code signature
|
||||
// 2. The kernel will only see the injected MachO instead
|
||||
//
|
||||
// Only arm64e for now
|
||||
void *injectApp(void *originalApp, size_t originalAppSize, void *appToInject, size_t appToInjectSize, size_t *outputSize) {
|
||||
*outputSize = 0;
|
||||
|
||||
// First validate the App to inject: It must be an arm64e application
|
||||
if (appToInjectSize < sizeof(MachOHeader)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
MachOHeader *injectedHeader = (MachOHeader*) appToInject;
|
||||
if (injectedHeader->cpuType != CPU_TYPE_ARM64) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (injectedHeader->cpuSubType != (CPU_SUBTYPE_ARM64E | CPU_SUBTYPE_PAC)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Ok, the App to inject is ok
|
||||
// Now build a fat header
|
||||
size_t originalAppSizeRounded = ROUND_TO_PAGE(originalAppSize);
|
||||
size_t appToInjectSizeRounded = ROUND_TO_PAGE(appToInjectSize);
|
||||
size_t totalSize = 0x4000 /* Fat Header + Alignment */ + originalAppSizeRounded + appToInjectSizeRounded;
|
||||
|
||||
void *fatBuf = malloc(totalSize);
|
||||
if (fatBuf == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
bzero(fatBuf, totalSize);
|
||||
|
||||
FatHeader *fatHeader = (FatHeader*) fatBuf;
|
||||
fatHeader->magic = htonl(FAT_MAGIC);
|
||||
fatHeader->archCount = htonl(2);
|
||||
|
||||
// Write first arch (original app)
|
||||
fatHeader->archs[0].cpuType = htonl(CPU_TYPE_ARM64);
|
||||
fatHeader->archs[0].cpuSubType = htonl(CPU_SUBTYPE_ARM64E); /* Note that this is not a valid cpu subtype */
|
||||
fatHeader->archs[0].fileOffset = htonl(0x4000);
|
||||
fatHeader->archs[0].size = htonl(originalAppSize);
|
||||
fatHeader->archs[0].alignment = htonl(0xE);
|
||||
|
||||
// Write second arch (injected app)
|
||||
fatHeader->archs[1].cpuType = htonl(CPU_TYPE_ARM64);
|
||||
fatHeader->archs[1].cpuSubType = htonl(CPU_SUBTYPE_ARM64E | CPU_SUBTYPE_PAC);
|
||||
fatHeader->archs[1].fileOffset = htonl(0x4000 + originalAppSizeRounded);
|
||||
fatHeader->archs[1].size = htonl(appToInjectSize);
|
||||
fatHeader->archs[1].alignment = htonl(0xE);
|
||||
|
||||
// Ok, now write the MachOs
|
||||
memcpy(fatBuf + 0x4000, originalApp, originalAppSize);
|
||||
memcpy(fatBuf + 0x4000 + originalAppSizeRounded, appToInject, appToInjectSize);
|
||||
|
||||
// We're done!
|
||||
*outputSize = totalSize;
|
||||
return fatBuf;
|
||||
}
|
||||
|
||||
@implementation ALTAppPatcher
|
||||
|
||||
- (BOOL)patchAppBinaryAtURL:(NSURL *)appFileURL withBinaryAtURL:(NSURL *)patchFileURL error:(NSError *__autoreleasing *)error
|
||||
{
|
||||
NSMutableData *originalApp = [NSMutableData dataWithContentsOfURL:appFileURL options:0 error:error];
|
||||
if (originalApp == nil)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSMutableData *injectedApp = [NSMutableData dataWithContentsOfURL:patchFileURL options:0 error:error];
|
||||
if (injectedApp == nil)
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
size_t outputSize = 0;
|
||||
void *output = injectApp(originalApp.mutableBytes, originalApp.length, injectedApp.mutableBytes, injectedApp.length, &outputSize);
|
||||
if (output == NULL)
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
// If injectApp fails, it means the patch app is in the wrong format.
|
||||
*error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSFileReadCorruptFileError userInfo:@{NSURLErrorKey: patchFileURL}];
|
||||
}
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
NSData *outputData = [NSData dataWithBytesNoCopy:output length:outputSize freeWhenDone:YES];
|
||||
if (![outputData writeToURL:appFileURL options:NSDataWritingAtomic error:error])
|
||||
{
|
||||
return NO;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
@end
|
||||
19
Sources/AltPatcher/include/ALTAppPatcher/ALTAppPatcher.h
Normal file
19
Sources/AltPatcher/include/ALTAppPatcher/ALTAppPatcher.h
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// ALTAppPatcher.h
|
||||
// ALTAppPatcher
|
||||
//
|
||||
// Created by Joseph Mattiello on 03/01/23.
|
||||
// Copyright © 2023 Provenance Emu. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
//! Project version number for ALTAppPatcher.
|
||||
FOUNDATION_EXPORT double ALTAppPatcherVersionNumber;
|
||||
|
||||
//! Project version string for ALTAppPatcher.
|
||||
FOUNDATION_EXPORT const unsigned char ALTAppPatcherVersionString[];
|
||||
|
||||
|
||||
# pragma mark - ALTAppPatcher
|
||||
#import <ALTAppPatcher/_ALTAppPatcher.h>
|
||||
1
Sources/AltPatcher/include/ALTAppPatcher/_ALTAppPatcher.h
Symbolic link
1
Sources/AltPatcher/include/ALTAppPatcher/_ALTAppPatcher.h
Symbolic link
@@ -0,0 +1 @@
|
||||
../../ALTAppPatcher.h
|
||||
61
Sources/Cargo/Commands/Build.swift
Normal file
61
Sources/Cargo/Commands/Build.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import ArgumentParser
|
||||
import SwiftLintFramework
|
||||
|
||||
extension SwiftLint {
|
||||
struct Build: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(abstract: "Print lint warnings and errors")
|
||||
|
||||
@OptionGroup
|
||||
var common: LintOrAnalyzeArguments
|
||||
@Option(help: pathOptionDescription(for: .build))
|
||||
var path: String?
|
||||
@Flag(help: quietOptionDescription(for: .build))
|
||||
var quiet = false
|
||||
@Option(help: "The directory of the cache used when linting.")
|
||||
var cachePath: String?
|
||||
@Flag(help: "Ignore cache when linting.")
|
||||
var noCache = false
|
||||
@Flag(help: "Run all rules, even opt-in and disabled ones, ignoring `only_rules`.")
|
||||
var enableAllRules = false
|
||||
@Argument(help: pathsArgumentDescription(for: .build))
|
||||
var paths = [String]()
|
||||
|
||||
func run() async throws {
|
||||
let allPaths: [String]
|
||||
if let path {
|
||||
queuedPrintError("""
|
||||
warning: The --path option is deprecated. Pass the path(s) to lint last to the swiftlint command.
|
||||
""")
|
||||
allPaths = [path] + paths
|
||||
} else if !paths.isEmpty {
|
||||
allPaths = paths
|
||||
} else {
|
||||
allPaths = [""] // Lint files in current working directory if no paths were specified.
|
||||
}
|
||||
let options = LintOrAnalyzeOptions(
|
||||
mode: .build,
|
||||
paths: allPaths,
|
||||
configurationFiles: common.config,
|
||||
strict: common.leniency == .strict,
|
||||
lenient: common.leniency == .lenient,
|
||||
forceExclude: common.forceExclude,
|
||||
useExcludingByPrefix: common.useAlternativeExcluding,
|
||||
useScriptInputFiles: common.useScriptInputFiles,
|
||||
benchmark: common.benchmark,
|
||||
reporter: common.reporter,
|
||||
quiet: quiet,
|
||||
output: common.output,
|
||||
progress: common.progress,
|
||||
cachePath: cachePath,
|
||||
ignoreCache: noCache,
|
||||
enableAllRules: enableAllRules,
|
||||
autocorrect: common.fix,
|
||||
format: common.format,
|
||||
compilerLogPath: nil,
|
||||
compileCommands: nil,
|
||||
inProcessSourcekit: common.inProcessSourcekit
|
||||
)
|
||||
try await LintOrAnalyzeCommand.run(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Sources/Cargo/Commands/Cargo.swift
Normal file
22
Sources/Cargo/Commands/Cargo.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct Cargo: AsyncParsableCommand {
|
||||
static let configuration: CommandConfiguration = {
|
||||
if let directory = ProcessInfo.processInfo.environment["BUILD_WORKSPACE_DIRECTORY"] {
|
||||
FileManager.default.changeCurrentDirectoryPath(directory)
|
||||
}
|
||||
|
||||
return CommandConfiguration(
|
||||
commandName: "cargo",
|
||||
abstract: "A tool to build `rust` projects with `cargo`.",
|
||||
version: Version.value,
|
||||
subcommands: [
|
||||
Build.self,
|
||||
Version.self
|
||||
],
|
||||
defaultSubcommand: Build.self
|
||||
)
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
extension RulesFilter.ExcludingOptions {
|
||||
static func excludingOptions(byCommandLineOptions rulesFilterOptions: RulesFilterOptions) -> Self {
|
||||
var excludingOptions: Self = []
|
||||
|
||||
switch rulesFilterOptions.ruleEnablement {
|
||||
case .enabled:
|
||||
excludingOptions.insert(.disabled)
|
||||
case .disabled:
|
||||
excludingOptions.insert(.enabled)
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if rulesFilterOptions.correctable {
|
||||
excludingOptions.insert(.uncorrectable)
|
||||
}
|
||||
|
||||
return excludingOptions
|
||||
}
|
||||
}
|
||||
20
Sources/Cargo/Commands/Common/RulesFilterOptions.swift
Normal file
20
Sources/Cargo/Commands/Common/RulesFilterOptions.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import ArgumentParser
|
||||
|
||||
enum RuleEnablementOptions: String, EnumerableFlag {
|
||||
case enabled, disabled
|
||||
|
||||
static func name(for value: RuleEnablementOptions) -> NameSpecification {
|
||||
return .shortAndLong
|
||||
}
|
||||
|
||||
static func help(for value: RuleEnablementOptions) -> ArgumentHelp? {
|
||||
return "Only show \(value.rawValue) rules"
|
||||
}
|
||||
}
|
||||
|
||||
struct RulesFilterOptions: ParsableArguments {
|
||||
@Flag(exclusivity: .exclusive)
|
||||
var ruleEnablement: RuleEnablementOptions?
|
||||
@Flag(name: .shortAndLong, help: "Only display correctable rules")
|
||||
var correctable = false
|
||||
}
|
||||
23
Sources/Cargo/Commands/Version.swift
Normal file
23
Sources/Cargo/Commands/Version.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import ArgumentParser
|
||||
import SwiftLintFramework
|
||||
|
||||
extension Cargo {
|
||||
struct Version: ParsableCommand {
|
||||
@Flag(help: "Display full version info")
|
||||
var verbose = false
|
||||
|
||||
static let configuration = CommandConfiguration(abstract: "Display the current version of Cargo")
|
||||
|
||||
static var value: String { "TODO" }
|
||||
|
||||
func run() throws {
|
||||
if verbose, let buildID = ExecutableInfo.buildID {
|
||||
print("Version:", Self.value)
|
||||
print("Build ID:", buildID)
|
||||
} else {
|
||||
print(Self.value)
|
||||
}
|
||||
ExitHelper.successfullyExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Sources/Cargo/swiftlint/Commands/Analyze.swift
Normal file
61
Sources/Cargo/swiftlint/Commands/Analyze.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import ArgumentParser
|
||||
import SwiftLintFramework
|
||||
|
||||
extension SwiftLint {
|
||||
struct Analyze: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(abstract: "Run analysis rules")
|
||||
|
||||
@OptionGroup
|
||||
var common: LintOrAnalyzeArguments
|
||||
@Option(help: pathOptionDescription(for: .analyze))
|
||||
var path: String?
|
||||
@Flag(help: quietOptionDescription(for: .analyze))
|
||||
var quiet = false
|
||||
@Option(help: "The path of the full xcodebuild log to use when running AnalyzerRules.")
|
||||
var compilerLogPath: String?
|
||||
@Option(help: "The path of a compilation database to use when running AnalyzerRules.")
|
||||
var compileCommands: String?
|
||||
@Argument(help: pathsArgumentDescription(for: .analyze))
|
||||
var paths = [String]()
|
||||
|
||||
func run() async throws {
|
||||
let allPaths: [String]
|
||||
if let path {
|
||||
queuedPrintError("""
|
||||
warning: The --path option is deprecated. Pass the path(s) to analyze last to the swiftlint command.
|
||||
""")
|
||||
allPaths = [path] + paths
|
||||
} else if !paths.isEmpty {
|
||||
allPaths = paths
|
||||
} else {
|
||||
allPaths = [""] // Analyze files in current working directory if no paths were specified.
|
||||
}
|
||||
let options = LintOrAnalyzeOptions(
|
||||
mode: .analyze,
|
||||
paths: allPaths,
|
||||
useSTDIN: false,
|
||||
configurationFiles: common.config,
|
||||
strict: common.leniency == .strict,
|
||||
lenient: common.leniency == .lenient,
|
||||
forceExclude: common.forceExclude,
|
||||
useExcludingByPrefix: common.useAlternativeExcluding,
|
||||
useScriptInputFiles: common.useScriptInputFiles,
|
||||
benchmark: common.benchmark,
|
||||
reporter: common.reporter,
|
||||
quiet: quiet,
|
||||
output: common.output,
|
||||
progress: common.progress,
|
||||
cachePath: nil,
|
||||
ignoreCache: true,
|
||||
enableAllRules: false,
|
||||
autocorrect: common.fix,
|
||||
format: common.format,
|
||||
compilerLogPath: compilerLogPath,
|
||||
compileCommands: compileCommands,
|
||||
inProcessSourcekit: common.inProcessSourcekit
|
||||
)
|
||||
|
||||
try await LintOrAnalyzeCommand.run(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
extension RulesFilter.ExcludingOptions {
|
||||
static func excludingOptions(byCommandLineOptions rulesFilterOptions: RulesFilterOptions) -> Self {
|
||||
var excludingOptions: Self = []
|
||||
|
||||
switch rulesFilterOptions.ruleEnablement {
|
||||
case .enabled:
|
||||
excludingOptions.insert(.disabled)
|
||||
case .disabled:
|
||||
excludingOptions.insert(.enabled)
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
if rulesFilterOptions.correctable {
|
||||
excludingOptions.insert(.uncorrectable)
|
||||
}
|
||||
|
||||
return excludingOptions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import ArgumentParser
|
||||
|
||||
enum RuleEnablementOptions: String, EnumerableFlag {
|
||||
case enabled, disabled
|
||||
|
||||
static func name(for value: RuleEnablementOptions) -> NameSpecification {
|
||||
return .shortAndLong
|
||||
}
|
||||
|
||||
static func help(for value: RuleEnablementOptions) -> ArgumentHelp? {
|
||||
return "Only show \(value.rawValue) rules"
|
||||
}
|
||||
}
|
||||
|
||||
struct RulesFilterOptions: ParsableArguments {
|
||||
@Flag(exclusivity: .exclusive)
|
||||
var ruleEnablement: RuleEnablementOptions?
|
||||
@Flag(name: .shortAndLong, help: "Only display correctable rules")
|
||||
var correctable = false
|
||||
}
|
||||
43
Sources/Cargo/swiftlint/Commands/Docs.swift
Normal file
43
Sources/Cargo/swiftlint/Commands/Docs.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import SwiftLintFramework
|
||||
|
||||
extension SwiftLint {
|
||||
struct Docs: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Open SwiftLint documentation website in the default web browser"
|
||||
)
|
||||
|
||||
@Argument(help: "The identifier of the rule to open the documentation for")
|
||||
var ruleID: String?
|
||||
|
||||
func run() throws {
|
||||
var subPage = ""
|
||||
if let ruleID {
|
||||
if primaryRuleList.list[ruleID] == nil {
|
||||
queuedPrintError("There is no rule named '\(ruleID)'. Opening rule directory instead.")
|
||||
subPage = "rule-directory.html"
|
||||
} else {
|
||||
subPage = ruleID + ".html"
|
||||
}
|
||||
}
|
||||
open(URL(string: "https://realm.github.io/SwiftLint/\(subPage)")!)
|
||||
ExitHelper.successfullyExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ url: URL) {
|
||||
let process = Process()
|
||||
#if os(Linux)
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env", isDirectory: false)
|
||||
let command = "xdg-open"
|
||||
process.arguments = [command, url.absoluteString]
|
||||
try? process.run()
|
||||
#else
|
||||
process.launchPath = "/usr/bin/env"
|
||||
let command = "open"
|
||||
process.arguments = [command, url.absoluteString]
|
||||
process.launch()
|
||||
#endif
|
||||
}
|
||||
28
Sources/Cargo/swiftlint/Commands/GenerateDocs.swift
Normal file
28
Sources/Cargo/swiftlint/Commands/GenerateDocs.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import SwiftLintFramework
|
||||
|
||||
extension SwiftLint {
|
||||
struct GenerateDocs: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
abstract: "Generates markdown documentation for selected group of rules"
|
||||
)
|
||||
|
||||
@Option(help: "The directory where the documentation should be saved")
|
||||
var path = "rule_docs"
|
||||
@Option(help: "The path to a SwiftLint configuration file")
|
||||
var config: String?
|
||||
@OptionGroup
|
||||
var rulesFilterOptions: RulesFilterOptions
|
||||
|
||||
func run() throws {
|
||||
let configuration = Configuration(configurationFiles: [config].compactMap({ $0 }))
|
||||
let rulesFilter = RulesFilter(enabledRules: configuration.rules)
|
||||
let rules = rulesFilter.getRules(excluding: .excludingOptions(byCommandLineOptions: rulesFilterOptions))
|
||||
|
||||
try RuleListDocumentation(rules)
|
||||
.write(to: URL(fileURLWithPath: path, isDirectory: true))
|
||||
ExitHelper.successfullyExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
64
Sources/Cargo/swiftlint/Commands/Lint.swift
Normal file
64
Sources/Cargo/swiftlint/Commands/Lint.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import ArgumentParser
|
||||
import SwiftLintFramework
|
||||
|
||||
extension SwiftLint {
|
||||
struct Lint: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(abstract: "Print lint warnings and errors")
|
||||
|
||||
@OptionGroup
|
||||
var common: LintOrAnalyzeArguments
|
||||
@Option(help: pathOptionDescription(for: .lint))
|
||||
var path: String?
|
||||
@Flag(help: "Lint standard input.")
|
||||
var useSTDIN = false
|
||||
@Flag(help: quietOptionDescription(for: .lint))
|
||||
var quiet = false
|
||||
@Option(help: "The directory of the cache used when linting.")
|
||||
var cachePath: String?
|
||||
@Flag(help: "Ignore cache when linting.")
|
||||
var noCache = false
|
||||
@Flag(help: "Run all rules, even opt-in and disabled ones, ignoring `only_rules`.")
|
||||
var enableAllRules = false
|
||||
@Argument(help: pathsArgumentDescription(for: .lint))
|
||||
var paths = [String]()
|
||||
|
||||
func run() async throws {
|
||||
let allPaths: [String]
|
||||
if let path {
|
||||
queuedPrintError("""
|
||||
warning: The --path option is deprecated. Pass the path(s) to lint last to the swiftlint command.
|
||||
""")
|
||||
allPaths = [path] + paths
|
||||
} else if !paths.isEmpty {
|
||||
allPaths = paths
|
||||
} else {
|
||||
allPaths = [""] // Lint files in current working directory if no paths were specified.
|
||||
}
|
||||
let options = LintOrAnalyzeOptions(
|
||||
mode: .lint,
|
||||
paths: allPaths,
|
||||
useSTDIN: useSTDIN,
|
||||
configurationFiles: common.config,
|
||||
strict: common.leniency == .strict,
|
||||
lenient: common.leniency == .lenient,
|
||||
forceExclude: common.forceExclude,
|
||||
useExcludingByPrefix: common.useAlternativeExcluding,
|
||||
useScriptInputFiles: common.useScriptInputFiles,
|
||||
benchmark: common.benchmark,
|
||||
reporter: common.reporter,
|
||||
quiet: quiet,
|
||||
output: common.output,
|
||||
progress: common.progress,
|
||||
cachePath: cachePath,
|
||||
ignoreCache: noCache,
|
||||
enableAllRules: enableAllRules,
|
||||
autocorrect: common.fix,
|
||||
format: common.format,
|
||||
compilerLogPath: nil,
|
||||
compileCommands: nil,
|
||||
inProcessSourcekit: common.inProcessSourcekit
|
||||
)
|
||||
try await LintOrAnalyzeCommand.run(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
Sources/Cargo/swiftlint/Commands/Rules.swift
Normal file
123
Sources/Cargo/swiftlint/Commands/Rules.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import ArgumentParser
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#else
|
||||
#error("Unsupported platform")
|
||||
#endif
|
||||
import Foundation
|
||||
@_spi(TestHelper)
|
||||
import SwiftLintFramework
|
||||
import SwiftyTextTable
|
||||
|
||||
extension SwiftLint {
|
||||
struct Rules: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(abstract: "Display the list of rules and their identifiers")
|
||||
|
||||
@Option(help: "The path to a SwiftLint configuration file")
|
||||
var config: String?
|
||||
@OptionGroup
|
||||
var rulesFilterOptions: RulesFilterOptions
|
||||
@Flag(name: .shortAndLong, help: "Display full configuration details")
|
||||
var verbose = false
|
||||
@Argument(help: "The rule identifier to display description for")
|
||||
var ruleID: String?
|
||||
|
||||
func run() throws {
|
||||
if let ruleID {
|
||||
guard let rule = primaryRuleList.list[ruleID] else {
|
||||
throw SwiftLintError.usageError(description: "No rule with identifier: \(ruleID)")
|
||||
}
|
||||
|
||||
rule.description.printDescription()
|
||||
return
|
||||
}
|
||||
|
||||
let configuration = Configuration(configurationFiles: [config].compactMap({ $0 }))
|
||||
let rulesFilter = RulesFilter(enabledRules: configuration.rules)
|
||||
let rules = rulesFilter.getRules(excluding: .excludingOptions(byCommandLineOptions: rulesFilterOptions))
|
||||
let table = TextTable(ruleList: rules, configuration: configuration, verbose: verbose)
|
||||
print(table.render())
|
||||
ExitHelper.successfullyExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension RuleDescription {
|
||||
func printDescription() {
|
||||
print("\(consoleDescription)")
|
||||
|
||||
guard !triggeringExamples.isEmpty else { return }
|
||||
|
||||
func indent(_ string: String) -> String {
|
||||
return string.components(separatedBy: "\n")
|
||||
.map { " \($0)" }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
print("\nTriggering Examples (violation is marked with '↓'):")
|
||||
for (index, example) in triggeringExamples.enumerated() {
|
||||
print("\nExample #\(index + 1)\n\n\(indent(example.code))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SwiftyTextTable
|
||||
|
||||
private extension TextTable {
|
||||
init(ruleList: RuleList, configuration: Configuration, verbose: Bool) {
|
||||
let columns = [
|
||||
TextTableColumn(header: "identifier"),
|
||||
TextTableColumn(header: "opt-in"),
|
||||
TextTableColumn(header: "correctable"),
|
||||
TextTableColumn(header: "enabled in your config"),
|
||||
TextTableColumn(header: "kind"),
|
||||
TextTableColumn(header: "analyzer"),
|
||||
TextTableColumn(header: "uses sourcekit"),
|
||||
TextTableColumn(header: "configuration")
|
||||
]
|
||||
self.init(columns: columns)
|
||||
let sortedRules = ruleList.list.sorted { $0.0 < $1.0 }
|
||||
func truncate(_ string: String) -> String {
|
||||
let stringWithNoNewlines = string.replacingOccurrences(of: "\n", with: "\\n")
|
||||
let minWidth = "configuration".count - "...".count
|
||||
let configurationStartColumn = 140
|
||||
let maxWidth = verbose ? Int.max : Terminal.currentWidth()
|
||||
let truncatedEndIndex = stringWithNoNewlines.index(
|
||||
stringWithNoNewlines.startIndex,
|
||||
offsetBy: max(minWidth, maxWidth - configurationStartColumn),
|
||||
limitedBy: stringWithNoNewlines.endIndex
|
||||
)
|
||||
if let truncatedEndIndex {
|
||||
return stringWithNoNewlines[..<truncatedEndIndex] + "..."
|
||||
}
|
||||
return stringWithNoNewlines
|
||||
}
|
||||
for (ruleID, ruleType) in sortedRules {
|
||||
let rule = ruleType.init()
|
||||
let configuredRule = configuration.configuredRule(forID: ruleID)
|
||||
addRow(values: [
|
||||
ruleID,
|
||||
(rule is OptInRule) ? "yes" : "no",
|
||||
(rule is CorrectableRule) ? "yes" : "no",
|
||||
configuredRule != nil ? "yes" : "no",
|
||||
ruleType.description.kind.rawValue,
|
||||
(rule is AnalyzerRule) ? "yes" : "no",
|
||||
(rule is SourceKitFreeRule) ? "no" : "yes",
|
||||
truncate((configuredRule ?? rule).configurationDescription)
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Terminal {
|
||||
static func currentWidth() -> Int {
|
||||
var size = winsize()
|
||||
#if os(Linux)
|
||||
_ = ioctl(CInt(STDOUT_FILENO), UInt(TIOCGWINSZ), &size)
|
||||
#else
|
||||
_ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &size)
|
||||
#endif
|
||||
return Int(size.ws_col)
|
||||
}
|
||||
}
|
||||
26
Sources/Cargo/swiftlint/Commands/SwiftLint.swift
Normal file
26
Sources/Cargo/swiftlint/Commands/SwiftLint.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct SwiftLint: AsyncParsableCommand {
|
||||
static let configuration: CommandConfiguration = {
|
||||
if let directory = ProcessInfo.processInfo.environment["BUILD_WORKSPACE_DIRECTORY"] {
|
||||
FileManager.default.changeCurrentDirectoryPath(directory)
|
||||
}
|
||||
|
||||
return CommandConfiguration(
|
||||
commandName: "swiftlint",
|
||||
abstract: "A tool to enforce Swift style and conventions.",
|
||||
version: Version.value,
|
||||
subcommands: [
|
||||
Analyze.self,
|
||||
Docs.self,
|
||||
GenerateDocs.self,
|
||||
Lint.self,
|
||||
Rules.self,
|
||||
Version.self
|
||||
],
|
||||
defaultSubcommand: Lint.self
|
||||
)
|
||||
}()
|
||||
}
|
||||
23
Sources/Cargo/swiftlint/Commands/Version.swift
Normal file
23
Sources/Cargo/swiftlint/Commands/Version.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import ArgumentParser
|
||||
import SwiftLintFramework
|
||||
|
||||
extension SwiftLint {
|
||||
struct Version: ParsableCommand {
|
||||
@Flag(help: "Display full version info")
|
||||
var verbose = false
|
||||
|
||||
static let configuration = CommandConfiguration(abstract: "Display the current version of SwiftLint")
|
||||
|
||||
static var value: String { SwiftLintFramework.Version.current.value }
|
||||
|
||||
func run() throws {
|
||||
if verbose, let buildID = ExecutableInfo.buildID {
|
||||
print("Version:", Self.value)
|
||||
print("Build ID:", buildID)
|
||||
} else {
|
||||
print(Self.value)
|
||||
}
|
||||
ExitHelper.successfullyExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import CollectionConcurrencyKit
|
||||
import Foundation
|
||||
import SourceKittenFramework
|
||||
import SwiftLintFramework
|
||||
|
||||
private actor CounterActor {
|
||||
private var count = 0
|
||||
|
||||
func next() -> Int {
|
||||
count += 1
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
private func scriptInputFiles() throws -> [SwiftLintFile] {
|
||||
let inputFileKey = "SCRIPT_INPUT_FILE_COUNT"
|
||||
guard let countString = ProcessInfo.processInfo.environment[inputFileKey] else {
|
||||
throw SwiftLintError.usageError(description: "\(inputFileKey) variable not set")
|
||||
}
|
||||
|
||||
guard let count = Int(countString) else {
|
||||
throw SwiftLintError.usageError(description: "\(inputFileKey) did not specify a number")
|
||||
}
|
||||
|
||||
return (0..<count).compactMap { fileNumber in
|
||||
do {
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let variable = "SCRIPT_INPUT_FILE_\(fileNumber)"
|
||||
guard let path = environment[variable] else {
|
||||
throw SwiftLintError.usageError(description: "Environment variable not set: \(variable)")
|
||||
}
|
||||
if path.bridge().isSwiftFile() {
|
||||
return SwiftLintFile(pathDeferringReading: path)
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
queuedPrintError(String(describing: error))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(Linux)
|
||||
private func autoreleasepool<T>(block: () -> T) -> T { return block() }
|
||||
#endif
|
||||
|
||||
extension Configuration {
|
||||
func visitLintableFiles(with visitor: LintableFilesVisitor, storage: RuleStorage) async throws -> [SwiftLintFile] {
|
||||
let files = try await Signposts.record(name: "Configuration.VisitLintableFiles.GetFiles") {
|
||||
try await getFiles(with: visitor)
|
||||
}
|
||||
let groupedFiles = try Signposts.record(name: "Configuration.VisitLintableFiles.GroupFiles") {
|
||||
try groupFiles(files, visitor: visitor)
|
||||
}
|
||||
let lintersForFile = Signposts.record(name: "Configuration.VisitLintableFiles.LintersForFile") {
|
||||
groupedFiles.map { file in
|
||||
linters(for: [file.key: file.value], visitor: visitor)
|
||||
}
|
||||
}
|
||||
let duplicateFileNames = Signposts.record(name: "Configuration.VisitLintableFiles.DuplicateFileNames") {
|
||||
lintersForFile.map(\.duplicateFileNames)
|
||||
}
|
||||
let collected = await Signposts.record(name: "Configuration.VisitLintableFiles.Collect") {
|
||||
await zip(lintersForFile, duplicateFileNames).asyncMap { linters, duplicateFileNames in
|
||||
await collect(linters: linters, visitor: visitor, storage: storage,
|
||||
duplicateFileNames: duplicateFileNames)
|
||||
}
|
||||
}
|
||||
let result = await Signposts.record(name: "Configuration.VisitLintableFiles.Visit") {
|
||||
await collected.asyncMap { linters, duplicateFileNames in
|
||||
await visit(linters: linters, visitor: visitor, duplicateFileNames: duplicateFileNames)
|
||||
}
|
||||
}
|
||||
return result.flatMap { $0 }
|
||||
}
|
||||
|
||||
private func groupFiles(_ files: [SwiftLintFile], visitor: LintableFilesVisitor) throws
|
||||
-> [Configuration: [SwiftLintFile]] {
|
||||
if files.isEmpty && !visitor.allowZeroLintableFiles {
|
||||
throw SwiftLintError.usageError(
|
||||
description: "No lintable files found at paths: '\(visitor.paths.joined(separator: ", "))'"
|
||||
)
|
||||
}
|
||||
|
||||
var groupedFiles = [Configuration: [SwiftLintFile]]()
|
||||
for file in files {
|
||||
let fileConfiguration = configuration(for: file)
|
||||
let fileConfigurationRootPath = fileConfiguration.rootDirectory.bridge()
|
||||
|
||||
// Files whose configuration specifies they should be excluded will be skipped
|
||||
let shouldSkip = fileConfiguration.excludedPaths.contains { excludedRelativePath in
|
||||
let excludedPath = fileConfigurationRootPath.appendingPathComponent(excludedRelativePath)
|
||||
let filePathComponents = file.path?.bridge().pathComponents ?? []
|
||||
let excludedPathComponents = excludedPath.bridge().pathComponents
|
||||
return filePathComponents.starts(with: excludedPathComponents)
|
||||
}
|
||||
|
||||
if !shouldSkip {
|
||||
groupedFiles[fileConfiguration, default: []].append(file)
|
||||
}
|
||||
}
|
||||
|
||||
return groupedFiles
|
||||
}
|
||||
|
||||
private func outputFilename(for path: String, duplicateFileNames: Set<String>) -> String {
|
||||
let basename = path.bridge().lastPathComponent
|
||||
if !duplicateFileNames.contains(basename) {
|
||||
return basename
|
||||
}
|
||||
|
||||
var pathComponents = path.bridge().pathComponents
|
||||
for component in rootDirectory.bridge().pathComponents where pathComponents.first == component {
|
||||
pathComponents.removeFirst()
|
||||
}
|
||||
|
||||
return pathComponents.joined(separator: "/")
|
||||
}
|
||||
|
||||
private func linters(for filesPerConfiguration: [Configuration: [SwiftLintFile]],
|
||||
visitor: LintableFilesVisitor) -> [Linter] {
|
||||
let fileCount = filesPerConfiguration.reduce(0) { $0 + $1.value.count }
|
||||
|
||||
var linters = [Linter]()
|
||||
linters.reserveCapacity(fileCount)
|
||||
for (config, files) in filesPerConfiguration {
|
||||
let newConfig: Configuration
|
||||
if visitor.cache != nil {
|
||||
newConfig = config.withPrecomputedCacheDescription()
|
||||
} else {
|
||||
newConfig = config
|
||||
}
|
||||
linters += files.map { visitor.linter(forFile: $0, configuration: newConfig) }
|
||||
}
|
||||
return linters
|
||||
}
|
||||
|
||||
private func collect(linters: [Linter],
|
||||
visitor: LintableFilesVisitor,
|
||||
storage: RuleStorage,
|
||||
duplicateFileNames: Set<String>) async -> ([CollectedLinter], Set<String>) {
|
||||
let counter = CounterActor()
|
||||
let total = linters.filter(\.isCollecting).count
|
||||
let progress = ProgressBar(count: total)
|
||||
if visitor.showProgressBar && total > 0 {
|
||||
await progress.initialize()
|
||||
}
|
||||
let collect = { (linter: Linter) -> CollectedLinter? in
|
||||
let skipFile = visitor.shouldSkipFile(atPath: linter.file.path)
|
||||
if !visitor.quiet && linter.isCollecting {
|
||||
if visitor.showProgressBar {
|
||||
await progress.printNext()
|
||||
} else if let filePath = linter.file.path {
|
||||
let outputFilename = self.outputFilename(for: filePath, duplicateFileNames: duplicateFileNames)
|
||||
let collected = await counter.next()
|
||||
if skipFile {
|
||||
queuedPrintError("""
|
||||
Skipping '\(outputFilename)' (\(collected)/\(total)) \
|
||||
because its compiler arguments could not be found
|
||||
""")
|
||||
} else {
|
||||
queuedPrintError("Collecting '\(outputFilename)' (\(collected)/\(total))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !skipFile else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return autoreleasepool {
|
||||
linter.collect(into: storage)
|
||||
}
|
||||
}
|
||||
|
||||
let collectedLinters = await visitor.parallel ?
|
||||
linters.concurrentCompactMap(collect) :
|
||||
linters.asyncCompactMap(collect)
|
||||
return (collectedLinters, duplicateFileNames)
|
||||
}
|
||||
|
||||
private func visit(linters: [CollectedLinter],
|
||||
visitor: LintableFilesVisitor,
|
||||
duplicateFileNames: Set<String>) async -> [SwiftLintFile] {
|
||||
let counter = CounterActor()
|
||||
let progress = ProgressBar(count: linters.count)
|
||||
if visitor.showProgressBar {
|
||||
await progress.initialize()
|
||||
}
|
||||
let visit = { (linter: CollectedLinter) -> SwiftLintFile in
|
||||
if !visitor.quiet {
|
||||
if visitor.showProgressBar {
|
||||
await progress.printNext()
|
||||
} else if let filePath = linter.file.path {
|
||||
let outputFilename = self.outputFilename(for: filePath, duplicateFileNames: duplicateFileNames)
|
||||
let visited = await counter.next()
|
||||
queuedPrintError("\(visitor.action) '\(outputFilename)' (\(visited)/\(linters.count))")
|
||||
}
|
||||
}
|
||||
|
||||
await Signposts.record(name: "Configuration.Visit", span: .file(linter.file.path ?? "")) {
|
||||
await visitor.block(linter)
|
||||
}
|
||||
return linter.file
|
||||
}
|
||||
return await visitor.parallel ?
|
||||
linters.concurrentMap(visit) :
|
||||
linters.asyncMap(visit)
|
||||
}
|
||||
|
||||
fileprivate func getFiles(with visitor: LintableFilesVisitor) async throws -> [SwiftLintFile] {
|
||||
if visitor.useSTDIN {
|
||||
let stdinData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
if let stdinString = String(data: stdinData, encoding: .utf8) {
|
||||
return [SwiftLintFile(contents: stdinString)]
|
||||
}
|
||||
throw SwiftLintError.usageError(description: "stdin isn't a UTF8-encoded string")
|
||||
} else if visitor.useScriptInputFiles {
|
||||
let files = try scriptInputFiles()
|
||||
guard visitor.forceExclude else {
|
||||
return files
|
||||
}
|
||||
|
||||
let scriptInputPaths = files.compactMap { $0.path }
|
||||
let filesToLint = visitor.useExcludingByPrefix ?
|
||||
filterExcludedPathsByPrefix(in: scriptInputPaths) :
|
||||
filterExcludedPaths(in: scriptInputPaths)
|
||||
return filesToLint.map(SwiftLintFile.init(pathDeferringReading:))
|
||||
}
|
||||
if !visitor.quiet {
|
||||
let filesInfo: String
|
||||
if visitor.paths.isEmpty || visitor.paths == [""] {
|
||||
filesInfo = "in current working directory"
|
||||
} else {
|
||||
filesInfo = "at paths \(visitor.paths.joined(separator: ", "))"
|
||||
}
|
||||
|
||||
queuedPrintError("\(visitor.action) Swift files \(filesInfo)")
|
||||
}
|
||||
return visitor.paths.flatMap {
|
||||
self.lintableFiles(inPath: $0, forceExclude: visitor.forceExclude,
|
||||
excludeByPrefix: visitor.useExcludingByPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func visitLintableFiles(options: LintOrAnalyzeOptions, cache: LinterCache? = nil, storage: RuleStorage,
|
||||
visitorBlock: @escaping (CollectedLinter) async -> Void) async throws -> [SwiftLintFile] {
|
||||
let visitor = try LintableFilesVisitor.create(options, cache: cache,
|
||||
allowZeroLintableFiles: allowZeroLintableFiles,
|
||||
block: visitorBlock)
|
||||
return try await visitLintableFiles(with: visitor, storage: storage)
|
||||
}
|
||||
|
||||
// MARK: LintOrAnalyze Command
|
||||
|
||||
init(options: LintOrAnalyzeOptions) {
|
||||
self.init(
|
||||
configurationFiles: options.configurationFiles,
|
||||
enableAllRules: options.enableAllRules,
|
||||
cachePath: options.cachePath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DuplicateCollector {
|
||||
var all = Set<String>()
|
||||
var duplicates = Set<String>()
|
||||
}
|
||||
|
||||
private extension Collection where Element == Linter {
|
||||
var duplicateFileNames: Set<String> {
|
||||
let collector = reduce(into: DuplicateCollector()) { result, linter in
|
||||
if let filename = linter.file.path?.bridge().lastPathComponent {
|
||||
if result.all.contains(filename) {
|
||||
result.duplicates.insert(filename)
|
||||
}
|
||||
|
||||
result.all.insert(filename)
|
||||
}
|
||||
}
|
||||
return collector.duplicates
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
extension ProcessInfo {
|
||||
var isLikelyXcodeCloudEnvironment: Bool {
|
||||
// https://developer.apple.com/documentation/xcode/environment-variable-reference
|
||||
let requiredKeys: Set = [
|
||||
"CI",
|
||||
"CI_BUILD_ID",
|
||||
"CI_BUILD_NUMBER",
|
||||
"CI_BUNDLE_ID",
|
||||
"CI_COMMIT",
|
||||
"CI_DERIVED_DATA_PATH",
|
||||
"CI_PRODUCT",
|
||||
"CI_PRODUCT_ID",
|
||||
"CI_PRODUCT_PLATFORM",
|
||||
"CI_PROJECT_FILE_PATH",
|
||||
"CI_START_CONDITION",
|
||||
"CI_TEAM_ID",
|
||||
"CI_WORKFLOW",
|
||||
"CI_WORKSPACE",
|
||||
"CI_XCODE_PROJECT",
|
||||
"CI_XCODE_SCHEME",
|
||||
"CI_XCODEBUILD_ACTION"
|
||||
]
|
||||
|
||||
return requiredKeys.isSubset(of: environment.keys)
|
||||
}
|
||||
}
|
||||
52
Sources/Cargo/swiftlint/Helpers/Benchmark.swift
Normal file
52
Sources/Cargo/swiftlint/Helpers/Benchmark.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
import SwiftLintFramework
|
||||
|
||||
struct BenchmarkEntry {
|
||||
let id: String
|
||||
let time: Double
|
||||
}
|
||||
|
||||
struct Benchmark {
|
||||
private let name: String
|
||||
private var entries = [BenchmarkEntry]()
|
||||
|
||||
init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
mutating func record(id: String, time: Double) {
|
||||
guard id != "custom_rules" else { return }
|
||||
entries.append(BenchmarkEntry(id: id, time: time))
|
||||
}
|
||||
|
||||
mutating func record(file: SwiftLintFile, from start: Date) {
|
||||
record(id: file.path ?? "<nopath>", time: -start.timeIntervalSinceNow)
|
||||
}
|
||||
|
||||
func save() {
|
||||
// Decomposed to improve compile times
|
||||
let entriesDict: [String: Double] = entries.reduce(into: [String: Double]()) { accu, idAndTime in
|
||||
accu[idAndTime.id] = (accu[idAndTime.id] ?? 0) + idAndTime.time
|
||||
}
|
||||
let entriesKeyValues: [(String, Double)] = entriesDict.sorted { $0.1 < $1.1 }
|
||||
let lines: [String] = entriesKeyValues.map { id, time -> String in
|
||||
return "\(numberFormatter.string(from: NSNumber(value: time))!): \(id)"
|
||||
}
|
||||
let string: String = lines.joined(separator: "\n") + "\n"
|
||||
let url = URL(fileURLWithPath: "benchmark_\(name)_\(timestamp).txt", isDirectory: false)
|
||||
try? string.data(using: .utf8)?.write(to: url, options: [.atomic])
|
||||
}
|
||||
}
|
||||
|
||||
private let numberFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 3
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private let timestamp: String = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy_MM_dd_HH_mm_ss"
|
||||
return formatter.string(from: Date())
|
||||
}()
|
||||
108
Sources/Cargo/swiftlint/Helpers/CompilerArgumentsExtractor.swift
Normal file
108
Sources/Cargo/swiftlint/Helpers/CompilerArgumentsExtractor.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
|
||||
struct CompilerArgumentsExtractor {
|
||||
static func allCompilerInvocations(compilerLogs: String) -> [[String]] {
|
||||
var compilerInvocations = [[String]]()
|
||||
compilerLogs.enumerateLines { line, _ in
|
||||
if let swiftcIndex = line.range(of: "swiftc ")?.upperBound, line.contains(" -module-name ") {
|
||||
let invocation = parseCLIArguments(String(line[swiftcIndex...]))
|
||||
.expandingResponseFiles
|
||||
.filteringCompilerArguments
|
||||
compilerInvocations.append(invocation)
|
||||
}
|
||||
}
|
||||
return compilerInvocations
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func parseCLIArguments(_ string: String) -> [String] {
|
||||
let escapedSpacePlaceholder = "\u{0}"
|
||||
let scanner = Scanner(string: string)
|
||||
var str = ""
|
||||
var didStart = false
|
||||
while let result = scanner.scanUpToString("\"") {
|
||||
if didStart {
|
||||
str += result.replacingOccurrences(of: " ", with: escapedSpacePlaceholder)
|
||||
str += " "
|
||||
} else {
|
||||
str += result
|
||||
}
|
||||
_ = scanner.scanString("\"")
|
||||
didStart.toggle()
|
||||
}
|
||||
return str.trimmingCharacters(in: .whitespaces)
|
||||
.replacingOccurrences(of: "\\ ", with: escapedSpacePlaceholder)
|
||||
.components(separatedBy: " ")
|
||||
.map { $0.replacingOccurrences(of: escapedSpacePlaceholder, with: " ") }
|
||||
}
|
||||
|
||||
/**
|
||||
Partially filters compiler arguments from `xcodebuild` to something that SourceKit/Clang will accept.
|
||||
|
||||
- parameter args: Compiler arguments, as parsed from `xcodebuild`.
|
||||
|
||||
- returns: A tuple of partially filtered compiler arguments in `.0`, and whether or not there are
|
||||
more flags to remove in `.1`.
|
||||
*/
|
||||
private func partiallyFilter(arguments args: [String]) -> ([String], Bool) {
|
||||
guard let indexOfFlagToRemove = args.firstIndex(of: "-output-file-map") else {
|
||||
return (args, false)
|
||||
}
|
||||
var args = args
|
||||
args.remove(at: args.index(after: indexOfFlagToRemove))
|
||||
args.remove(at: indexOfFlagToRemove)
|
||||
return (args, true)
|
||||
}
|
||||
|
||||
extension Array where Element == String {
|
||||
/// Return the full list of compiler arguments, replacing any response files with their contents.
|
||||
fileprivate var expandingResponseFiles: [String] {
|
||||
return flatMap { arg -> [String] in
|
||||
guard arg.starts(with: "@") else {
|
||||
return [arg]
|
||||
}
|
||||
let responseFile = String(arg.dropFirst())
|
||||
return (try? String(contentsOf: URL(fileURLWithPath: responseFile, isDirectory: false))).flatMap {
|
||||
$0.trimmingCharacters(in: .newlines)
|
||||
.components(separatedBy: "\n")
|
||||
.expandingResponseFiles
|
||||
} ?? [arg]
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns filtered compiler arguments from `xcodebuild` to something that SourceKit/Clang will accept.
|
||||
var filteringCompilerArguments: [String] {
|
||||
var args = self
|
||||
if args.first == "swiftc" {
|
||||
args.removeFirst()
|
||||
}
|
||||
|
||||
// https://github.com/realm/SwiftLint/issues/3365
|
||||
args = args.map { $0.replacingOccurrences(of: "\\=", with: "=") }
|
||||
args = args.map { $0.replacingOccurrences(of: "\\ ", with: " ") }
|
||||
args.append(contentsOf: ["-D", "DEBUG"])
|
||||
var shouldContinueToFilterArguments = true
|
||||
while shouldContinueToFilterArguments {
|
||||
(args, shouldContinueToFilterArguments) = partiallyFilter(arguments: args)
|
||||
}
|
||||
|
||||
return args.filter {
|
||||
![
|
||||
"-parseable-output",
|
||||
"-incremental",
|
||||
"-serialize-diagnostics",
|
||||
"-emit-dependencies",
|
||||
"-use-frontend-parseable-output"
|
||||
].contains($0)
|
||||
}.map {
|
||||
if $0 == "-O" {
|
||||
return "-Onone"
|
||||
} else if $0 == "-DNDEBUG=1" {
|
||||
return "-DDEBUG=1"
|
||||
}
|
||||
return $0
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Sources/Cargo/swiftlint/Helpers/ExitHelper.swift
Normal file
12
Sources/Cargo/swiftlint/Helpers/ExitHelper.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
#if os(Linux)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
enum ExitHelper {
|
||||
static func successfullyExit() {
|
||||
#if os(Linux)
|
||||
// Workaround for https://github.com/apple/swift/issues/59961
|
||||
Glibc.exit(0)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
63
Sources/Cargo/swiftlint/Helpers/LintOrAnalyzeArguments.swift
Normal file
63
Sources/Cargo/swiftlint/Helpers/LintOrAnalyzeArguments.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import ArgumentParser
|
||||
|
||||
enum LeniencyOptions: String, EnumerableFlag {
|
||||
case strict, lenient
|
||||
|
||||
static func help(for value: LeniencyOptions) -> ArgumentHelp? {
|
||||
switch value {
|
||||
case .strict:
|
||||
return "Upgrades warnings to serious violations (errors)."
|
||||
case .lenient:
|
||||
return "Downgrades serious violations to warnings, warning threshold is disabled."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Common Arguments
|
||||
|
||||
struct LintOrAnalyzeArguments: ParsableArguments {
|
||||
@Option(help: "The path to one or more SwiftLint configuration files, evaluated as a parent-child hierarchy.")
|
||||
var config = [String]()
|
||||
@Flag(name: [.long, .customLong("autocorrect")], help: "Correct violations whenever possible.")
|
||||
var fix = false
|
||||
@Flag(help: """
|
||||
Should reformat the Swift files using the same mechanism used by Xcode (via SourceKit).
|
||||
Only applied with `--fix`/`--autocorrect`.
|
||||
""")
|
||||
var format = false
|
||||
@Flag(help: "Use an alternative algorithm to exclude paths for `excluded`, which may be faster in some cases.")
|
||||
var useAlternativeExcluding = false
|
||||
@Flag(help: "Read SCRIPT_INPUT_FILE* environment variables as files.")
|
||||
var useScriptInputFiles = false
|
||||
@Flag(exclusivity: .exclusive)
|
||||
var leniency: LeniencyOptions?
|
||||
@Flag(help: "Exclude files in config `excluded` even if their paths are explicitly specified.")
|
||||
var forceExclude = false
|
||||
@Flag(help: "Save benchmarks to `benchmark_files.txt` and `benchmark_rules.txt`.")
|
||||
var benchmark = false
|
||||
@Option(help: "The reporter used to log errors and warnings.")
|
||||
var reporter: String?
|
||||
@Flag(help: "Use the in-process version of SourceKit.")
|
||||
var inProcessSourcekit = false
|
||||
@Option(help: "The file where violations should be saved. Prints to stdout by default.")
|
||||
var output: String?
|
||||
@Flag(help: "Show a live-updating progress bar instead of each file being processed.")
|
||||
var progress = false
|
||||
}
|
||||
|
||||
// MARK: - Common Argument Help
|
||||
|
||||
// It'd be great to be able to parameterize an `@OptionGroup` so we could move these options into
|
||||
// `LintOrAnalyzeArguments`.
|
||||
|
||||
func pathOptionDescription(for mode: LintOrAnalyzeMode) -> ArgumentHelp {
|
||||
ArgumentHelp(visibility: .hidden)
|
||||
}
|
||||
|
||||
func pathsArgumentDescription(for mode: LintOrAnalyzeMode) -> ArgumentHelp {
|
||||
"List of paths to the files or directories to \(mode.imperative)."
|
||||
}
|
||||
|
||||
func quietOptionDescription(for mode: LintOrAnalyzeMode) -> ArgumentHelp {
|
||||
"Don't print status logs like '\(mode.verb.capitalized) <file>' & 'Done \(mode.verb)'."
|
||||
}
|
||||
342
Sources/Cargo/swiftlint/Helpers/LintOrAnalyzeCommand.swift
Normal file
342
Sources/Cargo/swiftlint/Helpers/LintOrAnalyzeCommand.swift
Normal file
@@ -0,0 +1,342 @@
|
||||
import Dispatch
|
||||
import Foundation
|
||||
@_spi(TestHelper)
|
||||
import SwiftLintFramework
|
||||
|
||||
enum LintOrAnalyzeMode {
|
||||
case lint, analyze
|
||||
|
||||
var imperative: String {
|
||||
switch self {
|
||||
case .lint:
|
||||
return "lint"
|
||||
case .analyze:
|
||||
return "analyze"
|
||||
}
|
||||
}
|
||||
|
||||
var verb: String {
|
||||
switch self {
|
||||
case .lint:
|
||||
return "linting"
|
||||
case .analyze:
|
||||
return "analyzing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LintOrAnalyzeCommand {
|
||||
static func run(_ options: LintOrAnalyzeOptions) async throws {
|
||||
if options.inProcessSourcekit {
|
||||
queuedPrintError(
|
||||
"""
|
||||
warning: The --in-process-sourcekit option is deprecated. \
|
||||
SwiftLint now always uses an in-process SourceKit.
|
||||
"""
|
||||
)
|
||||
}
|
||||
try await Signposts.record(name: "LintOrAnalyzeCommand.run") {
|
||||
try await options.autocorrect ? autocorrect(options) : lintOrAnalyze(options)
|
||||
}
|
||||
ExitHelper.successfullyExit()
|
||||
}
|
||||
|
||||
private static func lintOrAnalyze(_ options: LintOrAnalyzeOptions) async throws {
|
||||
let builder = LintOrAnalyzeResultBuilder(options)
|
||||
let files = try await collectViolations(builder: builder)
|
||||
try Signposts.record(name: "LintOrAnalyzeCommand.PostProcessViolations") {
|
||||
try postProcessViolations(files: files, builder: builder)
|
||||
}
|
||||
}
|
||||
|
||||
private static func collectViolations(builder: LintOrAnalyzeResultBuilder) async throws -> [SwiftLintFile] {
|
||||
let options = builder.options
|
||||
let visitorMutationQueue = DispatchQueue(label: "io.realm.swiftlint.lintVisitorMutation")
|
||||
return try await builder.configuration.visitLintableFiles(options: options, cache: builder.cache,
|
||||
storage: builder.storage) { linter in
|
||||
let currentViolations: [StyleViolation]
|
||||
if options.benchmark {
|
||||
CustomRuleTimer.shared.activate()
|
||||
let start = Date()
|
||||
let (violationsBeforeLeniency, currentRuleTimes) = linter
|
||||
.styleViolationsAndRuleTimes(using: builder.storage)
|
||||
currentViolations = applyLeniency(options: options, violations: violationsBeforeLeniency)
|
||||
visitorMutationQueue.sync {
|
||||
builder.fileBenchmark.record(file: linter.file, from: start)
|
||||
currentRuleTimes.forEach { builder.ruleBenchmark.record(id: $0, time: $1) }
|
||||
builder.violations += currentViolations
|
||||
}
|
||||
} else {
|
||||
currentViolations = applyLeniency(options: options,
|
||||
violations: linter.styleViolations(using: builder.storage))
|
||||
visitorMutationQueue.sync {
|
||||
builder.violations += currentViolations
|
||||
}
|
||||
}
|
||||
linter.file.invalidateCache()
|
||||
builder.report(violations: currentViolations, realtimeCondition: true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func postProcessViolations(files: [SwiftLintFile], builder: LintOrAnalyzeResultBuilder) throws {
|
||||
let options = builder.options
|
||||
let configuration = builder.configuration
|
||||
if isWarningThresholdBroken(configuration: configuration, violations: builder.violations)
|
||||
&& !options.lenient {
|
||||
builder.violations.append(
|
||||
createThresholdViolation(threshold: configuration.warningThreshold!)
|
||||
)
|
||||
builder.report(violations: [builder.violations.last!], realtimeCondition: true)
|
||||
}
|
||||
builder.report(violations: builder.violations, realtimeCondition: false)
|
||||
let numberOfSeriousViolations = builder.violations.filter({ $0.severity == .error }).count
|
||||
if !options.quiet {
|
||||
printStatus(violations: builder.violations, files: files, serious: numberOfSeriousViolations,
|
||||
verb: options.verb)
|
||||
}
|
||||
if options.benchmark {
|
||||
builder.fileBenchmark.save()
|
||||
for (id, time) in CustomRuleTimer.shared.dump() {
|
||||
builder.ruleBenchmark.record(id: id, time: time)
|
||||
}
|
||||
builder.ruleBenchmark.save()
|
||||
if !options.quiet, let memoryUsage = memoryUsage() {
|
||||
queuedPrintError(memoryUsage)
|
||||
}
|
||||
}
|
||||
try builder.cache?.save()
|
||||
guard numberOfSeriousViolations == 0 else { exit(2) }
|
||||
}
|
||||
|
||||
private static func printStatus(violations: [StyleViolation], files: [SwiftLintFile], serious: Int, verb: String) {
|
||||
let pluralSuffix = { (collection: [Any]) -> String in
|
||||
return collection.count != 1 ? "s" : ""
|
||||
}
|
||||
queuedPrintError(
|
||||
"Done \(verb)! Found \(violations.count) violation\(pluralSuffix(violations)), " +
|
||||
"\(serious) serious in \(files.count) file\(pluralSuffix(files))."
|
||||
)
|
||||
}
|
||||
|
||||
private static func isWarningThresholdBroken(configuration: Configuration,
|
||||
violations: [StyleViolation]) -> Bool {
|
||||
guard let warningThreshold = configuration.warningThreshold else { return false }
|
||||
let numberOfWarningViolations = violations.filter({ $0.severity == .warning }).count
|
||||
return numberOfWarningViolations >= warningThreshold
|
||||
}
|
||||
|
||||
private static func createThresholdViolation(threshold: Int) -> StyleViolation {
|
||||
let description = RuleDescription(
|
||||
identifier: "warning_threshold",
|
||||
name: "Warning Threshold",
|
||||
description: "Number of warnings thrown is above the threshold",
|
||||
kind: .lint
|
||||
)
|
||||
return StyleViolation(
|
||||
ruleDescription: description,
|
||||
severity: .error,
|
||||
location: Location(file: "", line: 0, character: 0),
|
||||
reason: "Number of warnings exceeded threshold of \(threshold).")
|
||||
}
|
||||
|
||||
private static func applyLeniency(options: LintOrAnalyzeOptions, violations: [StyleViolation]) -> [StyleViolation] {
|
||||
switch (options.lenient, options.strict) {
|
||||
case (false, false):
|
||||
return violations
|
||||
|
||||
case (true, false):
|
||||
return violations.map {
|
||||
if $0.severity == .error {
|
||||
return $0.with(severity: .warning)
|
||||
} else {
|
||||
return $0
|
||||
}
|
||||
}
|
||||
|
||||
case (false, true):
|
||||
return violations.map {
|
||||
if $0.severity == .warning {
|
||||
return $0.with(severity: .error)
|
||||
} else {
|
||||
return $0
|
||||
}
|
||||
}
|
||||
|
||||
case (true, true):
|
||||
queuedFatalError("Invalid command line options: 'lenient' and 'strict' are mutually exclusive.")
|
||||
}
|
||||
}
|
||||
|
||||
private static func autocorrect(_ options: LintOrAnalyzeOptions) async throws {
|
||||
let storage = RuleStorage()
|
||||
let configuration = Configuration(options: options)
|
||||
let correctionsBuilder = CorrectionsBuilder()
|
||||
let files = try await configuration
|
||||
.visitLintableFiles(options: options, cache: nil, storage: storage) { linter in
|
||||
if options.format {
|
||||
switch configuration.indentation {
|
||||
case .tabs:
|
||||
linter.format(useTabs: true, indentWidth: 4)
|
||||
case .spaces(let count):
|
||||
linter.format(useTabs: false, indentWidth: count)
|
||||
}
|
||||
}
|
||||
|
||||
let corrections = linter.correct(using: storage)
|
||||
if !corrections.isEmpty && !options.quiet {
|
||||
if options.useSTDIN {
|
||||
queuedPrint(linter.file.contents)
|
||||
} else {
|
||||
if options.progress {
|
||||
await correctionsBuilder.append(corrections)
|
||||
} else {
|
||||
let correctionLogs = corrections.map(\.consoleDescription)
|
||||
queuedPrint(correctionLogs.joined(separator: "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !options.quiet {
|
||||
if options.progress {
|
||||
let corrections = await correctionsBuilder.corrections
|
||||
if !corrections.isEmpty {
|
||||
let correctionLogs = corrections.map(\.consoleDescription)
|
||||
options.writeToOutput(correctionLogs.joined(separator: "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
let pluralSuffix = { (collection: [Any]) -> String in
|
||||
return collection.count != 1 ? "s" : ""
|
||||
}
|
||||
queuedPrintError("Done correcting \(files.count) file\(pluralSuffix(files))!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LintOrAnalyzeOptions {
|
||||
let mode: LintOrAnalyzeMode
|
||||
let paths: [String]
|
||||
let useSTDIN: Bool
|
||||
let configurationFiles: [String]
|
||||
let strict: Bool
|
||||
let lenient: Bool
|
||||
let forceExclude: Bool
|
||||
let useExcludingByPrefix: Bool
|
||||
let useScriptInputFiles: Bool
|
||||
let benchmark: Bool
|
||||
let reporter: String?
|
||||
let quiet: Bool
|
||||
let output: String?
|
||||
let progress: Bool
|
||||
let cachePath: String?
|
||||
let ignoreCache: Bool
|
||||
let enableAllRules: Bool
|
||||
let autocorrect: Bool
|
||||
let format: Bool
|
||||
let compilerLogPath: String?
|
||||
let compileCommands: String?
|
||||
let inProcessSourcekit: Bool
|
||||
|
||||
var verb: String {
|
||||
if autocorrect {
|
||||
return "correcting"
|
||||
} else {
|
||||
return mode.verb
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LintOrAnalyzeResultBuilder {
|
||||
var fileBenchmark = Benchmark(name: "files")
|
||||
var ruleBenchmark = Benchmark(name: "rules")
|
||||
var violations = [StyleViolation]()
|
||||
let storage = RuleStorage()
|
||||
let configuration: Configuration
|
||||
let reporter: Reporter.Type
|
||||
let cache: LinterCache?
|
||||
let options: LintOrAnalyzeOptions
|
||||
|
||||
init(_ options: LintOrAnalyzeOptions) {
|
||||
let config = Signposts.record(name: "LintOrAnalyzeCommand.ParseConfiguration") {
|
||||
Configuration(options: options)
|
||||
}
|
||||
configuration = config
|
||||
reporter = reporterFrom(identifier: options.reporter ?? config.reporter)
|
||||
if options.ignoreCache || ProcessInfo.processInfo.isLikelyXcodeCloudEnvironment {
|
||||
cache = nil
|
||||
} else {
|
||||
cache = LinterCache(configuration: config)
|
||||
}
|
||||
self.options = options
|
||||
|
||||
if let outFile = options.output {
|
||||
do {
|
||||
try Data().write(to: URL(fileURLWithPath: outFile))
|
||||
} catch {
|
||||
queuedPrintError("Could not write to file at path \(outFile)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func report(violations: [StyleViolation], realtimeCondition: Bool) {
|
||||
if (reporter.isRealtime && (!options.progress || options.output != nil)) == realtimeCondition {
|
||||
let report = reporter.generateReport(violations)
|
||||
if !report.isEmpty {
|
||||
options.writeToOutput(report)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension LintOrAnalyzeOptions {
|
||||
func writeToOutput(_ string: String) {
|
||||
guard let outFile = output else {
|
||||
queuedPrint(string)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let outFileURL = URL(fileURLWithPath: outFile)
|
||||
let fileUpdater = try FileHandle(forUpdating: outFileURL)
|
||||
fileUpdater.seekToEndOfFile()
|
||||
fileUpdater.write(Data((string + "\n").utf8))
|
||||
fileUpdater.closeFile()
|
||||
} catch {
|
||||
queuedPrintError("Could not write to file at path \(outFile)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor CorrectionsBuilder {
|
||||
private(set) var corrections: [Correction] = []
|
||||
|
||||
func append(_ corrections: [Correction]) {
|
||||
self.corrections.append(contentsOf: corrections)
|
||||
}
|
||||
}
|
||||
|
||||
private func memoryUsage() -> String? {
|
||||
#if os(Linux)
|
||||
return nil
|
||||
#else
|
||||
var info = mach_task_basic_info()
|
||||
let basicInfoCount = MemoryLayout<mach_task_basic_info>.stride / MemoryLayout<natural_t>.stride
|
||||
var count = mach_msg_type_number_t(basicInfoCount)
|
||||
|
||||
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
|
||||
$0.withMemoryRebound(to: integer_t.self, capacity: basicInfoCount) {
|
||||
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
|
||||
}
|
||||
}
|
||||
|
||||
if kerr == KERN_SUCCESS {
|
||||
let bytes = Measurement<UnitInformationStorage>(value: Double(info.resident_size), unit: .bytes)
|
||||
let formatted = ByteCountFormatter().string(from: bytes)
|
||||
return "Memory used: \(formatted)"
|
||||
} else {
|
||||
let errorMessage = String(cString: mach_error_string(kerr), encoding: .ascii)
|
||||
return "Error with task_info(): \(errorMessage ?? "unknown")"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
253
Sources/Cargo/swiftlint/Helpers/LintableFilesVisitor.swift
Normal file
253
Sources/Cargo/swiftlint/Helpers/LintableFilesVisitor.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import SourceKittenFramework
|
||||
import SwiftLintFramework
|
||||
|
||||
typealias File = String
|
||||
typealias Arguments = [String]
|
||||
|
||||
class CompilerInvocations {
|
||||
static func buildLog(compilerInvocations: [[String]]) -> CompilerInvocations {
|
||||
return ArrayCompilerInvocations(invocations: compilerInvocations)
|
||||
}
|
||||
|
||||
static func compilationDatabase(compileCommands: [File: Arguments]) -> CompilerInvocations {
|
||||
return CompilationDatabaseInvocations(compileCommands: compileCommands)
|
||||
}
|
||||
|
||||
/// Default implementation
|
||||
func arguments(forFile path: String?) -> Arguments { [] }
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private class ArrayCompilerInvocations: CompilerInvocations {
|
||||
private let invocationsByArgument: [String: [Arguments]]
|
||||
|
||||
init(invocations: [Arguments]) {
|
||||
// Store invocations by the path, so next when we'll be asked for arguments,
|
||||
// we'll be able to return them faster
|
||||
self.invocationsByArgument = invocations.reduce(into: [:]) { result, arguments in
|
||||
arguments.forEach { result[$0, default: []].append(arguments) }
|
||||
}
|
||||
}
|
||||
|
||||
override func arguments(forFile path: String?) -> Arguments {
|
||||
return path.flatMap { path in
|
||||
return invocationsByArgument[path]?.first
|
||||
} ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private class CompilationDatabaseInvocations: CompilerInvocations {
|
||||
private let compileCommands: [File: Arguments]
|
||||
|
||||
init(compileCommands: [File: Arguments]) {
|
||||
self.compileCommands = compileCommands
|
||||
}
|
||||
|
||||
override func arguments(forFile path: String?) -> Arguments {
|
||||
return path.flatMap { path in
|
||||
return compileCommands[path] ??
|
||||
compileCommands[path.path(relativeTo: FileManager.default.currentDirectoryPath)]
|
||||
} ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LintOrAnalyzeModeWithCompilerArguments {
|
||||
case lint
|
||||
case analyze(allCompilerInvocations: CompilerInvocations)
|
||||
}
|
||||
|
||||
private func resolveParamsFiles(args: [String]) -> [String] {
|
||||
return args.reduce(into: []) { (allArgs: inout [String], arg: String) -> Void in
|
||||
if arg.hasPrefix("@"), let contents = try? String(contentsOfFile: String(arg.dropFirst())) {
|
||||
allArgs.append(contentsOf: resolveParamsFiles(args: contents.split(separator: "\n").map(String.init)))
|
||||
} else {
|
||||
allArgs.append(arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LintableFilesVisitor {
|
||||
let paths: [String]
|
||||
let action: String
|
||||
let useSTDIN: Bool
|
||||
let quiet: Bool
|
||||
let showProgressBar: Bool
|
||||
let useScriptInputFiles: Bool
|
||||
let forceExclude: Bool
|
||||
let useExcludingByPrefix: Bool
|
||||
let cache: LinterCache?
|
||||
let parallel: Bool
|
||||
let allowZeroLintableFiles: Bool
|
||||
let mode: LintOrAnalyzeModeWithCompilerArguments
|
||||
let block: (CollectedLinter) async -> Void
|
||||
|
||||
private init(paths: [String], action: String, useSTDIN: Bool, quiet: Bool, showProgressBar: Bool,
|
||||
useScriptInputFiles: Bool, forceExclude: Bool, useExcludingByPrefix: Bool,
|
||||
cache: LinterCache?, compilerInvocations: CompilerInvocations?,
|
||||
allowZeroLintableFiles: Bool, block: @escaping (CollectedLinter) async -> Void) {
|
||||
self.paths = resolveParamsFiles(args: paths)
|
||||
self.action = action
|
||||
self.useSTDIN = useSTDIN
|
||||
self.quiet = quiet
|
||||
self.showProgressBar = showProgressBar
|
||||
self.useScriptInputFiles = useScriptInputFiles
|
||||
self.forceExclude = forceExclude
|
||||
self.useExcludingByPrefix = useExcludingByPrefix
|
||||
self.cache = cache
|
||||
if let compilerInvocations {
|
||||
self.mode = .analyze(allCompilerInvocations: compilerInvocations)
|
||||
// SourceKit had some changes in 5.6 that makes it ~100x more expensive
|
||||
// to process files concurrently. By processing files serially, it's
|
||||
// only 2x slower than before.
|
||||
self.parallel = SwiftVersion.current < .fiveDotSix
|
||||
} else {
|
||||
self.mode = .lint
|
||||
self.parallel = true
|
||||
}
|
||||
self.block = block
|
||||
self.allowZeroLintableFiles = allowZeroLintableFiles
|
||||
}
|
||||
|
||||
static func create(_ options: LintOrAnalyzeOptions,
|
||||
cache: LinterCache?,
|
||||
allowZeroLintableFiles: Bool,
|
||||
block: @escaping (CollectedLinter) async -> Void)
|
||||
throws -> LintableFilesVisitor {
|
||||
try Signposts.record(name: "LintableFilesVisitor.Create") {
|
||||
let compilerInvocations: CompilerInvocations?
|
||||
if options.mode == .lint {
|
||||
compilerInvocations = nil
|
||||
} else {
|
||||
compilerInvocations = try loadCompilerInvocations(options)
|
||||
}
|
||||
|
||||
return LintableFilesVisitor(
|
||||
paths: options.paths, action: options.verb.bridge().capitalized,
|
||||
useSTDIN: options.useSTDIN, quiet: options.quiet,
|
||||
showProgressBar: options.progress,
|
||||
useScriptInputFiles: options.useScriptInputFiles,
|
||||
forceExclude: options.forceExclude,
|
||||
useExcludingByPrefix: options.useExcludingByPrefix,
|
||||
cache: cache,
|
||||
compilerInvocations: compilerInvocations,
|
||||
allowZeroLintableFiles: allowZeroLintableFiles, block: block
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSkipFile(atPath path: String?) -> Bool {
|
||||
switch self.mode {
|
||||
case .lint:
|
||||
return false
|
||||
case let .analyze(compilerInvocations):
|
||||
let compilerArguments = compilerInvocations.arguments(forFile: path)
|
||||
return compilerArguments.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func linter(forFile file: SwiftLintFile, configuration: Configuration) -> Linter {
|
||||
switch self.mode {
|
||||
case .lint:
|
||||
return Linter(file: file, configuration: configuration, cache: cache)
|
||||
case let .analyze(compilerInvocations):
|
||||
let compilerArguments = compilerInvocations.arguments(forFile: file.path)
|
||||
return Linter(file: file, configuration: configuration, compilerArguments: compilerArguments)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadCompilerInvocations(_ options: LintOrAnalyzeOptions) throws -> CompilerInvocations {
|
||||
if let path = options.compilerLogPath {
|
||||
guard let compilerInvocations = self.loadLogCompilerInvocations(path) else {
|
||||
throw SwiftLintError.usageError(description: "Could not read compiler log at path: '\(path)'")
|
||||
}
|
||||
|
||||
return .buildLog(compilerInvocations: compilerInvocations)
|
||||
} else if let path = options.compileCommands {
|
||||
do {
|
||||
return .compilationDatabase(compileCommands: try self.loadCompileCommands(path))
|
||||
} catch {
|
||||
throw SwiftLintError.usageError(
|
||||
description: "Could not read compilation database at path: '\(path)' \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
throw SwiftLintError.usageError(description: "Could not read compiler invocations")
|
||||
}
|
||||
|
||||
private static func loadLogCompilerInvocations(_ path: String) -> [[String]]? {
|
||||
if let data = FileManager.default.contents(atPath: path),
|
||||
let logContents = String(data: data, encoding: .utf8) {
|
||||
if logContents.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CompilerArgumentsExtractor.allCompilerInvocations(compilerLogs: logContents)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func loadCompileCommands(_ path: String) throws -> [File: Arguments] {
|
||||
guard let fileContents = FileManager.default.contents(atPath: path) else {
|
||||
throw CompileCommandsLoadError.nonExistentFile(path)
|
||||
}
|
||||
|
||||
if path.hasSuffix(".yaml") || path.hasSuffix(".yml") {
|
||||
// Assume this is a SwiftPM yaml file
|
||||
return try SwiftPMCompilationDB.parse(yaml: fileContents)
|
||||
}
|
||||
|
||||
guard let object = try? JSONSerialization.jsonObject(with: fileContents),
|
||||
let compileDB = object as? [[String: Any]] else {
|
||||
throw CompileCommandsLoadError.malformedCommands(path)
|
||||
}
|
||||
|
||||
// Convert the compilation database to a dictionary, with source files as keys and compiler arguments as values.
|
||||
//
|
||||
// Compilation databases are an array of dictionaries. Each dict has "file" and "arguments" keys.
|
||||
var commands = [File: Arguments]()
|
||||
for (index, entry) in compileDB.enumerated() {
|
||||
guard let file = entry["file"] as? String else {
|
||||
throw CompileCommandsLoadError.malformedFile(path, index)
|
||||
}
|
||||
|
||||
guard let arguments = entry["arguments"] as? [String] else {
|
||||
throw CompileCommandsLoadError.malformedArguments(path, index)
|
||||
}
|
||||
|
||||
guard arguments.contains(file) else {
|
||||
throw CompileCommandsLoadError.missingFileInArguments(path, index, arguments)
|
||||
}
|
||||
|
||||
commands[file] = arguments.filteringCompilerArguments
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
}
|
||||
|
||||
private enum CompileCommandsLoadError: LocalizedError {
|
||||
case nonExistentFile(String)
|
||||
case malformedCommands(String)
|
||||
case malformedFile(String, Int)
|
||||
case malformedArguments(String, Int)
|
||||
case missingFileInArguments(String, Int, [String])
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .nonExistentFile(path):
|
||||
return "Could not read compile commands file at '\(path)'"
|
||||
case let .malformedCommands(path):
|
||||
return "Compile commands file at '\(path)' isn't in the correct format"
|
||||
case let .malformedFile(path, index):
|
||||
return "Missing or invalid (must be a string) 'file' key in \(path) at index \(index)"
|
||||
case let .malformedArguments(path, index):
|
||||
return "Missing or invalid (must be an array of strings) 'arguments' key in \(path) at index \(index)"
|
||||
case let .missingFileInArguments(path, index, arguments):
|
||||
return "Entry in \(path) at index \(index) has 'arguments' which do not contain the 'file': \(arguments)"
|
||||
}
|
||||
}
|
||||
}
|
||||
63
Sources/Cargo/swiftlint/Helpers/ProgressBar.swift
Normal file
63
Sources/Cargo/swiftlint/Helpers/ProgressBar.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import Dispatch
|
||||
import Foundation
|
||||
import SwiftLintFramework
|
||||
|
||||
// Inspired by https://github.com/jkandzi/Progress.swift
|
||||
actor ProgressBar {
|
||||
private var index = 1
|
||||
private var lastPrintedTime: TimeInterval = 0.0
|
||||
private let startTime = uptime()
|
||||
private let count: Int
|
||||
|
||||
init(count: Int) {
|
||||
self.count = count
|
||||
}
|
||||
|
||||
func initialize() {
|
||||
// When progress is printed, the previous line is reset, so print an empty line before anything else
|
||||
queuedPrintError("")
|
||||
}
|
||||
|
||||
func printNext() {
|
||||
guard index <= count else { return }
|
||||
|
||||
let currentTime = uptime()
|
||||
if currentTime - lastPrintedTime > 0.1 || index == count {
|
||||
let lineReset = "\u{1B}[1A\u{1B}[K"
|
||||
let bar = makeBar()
|
||||
let timeEstimate = makeTimeEstimate(currentTime: currentTime)
|
||||
let lineContents = "\(index) of \(count) \(bar) \(timeEstimate)"
|
||||
queuedPrintError("\(lineReset)\(lineContents)")
|
||||
lastPrintedTime = currentTime
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func makeBar() -> String {
|
||||
let barLength = 30
|
||||
let completedBarElements = Int(Double(barLength) * (Double(index) / Double(count)))
|
||||
let barArray = Array(repeating: "=", count: completedBarElements) +
|
||||
Array(repeating: " ", count: barLength - completedBarElements)
|
||||
return "[\(barArray.joined())]"
|
||||
}
|
||||
|
||||
private func makeTimeEstimate(currentTime: TimeInterval) -> String {
|
||||
let totalTime = currentTime - startTime
|
||||
let itemsPerSecond = Double(index) / totalTime
|
||||
let estimatedTimeRemaining = Double(count - index) / itemsPerSecond
|
||||
let estimatedTimeRemainingString = "\(Int(estimatedTimeRemaining))s"
|
||||
return "ETA: \(estimatedTimeRemainingString) (\(Int(itemsPerSecond)) files/s)"
|
||||
}
|
||||
}
|
||||
|
||||
#if os(Linux)
|
||||
// swiftlint:disable:next identifier_name
|
||||
private let NSEC_PER_SEC = 1_000_000_000
|
||||
#endif
|
||||
|
||||
private func uptime() -> TimeInterval {
|
||||
Double(DispatchTime.now().uptimeNanoseconds) / Double(NSEC_PER_SEC)
|
||||
}
|
||||
49
Sources/Cargo/swiftlint/Helpers/RulesFilter.swift
Normal file
49
Sources/Cargo/swiftlint/Helpers/RulesFilter.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
@_spi(TestHelper)
|
||||
import SwiftLintFramework
|
||||
|
||||
extension RulesFilter {
|
||||
struct ExcludingOptions: OptionSet {
|
||||
let rawValue: Int
|
||||
|
||||
static let enabled = Self(rawValue: 1 << 0)
|
||||
static let disabled = Self(rawValue: 1 << 1)
|
||||
static let uncorrectable = Self(rawValue: 1 << 2)
|
||||
}
|
||||
}
|
||||
|
||||
class RulesFilter {
|
||||
private let allRules: RuleList
|
||||
private let enabledRules: [Rule]
|
||||
|
||||
init(allRules: RuleList = primaryRuleList, enabledRules: [Rule]) {
|
||||
self.allRules = allRules
|
||||
self.enabledRules = enabledRules
|
||||
}
|
||||
|
||||
func getRules(excluding excludingOptions: ExcludingOptions) -> RuleList {
|
||||
if excludingOptions.isEmpty {
|
||||
return allRules
|
||||
}
|
||||
|
||||
let filtered: [Rule.Type] = allRules.list.compactMap { ruleID, ruleType in
|
||||
let enabledRule = enabledRules.first { rule in
|
||||
type(of: rule).description.identifier == ruleID
|
||||
}
|
||||
let isRuleEnabled = enabledRule != nil
|
||||
|
||||
if excludingOptions.contains(.enabled) && isRuleEnabled {
|
||||
return nil
|
||||
}
|
||||
if excludingOptions.contains(.disabled) && !isRuleEnabled {
|
||||
return nil
|
||||
}
|
||||
if excludingOptions.contains(.uncorrectable) && !(ruleType is CorrectableRule.Type) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ruleType
|
||||
}
|
||||
|
||||
return RuleList(rules: filtered)
|
||||
}
|
||||
}
|
||||
73
Sources/Cargo/swiftlint/Helpers/Signposts.swift
Normal file
73
Sources/Cargo/swiftlint/Helpers/Signposts.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
#if canImport(os)
|
||||
import os.signpost
|
||||
private let timelineLog = OSLog(subsystem: "io.realm.swiftlint", category: "Timeline")
|
||||
private let fileLog = OSLog(subsystem: "io.realm.swiftlint", category: "File")
|
||||
#endif
|
||||
|
||||
struct Signposts {
|
||||
enum Span {
|
||||
case timeline, file(String)
|
||||
}
|
||||
|
||||
static func record<R>(name: StaticString, span: Span = .timeline, body: () throws -> R) rethrows -> R {
|
||||
#if canImport(os)
|
||||
let log: OSLog
|
||||
let description: String?
|
||||
switch span {
|
||||
case .timeline:
|
||||
log = timelineLog
|
||||
description = nil
|
||||
case .file(let file):
|
||||
log = fileLog
|
||||
description = file
|
||||
}
|
||||
let signpostID = OSSignpostID(log: log)
|
||||
if let description {
|
||||
os_signpost(.begin, log: log, name: name, signpostID: signpostID, "%{public}s", description)
|
||||
} else {
|
||||
os_signpost(.begin, log: log, name: name, signpostID: signpostID)
|
||||
}
|
||||
|
||||
let result = try body()
|
||||
if let description {
|
||||
os_signpost(.end, log: log, name: name, signpostID: signpostID, "%{public}s", description)
|
||||
} else {
|
||||
os_signpost(.end, log: log, name: name, signpostID: signpostID)
|
||||
}
|
||||
return result
|
||||
#else
|
||||
return try body()
|
||||
#endif
|
||||
}
|
||||
|
||||
static func record<R>(name: StaticString, span: Span = .timeline, body: () async throws -> R) async rethrows -> R {
|
||||
#if canImport(os)
|
||||
let log: OSLog
|
||||
let description: String?
|
||||
switch span {
|
||||
case .timeline:
|
||||
log = timelineLog
|
||||
description = nil
|
||||
case .file(let file):
|
||||
log = fileLog
|
||||
description = file
|
||||
}
|
||||
let signpostID = OSSignpostID(log: log)
|
||||
if let description {
|
||||
os_signpost(.begin, log: log, name: name, signpostID: signpostID, "%{public}s", description)
|
||||
} else {
|
||||
os_signpost(.begin, log: log, name: name, signpostID: signpostID)
|
||||
}
|
||||
|
||||
let result = try await body()
|
||||
if let description {
|
||||
os_signpost(.end, log: log, name: name, signpostID: signpostID, "%{public}s", description)
|
||||
} else {
|
||||
os_signpost(.end, log: log, name: name, signpostID: signpostID)
|
||||
}
|
||||
return result
|
||||
#else
|
||||
return try await body()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
12
Sources/Cargo/swiftlint/Helpers/SwiftLintError.swift
Normal file
12
Sources/Cargo/swiftlint/Helpers/SwiftLintError.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum SwiftLintError: LocalizedError {
|
||||
case usageError(description: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .usageError(let description):
|
||||
return description
|
||||
}
|
||||
}
|
||||
}
|
||||
74
Sources/Cargo/swiftlint/Helpers/SwiftPMCompilationDB.swift
Normal file
74
Sources/Cargo/swiftlint/Helpers/SwiftPMCompilationDB.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
import Yams
|
||||
|
||||
private struct SwiftPMCommand: Codable {
|
||||
let tool: String
|
||||
let module: String?
|
||||
let sources: [String]?
|
||||
let args: [String]?
|
||||
let importPaths: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case tool
|
||||
case module = "module-name"
|
||||
case sources
|
||||
case args = "other-args"
|
||||
case importPaths = "import-paths"
|
||||
}
|
||||
}
|
||||
|
||||
private struct SwiftPMNode: Codable {}
|
||||
|
||||
private struct SwiftPMNodes: Codable {
|
||||
let nodes: [String: SwiftPMNode]
|
||||
}
|
||||
|
||||
struct SwiftPMCompilationDB: Codable {
|
||||
private let commands: [String: SwiftPMCommand]
|
||||
|
||||
static func parse(yaml: Data) throws -> [File: Arguments] {
|
||||
let decoder = YAMLDecoder()
|
||||
let compilationDB: SwiftPMCompilationDB
|
||||
|
||||
if ProcessInfo.processInfo.environment["TEST_SRCDIR"] != nil {
|
||||
// Running tests
|
||||
let nodes = try decoder.decode(SwiftPMNodes.self, from: yaml)
|
||||
let suffix = "/Source/swiftlint/"
|
||||
let pathToReplace = Array(nodes.nodes.keys.filter({ node in
|
||||
node.hasSuffix(suffix)
|
||||
}))[0].dropLast(suffix.count - 1)
|
||||
let stringFileContents = String(data: yaml, encoding: .utf8)!
|
||||
.replacingOccurrences(of: pathToReplace, with: "")
|
||||
compilationDB = try decoder.decode(Self.self, from: stringFileContents)
|
||||
} else {
|
||||
compilationDB = try decoder.decode(Self.self, from: yaml)
|
||||
}
|
||||
|
||||
let swiftCompilerCommands = compilationDB.commands
|
||||
.filter { $0.value.tool == "swift-compiler" }
|
||||
let allSwiftSources = swiftCompilerCommands
|
||||
.flatMap { $0.value.sources ?? [] }
|
||||
.filter { $0.hasSuffix(".swift") }
|
||||
return Dictionary(uniqueKeysWithValues: allSwiftSources.map { swiftSource in
|
||||
let command = swiftCompilerCommands
|
||||
.values
|
||||
.first { $0.sources?.contains(swiftSource) == true }
|
||||
|
||||
guard let command,
|
||||
let module = command.module,
|
||||
let sources = command.sources,
|
||||
let arguments = command.args,
|
||||
let importPaths = command.importPaths
|
||||
else {
|
||||
return (swiftSource, [])
|
||||
}
|
||||
|
||||
let args = ["-module-name", module] +
|
||||
sources +
|
||||
arguments.filteringCompilerArguments +
|
||||
["-I"] + importPaths
|
||||
|
||||
return (swiftSource, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
59
Sources/Cargo/xcframework/BuildSetting.swift
Normal file
59
Sources/Cargo/xcframework/BuildSetting.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// BuildSettings.swift
|
||||
// Cargo
|
||||
//
|
||||
// Created by Joseph Mattiello on 02/28/23.
|
||||
// Copyright © 2023 Joseph Mattiello. All rights reserved.
|
||||
//
|
||||
|
||||
|
||||
//set -eu;
|
||||
//
|
||||
//BUILT_SRC="./em_proxy/$LIB_FILE_NAME.a"
|
||||
//ln -f -- "$BUILT_SRC" "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" || cp "$BUILT_SRC" "$TARGET_BUILD_DIR/$EXECUTABLE_PATH"
|
||||
//echo "$BUILT_SRC -> $TARGET_BUILD_DIR/$EXECUTABLE_PATH"
|
||||
|
||||
//# generated with cargo-xcode 1.5.0
|
||||
//# modified to use prebuilt binaries
|
||||
//
|
||||
//set -eu;
|
||||
//
|
||||
//BUILT_SRC="./minimuxer/$LIB_FILE_NAME.a"
|
||||
//ln -f -- "$BUILT_SRC" "$TARGET_BUILD_DIR/$EXECUTABLE_PATH" || cp "$BUILT_SRC" "$TARGET_BUILD_DIR/$EXECUTABLE_PATH"
|
||||
//echo "$BUILT_SRC -> $TARGET_BUILD_DIR/$EXECUTABLE_PATH"
|
||||
//
|
||||
//# xcode generates dep file, but for its own path, so append our rename to it
|
||||
// #DEP_FILE_SRC="minimuxer/target/${CARGO_XCODE_TARGET_TRIPLE}/release/${CARGO_XCODE_CARGO_DEP_FILE_NAME}"
|
||||
// #if [ -f "$DEP_FILE_SRC" ]; then
|
||||
//# DEP_FILE_DST="${DERIVED_FILE_DIR}/${CARGO_XCODE_TARGET_ARCH}-${EXECUTABLE_NAME}.d"
|
||||
//# cp -f "$DEP_FILE_SRC" "$DEP_FILE_DST"
|
||||
//# echo >> "$DEP_FILE_DST" "$SCRIPT_OUTPUT_FILE_0: $BUILT_SRC"
|
||||
//#fi
|
||||
//
|
||||
//# lipo script needs to know all the platform-specific files that have been built
|
||||
//# archs is in the file name, so that paths don't stay around after archs change
|
||||
//# must match input for LipoScript
|
||||
// #FILE_LIST="${DERIVED_FILE_DIR}/${ARCHS}-${EXECUTABLE_NAME}.xcfilelist"
|
||||
// #touch "$FILE_LIST"
|
||||
// #if ! egrep -q "$SCRIPT_OUTPUT_FILE_0" "$FILE_LIST" ; then
|
||||
//# echo >> "$FILE_LIST" "$SCRIPT_OUTPUT_FILE_0"
|
||||
//#fi
|
||||
|
||||
|
||||
import ArgumentParser
|
||||
|
||||
/// A representation of a build setting in an Xcode project, e.g.
|
||||
/// `IPHONEOS_DEPLOYMENT_TARGET=13.0`
|
||||
struct BuildSetting: ExpressibleByArgument {
|
||||
/// The name of the build setting, e.g. `IPHONEOS_DEPLOYMENT_TARGET`
|
||||
let name: String
|
||||
/// The value of the build setting
|
||||
let value: String
|
||||
|
||||
init?(argument: String) {
|
||||
let components = argument.components(separatedBy: "=")
|
||||
guard components.count == 2 else { return nil }
|
||||
name = components[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
value = components[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
71
Sources/Cargo/xcframework/Command+Options.swift
Normal file
71
Sources/Cargo/xcframework/Command+Options.swift
Normal file
@@ -0,0 +1,71 @@
|
||||
//
|
||||
// Command+Options.swift
|
||||
// Cargo
|
||||
//
|
||||
// Created by Joseph Mattiello on 02/28/23.
|
||||
// Copyright © 2023 Joseph Mattiello. All rights reserved.
|
||||
//
|
||||
|
||||
import ArgumentParser
|
||||
import PackageModel
|
||||
|
||||
extension Command {
|
||||
struct Options: ParsableArguments {
|
||||
// MARK: - Package Loading
|
||||
|
||||
@Option(help: ArgumentHelp("The location of the Package", valueName: "directory"))
|
||||
var packagePath = "."
|
||||
|
||||
// MARK: - Building
|
||||
|
||||
@Option(help: ArgumentHelp("The location of the build/cache directory to use", valueName: "directory"))
|
||||
var buildPath = ".build"
|
||||
|
||||
@Option(help: ArgumentHelp("Build with a specific configuration", valueName: "debug|release"))
|
||||
var configuration = PackageModel.BuildConfiguration.release
|
||||
|
||||
@Flag(inversion: .prefixedNo, help: "Whether to clean before we build")
|
||||
var clean = true
|
||||
|
||||
@Flag(inversion: .prefixedNo, help: "Whether to include debug symbols in the built XCFramework")
|
||||
var debugSymbols = true
|
||||
|
||||
@Flag(help: "Prints the available products and targets")
|
||||
var listProducts = false
|
||||
|
||||
@Option(help: "The path to a .xcconfig file that can be used to override Xcode build settings. Relative to the package path.")
|
||||
var xcconfig: String?
|
||||
|
||||
@Flag(help: "Enables Library Evolution for the whole build stack. Normally we apply it only to the targets listed to be built to work around issues with projects that don't support it.")
|
||||
var stackEvolution: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Arbitrary Xcode build settings that are passed directly to the `xcodebuild` invocation. Can be specified multiple times.", valueName: "NAME=VALUE"))
|
||||
var xcSetting: [BuildSetting] = []
|
||||
|
||||
// MARK: - Output Options
|
||||
|
||||
@Option(
|
||||
help: ArgumentHelp(
|
||||
"A list of platforms you want to build for. Can be specified multiple times."
|
||||
+ " Default is to build for all platforms supported in your Package.swift, or all Apple platforms (except for maccatalyst platform) if omitted",
|
||||
valueName: TargetPlatform.allCases.map { $0.rawValue }.joined(separator: "|")
|
||||
)
|
||||
)
|
||||
var platform: [TargetPlatform] = []
|
||||
|
||||
@Option(help: ArgumentHelp("Where to place the compiled library", valueName: "directory"))
|
||||
var output = "."
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var githubAction: Bool = false
|
||||
|
||||
// MARK: - Targets
|
||||
|
||||
@Argument(help: "An optional list of products (or targets) to build. Defaults to building all `.library` products")
|
||||
var products: [String] = []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ParsableArguments Extensions
|
||||
|
||||
extension PackageModel.BuildConfiguration: ExpressibleByArgument {}
|
||||
142
Sources/Cargo/xcframework/Command.swift
Normal file
142
Sources/Cargo/xcframework/Command.swift
Normal file
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// Command.swift
|
||||
// Cargo
|
||||
//
|
||||
// Created by Joseph Mattiello on 02/28/23.
|
||||
// Copyright © 2023 Joseph Mattiello. All rights reserved.
|
||||
//
|
||||
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import PackageLoading
|
||||
import PackageModel
|
||||
import TSCBasic
|
||||
import Workspace
|
||||
import Xcodeproj
|
||||
|
||||
struct Command: ParsableCommand {
|
||||
// MARK: - Configuration
|
||||
|
||||
static var configuration = CommandConfiguration(
|
||||
abstract: "Builds a `rust` package using `cargo`.",
|
||||
discussion:
|
||||
"""
|
||||
|
||||
""",
|
||||
version: "1.1.0"
|
||||
)
|
||||
|
||||
// MARK: - Arguments
|
||||
|
||||
@OptionGroup()
|
||||
var options: Options
|
||||
|
||||
// MARK: - Execution
|
||||
|
||||
// swiftlint:disable:next function_body_length
|
||||
func run() throws {
|
||||
// load all/validate of the package info
|
||||
let package = try PackageInfo(options: options)
|
||||
|
||||
// validate that package to make sure we can generate it
|
||||
let validation = package.validationErrors()
|
||||
if validation.isEmpty == false {
|
||||
for error in validation {
|
||||
print(error.isFatal ? "Error:" : "Warning:", error.errorDescription!)
|
||||
}
|
||||
if validation.contains(where: { $0.isFatal }) {
|
||||
Darwin.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// generate the Xcode project file
|
||||
let generator = ProjectGenerator(package: package)
|
||||
|
||||
let platforms = try package.supportedPlatforms()
|
||||
|
||||
// get what we're building
|
||||
try generator.writeDistributionXcconfig()
|
||||
let project = try generator.generate()
|
||||
|
||||
// printing packages?
|
||||
if options.listProducts {
|
||||
package.printAllProducts(project: project)
|
||||
Darwin.exit(0)
|
||||
}
|
||||
|
||||
// get valid packages and their SDKs
|
||||
let productNames = try package.validProductNames(project: project)
|
||||
let sdks = platforms.flatMap { $0.sdks }
|
||||
|
||||
// we've applied the xcconfig to everything, but some dependencies (*cough* swift-nio)
|
||||
// have build errors, so we remove it from targets we're not building
|
||||
if options.stackEvolution == false {
|
||||
try project.enableDistribution(targets: productNames, xcconfig: AbsolutePath(package.distributionBuildXcconfig.path).relative(to: AbsolutePath(package.rootDirectory.path)))
|
||||
}
|
||||
|
||||
// save the project
|
||||
try project.save(to: generator.projectPath)
|
||||
|
||||
// start building
|
||||
let builder = XcodeBuilder(project: project, projectPath: generator.projectPath, package: package, options: options)
|
||||
|
||||
// clean first
|
||||
if options.clean {
|
||||
try builder.clean()
|
||||
}
|
||||
|
||||
// all of our targets for each platform, then group the resulting .frameworks by target
|
||||
var frameworkFiles: [String: [XcodeBuilder.BuildResult]] = [:]
|
||||
|
||||
for sdk in sdks {
|
||||
try builder.build(targets: productNames, sdk: sdk)
|
||||
.forEach { pair in
|
||||
if frameworkFiles[pair.key] == nil {
|
||||
frameworkFiles[pair.key] = []
|
||||
}
|
||||
frameworkFiles[pair.key]?.append(pair.value)
|
||||
}
|
||||
}
|
||||
|
||||
var xcframeworkFiles: [(String, Foundation.URL)] = []
|
||||
|
||||
// then we merge the resulting frameworks
|
||||
try frameworkFiles
|
||||
.forEach { pair in
|
||||
xcframeworkFiles.append((pair.key, try builder.merge(target: pair.key, buildResults: pair.value)))
|
||||
}
|
||||
|
||||
// zip it up if thats what they want
|
||||
if options.zip {
|
||||
let zipper = Zipper(package: package)
|
||||
let zipped = try xcframeworkFiles
|
||||
.flatMap { pair -> [Foundation.URL] in
|
||||
let zip = try zipper.zip(target: pair.0, version: self.options.zipVersion, file: pair.1)
|
||||
let checksum = try zipper.checksum(file: zip)
|
||||
try zipper.clean(file: pair.1)
|
||||
|
||||
return [zip, checksum]
|
||||
}
|
||||
|
||||
// notify the action if we have one
|
||||
if options.githubAction {
|
||||
let zips = zipped.map { $0.path }.joined(separator: "\n")
|
||||
let data = Data(zips.utf8)
|
||||
let url = Foundation.URL(fileURLWithPath: options.buildPath).appendingPathComponent("xcframework-zipfile.url")
|
||||
try data.write(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
private enum Error: Swift.Error, LocalizedError {
|
||||
case noProducts
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noProducts: return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//
|
||||
// Collection-Extensions.swift
|
||||
// swift-create-xcframework
|
||||
//
|
||||
// Created by Rob Amos on 9/5/20.
|
||||
//
|
||||
|
||||
extension Collection {
|
||||
var nonEmpty: Self? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// PackageDescription+Extensions.swift
|
||||
// swift-create-xcframework
|
||||
//
|
||||
// Created by Rob Amos on 7/5/20.
|
||||
//
|
||||
|
||||
import PackageModel
|
||||
|
||||
extension ProductType {
|
||||
var isLibrary: Bool {
|
||||
if case .library = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
extension Manifest {
|
||||
var libraryProductNames: [String] {
|
||||
products
|
||||
.compactMap { product in
|
||||
guard product.type.isLibrary else { return nil }
|
||||
return product.name
|
||||
}
|
||||
}
|
||||
}
|
||||
113
Sources/Cargo/xcframework/Platforms.swift
Normal file
113
Sources/Cargo/xcframework/Platforms.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
//
|
||||
// Platforms.swift
|
||||
// Cargo
|
||||
//
|
||||
// Created by Joseph Mattiello on 02/28/23.
|
||||
// Copyright © 2023 Joseph Mattiello. All rights reserved.
|
||||
//
|
||||
|
||||
import ArgumentParser
|
||||
import PackageModel
|
||||
|
||||
enum TargetPlatform: String, ExpressibleByArgument, CaseIterable {
|
||||
case ios
|
||||
case macos
|
||||
case maccatalyst
|
||||
case tvos
|
||||
case watchos
|
||||
|
||||
init?(argument: String) {
|
||||
self.init(rawValue: argument.lowercased())
|
||||
}
|
||||
|
||||
var platformName: String {
|
||||
switch self {
|
||||
case .ios: "ios"
|
||||
case .macos: "macos"
|
||||
case .maccatalyst: "macos"
|
||||
case .tvos: "tvos"
|
||||
case .watchos: "watchos"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Target SDKs
|
||||
|
||||
struct SDK {
|
||||
let destination: String
|
||||
let archiveName: String
|
||||
let releaseFolder: String
|
||||
let buildSettings: [String: String]?
|
||||
}
|
||||
|
||||
var sdks: [SDK] {
|
||||
switch self {
|
||||
case .ios:
|
||||
return [
|
||||
SDK(
|
||||
destination: "generic/platform=iOS",
|
||||
archiveName: "iphoneos.xcarchive",
|
||||
releaseFolder: "Release-iphoneos",
|
||||
buildSettings: nil
|
||||
),
|
||||
SDK(
|
||||
destination: "generic/platform=iOS Simulator",
|
||||
archiveName: "iphonesimulator.xcarchive",
|
||||
releaseFolder: "Release-iphonesimulator",
|
||||
buildSettings: nil
|
||||
)
|
||||
]
|
||||
|
||||
case .macos:
|
||||
return [
|
||||
SDK(
|
||||
destination: "generic/platform=macOS,name=Any Mac",
|
||||
archiveName: "macos.xcarchive",
|
||||
releaseFolder: "Release",
|
||||
buildSettings: nil
|
||||
)
|
||||
]
|
||||
|
||||
case .maccatalyst:
|
||||
return [
|
||||
SDK(
|
||||
destination: "generic/platform=macOS,variant=Mac Catalyst",
|
||||
archiveName: "maccatalyst.xcarchive",
|
||||
releaseFolder: "Release-maccatalyst",
|
||||
buildSettings: ["SUPPORTS_MACCATALYST": "YES"]
|
||||
)
|
||||
]
|
||||
|
||||
case .tvos:
|
||||
return [
|
||||
SDK(
|
||||
destination: "generic/platform=tvOS",
|
||||
archiveName: "appletvos.xcarchive",
|
||||
releaseFolder: "Release-appletvos",
|
||||
buildSettings: nil
|
||||
),
|
||||
SDK(
|
||||
destination: "generic/platform=tvOS Simulator",
|
||||
archiveName: "appletvsimulator.xcarchive",
|
||||
releaseFolder: "Release-appletvsimulator",
|
||||
buildSettings: nil
|
||||
)
|
||||
]
|
||||
|
||||
case .watchos:
|
||||
return [
|
||||
SDK(
|
||||
destination: "generic/platform=watchOS",
|
||||
archiveName: "watchos.xcarchive",
|
||||
releaseFolder: "Release-watchos",
|
||||
buildSettings: nil
|
||||
),
|
||||
SDK(
|
||||
destination: "generic/platform=watchOS Simulator",
|
||||
archiveName: "watchsimulator.xcarchive",
|
||||
releaseFolder: "Release-watchsimulator",
|
||||
buildSettings: nil
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Sources/Cargo/xcframework/main.swift
Normal file
9
Sources/Cargo/xcframework/main.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
//
|
||||
// main.swift
|
||||
// Cargo
|
||||
//
|
||||
// Created by Joseph Mattiello on 02/28/23.
|
||||
// Copyright © 2023 Joseph Mattiello. All rights reserved.
|
||||
//
|
||||
|
||||
Command.main()
|
||||
19
Sources/EmotionalDamage/EmotionalDamage.swift
Normal file
19
Sources/EmotionalDamage/EmotionalDamage.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// EmotionalDamage.swift
|
||||
// EmotionalDamage
|
||||
//
|
||||
// Created by Jackson Coxson on 10/26/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import em_proxy
|
||||
|
||||
public func start_em_proxy(bind_addr: String) {
|
||||
let host = NSString(string: bind_addr)
|
||||
let host_pointer = UnsafeMutablePointer<CChar>(mutating: host.utf8String)
|
||||
_ = start_emotional_damage(host_pointer)
|
||||
}
|
||||
|
||||
public func stop_em_proxy() {
|
||||
stop_emotional_damage()
|
||||
}
|
||||
113
Sources/MiniMuxerSwift/MiniMuxer.swift
Normal file
113
Sources/MiniMuxerSwift/MiniMuxer.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
//
|
||||
// minimuxer.swift
|
||||
// minimuxer
|
||||
//
|
||||
// Created by Jackson Coxson on 10/27/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import minimuxer
|
||||
|
||||
public enum Uhoh: Error {
|
||||
case Good
|
||||
case Bad(code: Int32)
|
||||
}
|
||||
|
||||
public func start_minimuxer(pairing_file: String) -> Int32 {
|
||||
let pf = NSString(string: pairing_file)
|
||||
let pf_pointer = UnsafeMutablePointer<CChar>(mutating: pf.utf8String)
|
||||
let u = NSString(string: getDocumentsDirectory().absoluteString)
|
||||
let u_ptr = UnsafeMutablePointer<CChar>(mutating: u.utf8String)
|
||||
return minimuxer_c_start(pf_pointer, u_ptr)
|
||||
}
|
||||
|
||||
public func set_usbmuxd_socket() {
|
||||
target_minimuxer_address()
|
||||
}
|
||||
|
||||
public func debug_app(app_id: String) throws -> Uhoh {
|
||||
let ai = NSString(string: app_id)
|
||||
let ai_pointer = UnsafeMutablePointer<CChar>(mutating: ai.utf8String)
|
||||
#if true // Retries
|
||||
var res = minimuxer_debug_app(ai_pointer)
|
||||
var attempts = 10
|
||||
while attempts != 0, res != 0 {
|
||||
os_log("(JIT) ATTEMPTS: %@", type: .debug, attempts)
|
||||
res = minimuxer_debug_app(ai_pointer)
|
||||
attempts -= 1
|
||||
}
|
||||
#else
|
||||
let res = minimuxer_debug_app(ai_pointer)
|
||||
#endif
|
||||
if res != 0 {
|
||||
throw Uhoh.Bad(code: res)
|
||||
}
|
||||
return Uhoh.Good
|
||||
}
|
||||
|
||||
public func install_provisioning_profile(plist: Data) throws -> Uhoh {
|
||||
let pls = String(decoding: plist, as: UTF8.self)
|
||||
print(pls)
|
||||
print(plist)
|
||||
#if false // Retries
|
||||
var res = minimuxer_install_provisioning_profile(x, UInt32(plist.count))
|
||||
var attempts = 10
|
||||
while attempts != 0, res != 0 {
|
||||
print("(INSTALL) ATTEMPTS: \(attempts)")
|
||||
res = minimuxer_install_provisioning_profile(x, UInt32(plist.count))
|
||||
attempts -= 1
|
||||
}
|
||||
#else
|
||||
let x = plist.withUnsafeBytes { buf in UnsafeMutableRawPointer(mutating: buf) }
|
||||
#endif
|
||||
let res = minimuxer_install_provisioning_profile(x, UInt32(plist.count))
|
||||
if res != 0 {
|
||||
throw Uhoh.Bad(code: res)
|
||||
}
|
||||
return Uhoh.Good
|
||||
}
|
||||
|
||||
public func remove_provisioning_profile(id: String) throws -> Uhoh {
|
||||
let id_ns = NSString(string: id)
|
||||
let id_pointer = UnsafeMutablePointer<CChar>(mutating: id_ns.utf8String)
|
||||
#if false // Retries
|
||||
var res = minimuxer_remove_provisioning_profile(id_pointer)
|
||||
var attempts = 10
|
||||
while attempts != 0, res != 0 {
|
||||
print("(REMOVE PROFILE) ATTEMPTS: \(attempts)")
|
||||
res = minimuxer_remove_provisioning_profile(id_pointer)
|
||||
attempts -= 1
|
||||
}
|
||||
#else
|
||||
let res = minimuxer_remove_provisioning_profile(id_pointer)
|
||||
#endif
|
||||
if res != 0 {
|
||||
throw Uhoh.Bad(code: res)
|
||||
}
|
||||
return Uhoh.Good
|
||||
}
|
||||
|
||||
public func remove_app(app_id: String) throws -> Uhoh {
|
||||
let ai = NSString(string: app_id)
|
||||
let ai_pointer = UnsafeMutablePointer<CChar>(mutating: ai.utf8String)
|
||||
let res = minimuxer_remove_app(ai_pointer)
|
||||
if res != 0 {
|
||||
throw Uhoh.Bad(code: res)
|
||||
}
|
||||
return Uhoh.Good
|
||||
}
|
||||
|
||||
public func auto_mount_dev_image() {
|
||||
let u = NSString(string: getDocumentsDirectory().absoluteString)
|
||||
let u_ptr = UnsafeMutablePointer<CChar>(mutating: u.utf8String)
|
||||
minimuxer_auto_mount(u_ptr)
|
||||
}
|
||||
|
||||
func getDocumentsDirectory() -> URL {
|
||||
// find all possible documents directories for this user
|
||||
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
|
||||
|
||||
// just send back the first one, which ought to be the only one
|
||||
return paths[0]
|
||||
}
|
||||
10
Sources/Shared/AltConstants.swift
Normal file
10
Sources/Shared/AltConstants.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
//
|
||||
// AltConstants.swift
|
||||
//
|
||||
//
|
||||
// Created by Joseph Mattiello on 2/28/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public let ALTDeviceListeningSocket: UInt16 = 28151
|
||||
13
Sources/Shared/CFNotificationName+AltStore.swift
Normal file
13
Sources/Shared/CFNotificationName+AltStore.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// CFNotificationName+SideStore.swift
|
||||
// SideKit
|
||||
//
|
||||
// Created by Joseph Mattiello on 02/28/23.
|
||||
// Copyright © 2023 Joseph Mattiello. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public let ALTWiredServerConnectionAvailableRequest = CFNotificationName("io.altstore.Request.WiredServerConnectionAvailable" as CFString)
|
||||
public let ALTWiredServerConnectionAvailableResponse = CFNotificationName("io.altstore.Response.WiredServerConnectionAvailable" as CFString)
|
||||
public let ALTWiredServerConnectionStartRequest = CFNotificationName("io.altstore.Request.WiredServerConnectionStart" as CFString)
|
||||
101
Sources/Shared/Connections/Connection.swift
Normal file
101
Sources/Shared/Connections/Connection.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// Connection.swift
|
||||
// AltKit
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import SideKit
|
||||
|
||||
public protocol SideConnection: Connection {
|
||||
func __send(_ data: Data, completionHandler: @escaping (Bool, Error?) -> Void)
|
||||
func __receiveData(expectedSize: Int, completionHandler: @escaping (Data?, Error?) -> Void)
|
||||
}
|
||||
|
||||
public extension SideConnection {
|
||||
func send(_ data: Data, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void) {
|
||||
__send(data) { success, error in
|
||||
let result = Result(success, error).mapError { (failure: Error) -> ALTServerError in
|
||||
guard let nwError = failure as? NWError else { return ALTServerError(failure) }
|
||||
return ALTServerError.lostConnection(underlyingError: nwError)
|
||||
}
|
||||
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveData(expectedSize: Int, completionHandler: @escaping (Result<Data, ALTServerError>) -> Void) {
|
||||
__receiveData(expectedSize: expectedSize) { data, error in
|
||||
let result = Result(data, error).mapError { (failure: Error) -> ALTServerError in
|
||||
guard let nwError = failure as? NWError else { return ALTServerError(failure) }
|
||||
return ALTServerError.lostConnection(underlyingError: nwError)
|
||||
}
|
||||
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
|
||||
func send<T: Encodable>(_ response: T, shouldDisconnect: Bool = false, completionHandler: @escaping (Result<Void, ALTServerError>) -> Void) {
|
||||
func finish(_ result: Result<Void, ALTServerError>) {
|
||||
completionHandler(result)
|
||||
|
||||
if shouldDisconnect {
|
||||
// Add short delay to prevent us from dropping connection too quickly.
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) {
|
||||
self.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(response)
|
||||
let responseSize = withUnsafeBytes(of: Int32(data.count)) { Data($0) }
|
||||
|
||||
send(responseSize) { result in
|
||||
switch result {
|
||||
case let .failure(error): finish(.failure(error))
|
||||
case .success:
|
||||
self.send(data) { result in
|
||||
switch result {
|
||||
case let .failure(error): finish(.failure(error))
|
||||
case .success: finish(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
finish(.failure(.invalidResponse(underlyingError: error)))
|
||||
}
|
||||
}
|
||||
|
||||
func receiveRequest(completionHandler: @escaping (Result<ServerRequest, ALTServerError>) -> Void) {
|
||||
let size = MemoryLayout<Int32>.size
|
||||
|
||||
print("Receiving request size from connection:", self)
|
||||
receiveData(expectedSize: size) { result in
|
||||
do {
|
||||
let data = try result.get()
|
||||
|
||||
let expectedSize = Int(data.withUnsafeBytes { $0.load(as: Int32.self) })
|
||||
print("Receiving request from connection: \(self)... (\(expectedSize) bytes)")
|
||||
|
||||
self.receiveData(expectedSize: expectedSize) { result in
|
||||
do {
|
||||
let data = try result.get()
|
||||
let request = try JSONDecoder().decode(ServerRequest.self, from: data)
|
||||
|
||||
print("Received request:", request)
|
||||
completionHandler(.success(request))
|
||||
} catch {
|
||||
completionHandler(.failure(ALTServerError(error)))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(ALTServerError(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
Sources/Shared/Connections/ConnectionManager.swift
Normal file
158
Sources/Shared/Connections/ConnectionManager.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
//
|
||||
// ConnectionManager.swift
|
||||
// AltServer
|
||||
//
|
||||
// Created by Riley Testut on 5/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import SideKit
|
||||
|
||||
public protocol RequestHandler {
|
||||
func handleAnisetteDataRequest(_ request: AnisetteDataRequest, for connection: Connection, completionHandler: @escaping (Result<AnisetteDataResponse, Error>) -> Void)
|
||||
func handlePrepareAppRequest(_ request: PrepareAppRequest, for connection: Connection, completionHandler: @escaping (Result<InstallationProgressResponse, Error>) -> Void)
|
||||
|
||||
func handleInstallProvisioningProfilesRequest(_ request: InstallProvisioningProfilesRequest, for connection: Connection,
|
||||
completionHandler: @escaping (Result<InstallProvisioningProfilesResponse, Error>) -> Void)
|
||||
func handleRemoveProvisioningProfilesRequest(_ request: RemoveProvisioningProfilesRequest, for connection: Connection,
|
||||
completionHandler: @escaping (Result<RemoveProvisioningProfilesResponse, Error>) -> Void)
|
||||
|
||||
func handleRemoveAppRequest(_ request: RemoveAppRequest, for connection: Connection, completionHandler: @escaping (Result<RemoveAppResponse, Error>) -> Void)
|
||||
|
||||
func handleEnableUnsignedCodeExecutionRequest(_ request: EnableUnsignedCodeExecutionRequest, for connection: Connection, completionHandler: @escaping (Result<EnableUnsignedCodeExecutionResponse, Error>) -> Void)
|
||||
}
|
||||
|
||||
public protocol ConnectionHandler: AnyObject {
|
||||
associatedtype ConnectionType = Connection
|
||||
var connectionHandler: ((ConnectionType) -> Void)? { get set }
|
||||
var disconnectionHandler: ((ConnectionType) -> Void)? { get set }
|
||||
|
||||
func startListening()
|
||||
func stopListening()
|
||||
}
|
||||
|
||||
public class ConnectionManager<RequestHandlerType: RequestHandler, ConnectionType: NetworkConnection & AnyObject, ConnectionHandlerType: ConnectionHandler> where ConnectionHandlerType.ConnectionType == ConnectionType {
|
||||
public let requestHandler: RequestHandlerType
|
||||
public let connectionHandlers: [ConnectionHandlerType]
|
||||
|
||||
public var isStarted = false
|
||||
|
||||
private var connections = [ConnectionType]()
|
||||
private let connectionsLock = NSLock()
|
||||
|
||||
public init(requestHandler: RequestHandlerType, connectionHandlers: [ConnectionHandlerType]) {
|
||||
self.requestHandler = requestHandler
|
||||
self.connectionHandlers = connectionHandlers
|
||||
|
||||
for handler in connectionHandlers {
|
||||
handler.connectionHandler = { [weak self] connection in
|
||||
self?.prepare(connection)
|
||||
}
|
||||
|
||||
handler.disconnectionHandler = { [weak self] connection in
|
||||
self?.disconnect(connection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func start() {
|
||||
guard !isStarted else { return }
|
||||
|
||||
for connectionHandler in connectionHandlers {
|
||||
connectionHandler.startListening()
|
||||
}
|
||||
|
||||
isStarted = true
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
guard isStarted else { return }
|
||||
|
||||
for connectionHandler in connectionHandlers {
|
||||
connectionHandler.stopListening()
|
||||
}
|
||||
|
||||
isStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
private extension ConnectionManager {
|
||||
func prepare(_ connection: ConnectionType) {
|
||||
connectionsLock.lock()
|
||||
defer { self.connectionsLock.unlock() }
|
||||
|
||||
guard !connections.contains(where: { $0 === connection }) else { return }
|
||||
connections.append(connection)
|
||||
|
||||
handleRequest(for: connection)
|
||||
}
|
||||
|
||||
func disconnect(_ connection: ConnectionType) {
|
||||
connectionsLock.lock()
|
||||
defer { self.connectionsLock.unlock() }
|
||||
|
||||
guard let index = connections.firstIndex(where: { $0 === connection }) else { return }
|
||||
connections.remove(at: index)
|
||||
}
|
||||
|
||||
func handleRequest(for connection: ConnectionType) {
|
||||
func finish<T: ServerMessageProtocol>(_ result: Result<T, Error>) {
|
||||
do {
|
||||
let response = try result.get()
|
||||
connection.send(response, shouldDisconnect: true) { result in
|
||||
print("Sent response \(response) with result:", result)
|
||||
}
|
||||
} catch {
|
||||
let response = ErrorResponse(error: ALTServerError(error))
|
||||
connection.send(response, shouldDisconnect: true) { result in
|
||||
print("Sent error response \(response) with result:", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection.receiveRequest { result in
|
||||
print("Received request with result:", result)
|
||||
|
||||
switch result {
|
||||
case let .failure(error): finish(Result<ErrorResponse, Error>.failure(error))
|
||||
|
||||
case let .success(.anisetteData(request)):
|
||||
self.requestHandler.handleAnisetteDataRequest(request, for: connection) { result in
|
||||
finish(result)
|
||||
}
|
||||
|
||||
case let .success(.prepareApp(request)):
|
||||
self.requestHandler.handlePrepareAppRequest(request, for: connection) { result in
|
||||
finish(result)
|
||||
}
|
||||
|
||||
case .success(.beginInstallation): break
|
||||
|
||||
case let .success(.installProvisioningProfiles(request)):
|
||||
self.requestHandler.handleInstallProvisioningProfilesRequest(request, for: connection) { result in
|
||||
finish(result)
|
||||
}
|
||||
|
||||
case let .success(.removeProvisioningProfiles(request)):
|
||||
self.requestHandler.handleRemoveProvisioningProfilesRequest(request, for: connection) { result in
|
||||
finish(result)
|
||||
}
|
||||
|
||||
case let .success(.removeApp(request)):
|
||||
self.requestHandler.handleRemoveAppRequest(request, for: connection) { result in
|
||||
finish(result)
|
||||
}
|
||||
|
||||
case let .success(.enableUnsignedCodeExecution(request)):
|
||||
self.requestHandler.handleEnableUnsignedCodeExecutionRequest(request, for: connection) { result in
|
||||
finish(result)
|
||||
}
|
||||
|
||||
case .success(.unknown):
|
||||
finish(Result<ErrorResponse, Error>.failure(ALTServerError.unknownRequest))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Sources/Shared/Connections/NetworkConnection.swift
Normal file
48
Sources/Shared/Connections/NetworkConnection.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// NetworkConnection.swift
|
||||
// AltKit
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import SideKit
|
||||
|
||||
public class NetworkConnection: NSObject, SideConnection {
|
||||
public let nwConnection: NWConnection
|
||||
|
||||
public init(_ nwConnection: NWConnection) {
|
||||
self.nwConnection = nwConnection
|
||||
}
|
||||
|
||||
public func __send(_ data: Data, completionHandler: @escaping (Bool, Error?) -> Void) {
|
||||
nwConnection.send(content: data, completion: .contentProcessed { error in
|
||||
completionHandler(error == nil, error)
|
||||
})
|
||||
}
|
||||
|
||||
public func __receiveData(expectedSize: Int, completionHandler: @escaping (Data?, Error?) -> Void) {
|
||||
nwConnection.receive(minimumIncompleteLength: expectedSize, maximumLength: expectedSize) { data, _, _, error in
|
||||
guard data != nil || error != nil else {
|
||||
return completionHandler(nil, ALTServerError.lostConnection(underlyingError: error))
|
||||
}
|
||||
|
||||
completionHandler(data, error)
|
||||
}
|
||||
}
|
||||
|
||||
public func disconnect() {
|
||||
switch nwConnection.state {
|
||||
case .cancelled, .failed: break
|
||||
default: nwConnection.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension NetworkConnection {
|
||||
override var description: String {
|
||||
"\(nwConnection.endpoint) (Network)"
|
||||
}
|
||||
}
|
||||
138
Sources/Shared/Connections/XPCConnection.swift
Normal file
138
Sources/Shared/Connections/XPCConnection.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// XPCConnection.swift
|
||||
// AltKit
|
||||
//
|
||||
// Created by Riley Testut on 6/15/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SideKit
|
||||
|
||||
@objc private protocol XPCConnectionProxy {
|
||||
func ping(completionHandler: @escaping () -> Void)
|
||||
func receive(_ data: Data, completionHandler: @escaping (Bool, Error?) -> Void)
|
||||
}
|
||||
|
||||
public extension XPCConnection {
|
||||
static let unc0verMachServiceName = "cy:io.altstore.altdaemon"
|
||||
static let odysseyMachServiceName = "lh:io.altstore.altdaemon"
|
||||
|
||||
static let machServiceNames = [unc0verMachServiceName, odysseyMachServiceName]
|
||||
}
|
||||
|
||||
public class XPCConnection: NSObject, SideConnection {
|
||||
public let xpcConnection: NSXPCConnection
|
||||
|
||||
private let queue = DispatchQueue(label: "io.altstore.XPCConnection")
|
||||
private let dispatchGroup = DispatchGroup()
|
||||
private var semaphore: DispatchSemaphore?
|
||||
private var buffer = Data(capacity: 1024)
|
||||
|
||||
private var error: Error?
|
||||
|
||||
public init(_ xpcConnection: NSXPCConnection) {
|
||||
let proxyInterface = NSXPCInterface(with: XPCConnectionProxy.self)
|
||||
xpcConnection.remoteObjectInterface = proxyInterface
|
||||
xpcConnection.exportedInterface = proxyInterface
|
||||
|
||||
self.xpcConnection = xpcConnection
|
||||
|
||||
super.init()
|
||||
|
||||
xpcConnection.interruptionHandler = {
|
||||
self.error = ALTServerError.lostConnection(underlyingError: nil)
|
||||
}
|
||||
|
||||
xpcConnection.exportedObject = self
|
||||
xpcConnection.resume()
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private extension XPCConnection {
|
||||
func makeProxy(errorHandler: @escaping (Error) -> Void) -> XPCConnectionProxy {
|
||||
let proxy = xpcConnection.remoteObjectProxyWithErrorHandler { error in
|
||||
print("Error messaging remote object proxy:", error)
|
||||
self.error = error
|
||||
errorHandler(error)
|
||||
} as! XPCConnectionProxy
|
||||
|
||||
return proxy
|
||||
}
|
||||
}
|
||||
|
||||
public extension XPCConnection {
|
||||
func connect(completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
let proxy = makeProxy { error in
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
|
||||
proxy.ping {
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
xpcConnection.invalidate()
|
||||
}
|
||||
|
||||
func __send(_ data: Data, completionHandler: @escaping (Bool, Error?) -> Void) {
|
||||
guard error == nil else { return completionHandler(false, error) }
|
||||
|
||||
let proxy = makeProxy { error in
|
||||
completionHandler(false, error)
|
||||
}
|
||||
|
||||
proxy.receive(data) { success, error in
|
||||
completionHandler(success, error)
|
||||
}
|
||||
}
|
||||
|
||||
func __receiveData(expectedSize: Int, completionHandler: @escaping (Data?, Error?) -> Void) {
|
||||
guard error == nil else { return completionHandler(nil, error) }
|
||||
|
||||
queue.async {
|
||||
let copiedBuffer = self.buffer // Copy buffer to prevent runtime crashes.
|
||||
guard copiedBuffer.count >= expectedSize else {
|
||||
self.semaphore = DispatchSemaphore(value: 0)
|
||||
DispatchQueue.global().async {
|
||||
_ = self.semaphore?.wait(timeout: .now() + 1.0)
|
||||
self.__receiveData(expectedSize: expectedSize, completionHandler: completionHandler)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let data = copiedBuffer.prefix(expectedSize)
|
||||
self.buffer = copiedBuffer.dropFirst(expectedSize)
|
||||
|
||||
completionHandler(data, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension XPCConnection {
|
||||
override var description: String {
|
||||
"\(xpcConnection.endpoint) (XPC)"
|
||||
}
|
||||
}
|
||||
|
||||
extension XPCConnection: XPCConnectionProxy {
|
||||
fileprivate func ping(completionHandler: @escaping () -> Void) {
|
||||
completionHandler()
|
||||
}
|
||||
|
||||
fileprivate func receive(_ data: Data, completionHandler: @escaping (Bool, Error?) -> Void) {
|
||||
queue.async {
|
||||
self.buffer.append(data)
|
||||
|
||||
self.semaphore?.signal()
|
||||
self.semaphore = nil
|
||||
|
||||
completionHandler(true, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
70
Sources/Shared/Extensions/Bundle+AltStore.swift
Normal file
70
Sources/Shared/Extensions/Bundle+AltStore.swift
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// Bundle+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/30/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
|
||||
public extension Bundle {
|
||||
enum Info {
|
||||
public static let deviceID = "ALTDeviceID"
|
||||
public static let serverID = "ALTServerID"
|
||||
public static let certificateID = "ALTCertificateID"
|
||||
public static let appGroups = "ALTAppGroups"
|
||||
public static let altBundleID = "ALTBundleIdentifier"
|
||||
public static let orgbundleIdentifier = "com.SideStore"
|
||||
public static let appbundleIdentifier = orgbundleIdentifier + ".SideStore"
|
||||
public static let devicePairingString = "ALTPairingFile"
|
||||
public static let urlTypes = "CFBundleURLTypes"
|
||||
public static let exportedUTIs = "UTExportedTypeDeclarations"
|
||||
|
||||
public static let untetherURL = "ALTFugu14UntetherURL"
|
||||
public static let untetherRequired = "ALTFugu14UntetherRequired"
|
||||
public static let untetherMinimumiOSVersion = "ALTFugu14UntetherMinimumVersion"
|
||||
public static let untetherMaximumiOSVersion = "ALTFugu14UntetherMaximumVersion"
|
||||
}
|
||||
}
|
||||
|
||||
public extension Bundle {
|
||||
var infoPlistURL: URL {
|
||||
let infoPlistURL = bundleURL.appendingPathComponent("Info.plist")
|
||||
return infoPlistURL
|
||||
}
|
||||
|
||||
var provisioningProfileURL: URL {
|
||||
let provisioningProfileURL = bundleURL.appendingPathComponent("embedded.mobileprovision")
|
||||
return provisioningProfileURL
|
||||
}
|
||||
|
||||
var certificateURL: URL {
|
||||
let certificateURL = bundleURL.appendingPathComponent("ALTCertificate.p12")
|
||||
return certificateURL
|
||||
}
|
||||
|
||||
var altstorePlistURL: URL {
|
||||
let altstorePlistURL = bundleURL.appendingPathComponent("AltStore.plist")
|
||||
return altstorePlistURL
|
||||
}
|
||||
}
|
||||
|
||||
public extension Bundle {
|
||||
static var baseAltStoreAppGroupID = "group.com.SideStore.SideStore"
|
||||
|
||||
var appGroups: [String] {
|
||||
infoDictionary?[Bundle.Info.appGroups] as? [String] ?? []
|
||||
}
|
||||
|
||||
var altstoreAppGroup: String? {
|
||||
let appGroup = appGroups.first { $0.contains(Bundle.baseAltStoreAppGroupID) }
|
||||
return appGroup
|
||||
}
|
||||
|
||||
var completeInfoDictionary: [String: Any]? {
|
||||
let infoPlistURL = self.infoPlistURL
|
||||
return NSDictionary(contentsOf: infoPlistURL) as? [String: Any]
|
||||
}
|
||||
}
|
||||
128
Sources/Shared/Extensions/NSError+AltStore.swift
Normal file
128
Sources/Shared/Extensions/NSError+AltStore.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// NSError+AltStore.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/11/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension NSError {
|
||||
@objc(alt_localizedFailure)
|
||||
var localizedFailure: String? {
|
||||
let localizedFailure = (userInfo[NSLocalizedFailureErrorKey] as? String) ?? (NSError.userInfoValueProvider(forDomain: domain)?(self, NSLocalizedFailureErrorKey) as? String)
|
||||
return localizedFailure
|
||||
}
|
||||
|
||||
@objc(alt_localizedDebugDescription)
|
||||
var localizedDebugDescription: String? {
|
||||
let debugDescription = (userInfo[NSDebugDescriptionErrorKey] as? String) ?? (NSError.userInfoValueProvider(forDomain: domain)?(self, NSDebugDescriptionErrorKey) as? String)
|
||||
return debugDescription
|
||||
}
|
||||
|
||||
@objc(alt_errorWithLocalizedFailure:)
|
||||
func withLocalizedFailure(_ failure: String) -> NSError {
|
||||
var userInfo = self.userInfo
|
||||
userInfo[NSLocalizedFailureErrorKey] = failure
|
||||
|
||||
if let failureReason = localizedFailureReason {
|
||||
userInfo[NSLocalizedFailureReasonErrorKey] = failureReason
|
||||
} else if localizedFailure == nil && localizedFailureReason == nil && localizedDescription.contains(localizedErrorCode) {
|
||||
// Default localizedDescription, so replace with just the localized error code portion.
|
||||
userInfo[NSLocalizedFailureReasonErrorKey] = "(\(localizedErrorCode).)"
|
||||
} else {
|
||||
userInfo[NSLocalizedFailureReasonErrorKey] = localizedDescription
|
||||
}
|
||||
|
||||
if let localizedDescription = NSError.userInfoValueProvider(forDomain: domain)?(self, NSLocalizedDescriptionKey) as? String {
|
||||
userInfo[NSLocalizedDescriptionKey] = localizedDescription
|
||||
}
|
||||
|
||||
// Don't accidentally remove localizedDescription from dictionary
|
||||
// userInfo[NSLocalizedDescriptionKey] = NSError.userInfoValueProvider(forDomain: self.domain)?(self, NSLocalizedDescriptionKey) as? String
|
||||
|
||||
if let recoverySuggestion = localizedRecoverySuggestion {
|
||||
userInfo[NSLocalizedRecoverySuggestionErrorKey] = recoverySuggestion
|
||||
}
|
||||
|
||||
let error = NSError(domain: domain, code: code, userInfo: userInfo)
|
||||
return error
|
||||
}
|
||||
|
||||
func sanitizedForCoreData() -> NSError {
|
||||
var userInfo = self.userInfo
|
||||
userInfo[NSLocalizedFailureErrorKey] = localizedFailure
|
||||
userInfo[NSLocalizedDescriptionKey] = localizedDescription
|
||||
userInfo[NSLocalizedFailureReasonErrorKey] = localizedFailureReason
|
||||
userInfo[NSLocalizedRecoverySuggestionErrorKey] = localizedRecoverySuggestion
|
||||
|
||||
// Remove userInfo values that don't conform to NSSecureEncoding.
|
||||
userInfo = userInfo.filter { _, value in
|
||||
(value as AnyObject) is NSSecureCoding
|
||||
}
|
||||
|
||||
// Sanitize underlying errors.
|
||||
if let underlyingError = userInfo[NSUnderlyingErrorKey] as? Error {
|
||||
let sanitizedError = (underlyingError as NSError).sanitizedForCoreData()
|
||||
userInfo[NSUnderlyingErrorKey] = sanitizedError
|
||||
}
|
||||
|
||||
if #available(iOS 14.5, macOS 11.3, *), let underlyingErrors = userInfo[NSMultipleUnderlyingErrorsKey] as? [Error] {
|
||||
let sanitizedErrors = underlyingErrors.map { ($0 as NSError).sanitizedForCoreData() }
|
||||
userInfo[NSMultipleUnderlyingErrorsKey] = sanitizedErrors
|
||||
}
|
||||
|
||||
let error = NSError(domain: domain, code: code, userInfo: userInfo)
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
extension Error {
|
||||
var underlyingError: Error? {
|
||||
let underlyingError = (self as NSError).userInfo[NSUnderlyingErrorKey] as? Error
|
||||
return underlyingError
|
||||
}
|
||||
|
||||
var localizedErrorCode: String {
|
||||
let localizedErrorCode = String(format: NSLocalizedString("%@ error %@", comment: ""), (self as NSError).domain, (self as NSError).code as NSNumber)
|
||||
return localizedErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
protocol ALTLocalizedError: LocalizedError, CustomNSError {
|
||||
var failure: String? { get }
|
||||
|
||||
var underlyingError: Error? { get }
|
||||
}
|
||||
|
||||
extension ALTLocalizedError {
|
||||
var errorUserInfo: [String: Any] {
|
||||
let userInfo = ([
|
||||
NSLocalizedDescriptionKey: errorDescription,
|
||||
NSLocalizedFailureReasonErrorKey: failureReason,
|
||||
NSLocalizedFailureErrorKey: failure,
|
||||
NSUnderlyingErrorKey: underlyingError
|
||||
] as [String: Any?]).compactMapValues { $0 }
|
||||
return userInfo
|
||||
}
|
||||
|
||||
var underlyingError: Error? {
|
||||
// Error's default implementation calls errorUserInfo,
|
||||
// but ALTLocalizedError.errorUserInfo calls underlyingError.
|
||||
// Return nil to prevent infinite recursion.
|
||||
nil
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
guard let errorFailure = failure else { return (underlyingError as NSError?)?.localizedDescription }
|
||||
guard let failureReason = failureReason else { return errorFailure }
|
||||
|
||||
let errorDescription = errorFailure + " " + failureReason
|
||||
return errorDescription
|
||||
}
|
||||
|
||||
var failureReason: String? { (underlyingError as NSError?)?.localizedDescription }
|
||||
var recoverySuggestion: String? { (underlyingError as NSError?)?.localizedRecoverySuggestion }
|
||||
var helpAnchor: String? { (underlyingError as NSError?)?.helpAnchor }
|
||||
}
|
||||
28
Sources/Shared/Extensions/NSXPCConnection+MachServices.swift
Normal file
28
Sources/Shared/Extensions/NSXPCConnection+MachServices.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// NSXPCConnection+MachServices.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/22/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc private protocol XPCPrivateAPI {
|
||||
init(machServiceName: String)
|
||||
init(machServiceName: String, options: NSXPCConnection.Options)
|
||||
}
|
||||
|
||||
public extension NSXPCConnection {
|
||||
class func makeConnection(machServiceName: String) -> NSXPCConnection {
|
||||
let connection = unsafeBitCast(self, to: XPCPrivateAPI.Type.self).init(machServiceName: machServiceName, options: .privileged)
|
||||
return unsafeBitCast(connection, to: NSXPCConnection.self)
|
||||
}
|
||||
}
|
||||
|
||||
public extension NSXPCListener {
|
||||
class func makeListener(machServiceName: String) -> NSXPCListener {
|
||||
let listener = unsafeBitCast(self, to: XPCPrivateAPI.Type.self).init(machServiceName: machServiceName)
|
||||
return unsafeBitCast(listener, to: NSXPCListener.self)
|
||||
}
|
||||
}
|
||||
57
Sources/Shared/Extensions/Result+Conveniences.swift
Normal file
57
Sources/Shared/Extensions/Result+Conveniences.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// Result+Conveniences.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/22/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Result {
|
||||
var value: Success? {
|
||||
switch self {
|
||||
case let .success(value): return value
|
||||
case .failure: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var error: Failure? {
|
||||
switch self {
|
||||
case .success: return nil
|
||||
case let .failure(error): return error
|
||||
}
|
||||
}
|
||||
|
||||
init(_ value: Success?, _ error: Failure?) {
|
||||
switch (value, error) {
|
||||
case let (value?, _): self = .success(value)
|
||||
case let (_, error?): self = .failure(error)
|
||||
case (nil, nil): preconditionFailure("Either value or error must be non-nil")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Result where Success == Void {
|
||||
init(_ success: Bool, _ error: Failure?) {
|
||||
if success {
|
||||
self = .success(())
|
||||
} else if let error = error {
|
||||
self = .failure(error)
|
||||
} else {
|
||||
preconditionFailure("Error must be non-nil if success is false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Result {
|
||||
init<T, U>(_ values: (T?, U?), _ error: Failure?) where Success == (T, U) {
|
||||
if let value1 = values.0, let value2 = values.1 {
|
||||
self = .success((value1, value2))
|
||||
} else if let error = error {
|
||||
self = .failure(error)
|
||||
} else {
|
||||
preconditionFailure("Error must be non-nil if either provided values are nil")
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Sources/Shared/XPC/AltXPCProtocol.swift
Normal file
21
Sources/Shared/XPC/AltXPCProtocol.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// AltXPCProtocol.swift
|
||||
// SideStore
|
||||
//
|
||||
// Created by Joseph Mattiello on 02/28/23.
|
||||
// Copyright © 2023 Joseph Mattiello. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public typealias AltXPCProtocol = SideXPCProtocol
|
||||
|
||||
@objc
|
||||
public protocol SideXPCProtocol {
|
||||
func ping(completionHandler: @escaping () -> Void)
|
||||
func requestAnisetteData(completionHandler: @escaping (ALTAnisetteData?, Error?) -> Void)
|
||||
}
|
||||
|
||||
@objc public class ALTAnisetteData: NSObject {
|
||||
// implementation
|
||||
}
|
||||
@@ -8,114 +8,104 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension AppDelegate
|
||||
{
|
||||
extension AppDelegate {
|
||||
static let startBackupNotification = Notification.Name("io.altstore.StartBackup")
|
||||
static let startRestoreNotification = Notification.Name("io.altstore.StartRestore")
|
||||
|
||||
|
||||
static let operationDidFinishNotification = Notification.Name("io.altstore.BackupOperationFinished")
|
||||
|
||||
|
||||
static let operationResultKey = "result"
|
||||
}
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
var window: UIWindow?
|
||||
|
||||
|
||||
private var currentBackupReturnURL: URL?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
|
||||
{
|
||||
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AppDelegate.operationDidFinish(_:)), name: AppDelegate.operationDidFinishNotification, object: nil)
|
||||
|
||||
|
||||
let viewController = ViewController()
|
||||
|
||||
self.window = UIWindow(frame: UIScreen.main.bounds)
|
||||
self.window?.rootViewController = viewController
|
||||
self.window?.makeKeyAndVisible()
|
||||
|
||||
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window?.rootViewController = viewController
|
||||
window?.makeKeyAndVisible()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationWillResignActive(_ application: UIApplication) {
|
||||
func applicationWillResignActive(_: UIApplication) {
|
||||
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
|
||||
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
func applicationDidBecomeActive(_: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
func applicationWillTerminate(_: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool
|
||||
{
|
||||
return self.open(url)
|
||||
|
||||
func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {
|
||||
open(url)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDelegate
|
||||
{
|
||||
func open(_ url: URL) -> Bool
|
||||
{
|
||||
private extension AppDelegate {
|
||||
func open(_ url: URL) -> Bool {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
|
||||
guard let command = components.host?.lowercased() else { return false }
|
||||
|
||||
switch command
|
||||
{
|
||||
|
||||
switch command {
|
||||
case "backup":
|
||||
guard let returnString = components.queryItems?.first(where: { $0.name == "returnURL" })?.value, let returnURL = URL(string: returnString) else { return false }
|
||||
self.currentBackupReturnURL = returnURL
|
||||
currentBackupReturnURL = returnURL
|
||||
NotificationCenter.default.post(name: AppDelegate.startBackupNotification, object: nil)
|
||||
|
||||
|
||||
return true
|
||||
|
||||
|
||||
case "restore":
|
||||
guard let returnString = components.queryItems?.first(where: { $0.name == "returnURL" })?.value, let returnURL = URL(string: returnString) else { return false }
|
||||
self.currentBackupReturnURL = returnURL
|
||||
currentBackupReturnURL = returnURL
|
||||
NotificationCenter.default.post(name: AppDelegate.startRestoreNotification, object: nil)
|
||||
|
||||
|
||||
return true
|
||||
|
||||
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
@objc func operationDidFinish(_ notification: Notification)
|
||||
{
|
||||
|
||||
@objc func operationDidFinish(_ notification: Notification) {
|
||||
defer { self.currentBackupReturnURL = nil }
|
||||
|
||||
|
||||
guard
|
||||
let returnURL = self.currentBackupReturnURL,
|
||||
let returnURL = currentBackupReturnURL,
|
||||
let result = notification.userInfo?[AppDelegate.operationResultKey] as? Result<Void, Error>
|
||||
else { return }
|
||||
|
||||
|
||||
guard var components = URLComponents(url: returnURL, resolvingAgainstBaseURL: false) else { return }
|
||||
|
||||
switch result
|
||||
{
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
components.path = "/success"
|
||||
|
||||
case .failure(let error as NSError):
|
||||
|
||||
case let .failure(error as NSError):
|
||||
components.path = "/failure"
|
||||
components.queryItems = ["errorDomain": error.domain,
|
||||
"errorCode": String(error.code),
|
||||
"errorDescription": error.localizedDescription].map { URLQueryItem(name: $0, value: $1) }
|
||||
}
|
||||
|
||||
|
||||
guard let responseURL = components.url else { return }
|
||||
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(responseURL, options: [:]) { (success) in
|
||||
UIApplication.shared.open(responseURL, options: [:]) { success in
|
||||
print("Sent response to app with success:", success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension ErrorUserInfoKey
|
||||
{
|
||||
extension ErrorUserInfoKey {
|
||||
static let sourceFile: String = "alt_sourceFile"
|
||||
static let sourceFileLine: String = "alt_sourceFileLine"
|
||||
}
|
||||
|
||||
extension Error
|
||||
{
|
||||
extension Error {
|
||||
var sourceDescription: String? {
|
||||
guard let sourceFile = (self as NSError).userInfo[ErrorUserInfoKey.sourceFile] as? String, let sourceFileLine = (self as NSError).userInfo[ErrorUserInfoKey.sourceFileLine] else {
|
||||
return nil
|
||||
@@ -24,267 +22,225 @@ extension Error
|
||||
}
|
||||
}
|
||||
|
||||
struct BackupError: ALTLocalizedError
|
||||
{
|
||||
enum Code
|
||||
{
|
||||
struct BackupError: ALTLocalizedError {
|
||||
enum Code {
|
||||
case invalidBundleID
|
||||
case appGroupNotFound(String?)
|
||||
case randomError // Used for debugging.
|
||||
}
|
||||
|
||||
|
||||
let code: Code
|
||||
|
||||
|
||||
let sourceFile: String
|
||||
let sourceFileLine: Int
|
||||
|
||||
|
||||
var failure: String?
|
||||
|
||||
var failureReason: String? {
|
||||
switch self.code
|
||||
{
|
||||
switch code {
|
||||
case .invalidBundleID: return NSLocalizedString("The bundle identifier is invalid.", comment: "")
|
||||
case .appGroupNotFound(let appGroup):
|
||||
if let appGroup = appGroup
|
||||
{
|
||||
case let .appGroupNotFound(appGroup):
|
||||
if let appGroup = appGroup {
|
||||
return String(format: NSLocalizedString("The app group “%@” could not be found.", comment: ""), appGroup)
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
return NSLocalizedString("The AltStore app group could not be found.", comment: "")
|
||||
}
|
||||
case .randomError: return NSLocalizedString("A random error occured.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var errorUserInfo: [String : Any] {
|
||||
let userInfo: [String: Any?] = [NSLocalizedDescriptionKey: self.errorDescription,
|
||||
NSLocalizedFailureReasonErrorKey: self.failureReason,
|
||||
NSLocalizedFailureErrorKey: self.failure,
|
||||
ErrorUserInfoKey.sourceFile: self.sourceFile,
|
||||
ErrorUserInfoKey.sourceFileLine: self.sourceFileLine]
|
||||
|
||||
var errorUserInfo: [String: Any] {
|
||||
let userInfo: [String: Any?] = [NSLocalizedDescriptionKey: errorDescription,
|
||||
NSLocalizedFailureReasonErrorKey: failureReason,
|
||||
NSLocalizedFailureErrorKey: failure,
|
||||
ErrorUserInfoKey.sourceFile: sourceFile,
|
||||
ErrorUserInfoKey.sourceFileLine: sourceFileLine]
|
||||
return userInfo.compactMapValues { $0 }
|
||||
}
|
||||
|
||||
init(_ code: Code, description: String? = nil, file: String = #file, line: Int = #line)
|
||||
{
|
||||
|
||||
init(_ code: Code, description: String? = nil, file: String = #file, line: Int = #line) {
|
||||
self.code = code
|
||||
self.failure = description
|
||||
self.sourceFile = file
|
||||
self.sourceFileLine = line
|
||||
failure = description
|
||||
sourceFile = file
|
||||
sourceFileLine = line
|
||||
}
|
||||
}
|
||||
|
||||
class BackupController: NSObject
|
||||
{
|
||||
class BackupController: NSObject {
|
||||
private let fileCoordinator = NSFileCoordinator(filePresenter: nil)
|
||||
private let operationQueue = OperationQueue()
|
||||
|
||||
override init()
|
||||
{
|
||||
self.operationQueue.name = "AltBackup-BackupQueue"
|
||||
|
||||
override init() {
|
||||
operationQueue.name = "AltBackup-BackupQueue"
|
||||
}
|
||||
|
||||
func performBackup(completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
do
|
||||
{
|
||||
|
||||
func performBackup(completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
do {
|
||||
guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else {
|
||||
throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to create backup directory.", comment: ""))
|
||||
}
|
||||
|
||||
|
||||
guard
|
||||
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
|
||||
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
|
||||
else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to create backup directory.", comment: "")) }
|
||||
|
||||
|
||||
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
|
||||
|
||||
|
||||
// Use temporary directory to prevent messing up successful backup with incomplete one.
|
||||
let temporaryAppBackupDirectory = backupsDirectory.appendingPathComponent("Temp", isDirectory: true).appendingPathComponent(UUID().uuidString)
|
||||
let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier)
|
||||
|
||||
|
||||
let writingIntent = NSFileAccessIntent.writingIntent(with: temporaryAppBackupDirectory, options: [])
|
||||
let replacementIntent = NSFileAccessIntent.writingIntent(with: appBackupDirectory, options: [.forReplacing])
|
||||
self.fileCoordinator.coordinate(with: [writingIntent, replacementIntent], queue: self.operationQueue) { (error) in
|
||||
do
|
||||
{
|
||||
if let error = error
|
||||
{
|
||||
fileCoordinator.coordinate(with: [writingIntent, replacementIntent], queue: operationQueue) { error in
|
||||
do {
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
|
||||
do {
|
||||
let mainGroupBackupDirectory = temporaryAppBackupDirectory.appendingPathComponent("App")
|
||||
try FileManager.default.createDirectory(at: mainGroupBackupDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
|
||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent)
|
||||
|
||||
if FileManager.default.fileExists(atPath: backupDocumentsDirectory.path)
|
||||
{
|
||||
|
||||
if FileManager.default.fileExists(atPath: backupDocumentsDirectory.path) {
|
||||
try FileManager.default.removeItem(at: backupDocumentsDirectory)
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: documentsDirectory.path)
|
||||
{
|
||||
|
||||
if FileManager.default.fileExists(atPath: documentsDirectory.path) {
|
||||
try FileManager.default.copyItem(at: documentsDirectory, to: backupDocumentsDirectory)
|
||||
}
|
||||
|
||||
|
||||
print("Copied Documents directory from \(documentsDirectory) to \(backupDocumentsDirectory)")
|
||||
|
||||
|
||||
let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||
let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent)
|
||||
|
||||
if FileManager.default.fileExists(atPath: backupLibraryDirectory.path)
|
||||
{
|
||||
|
||||
if FileManager.default.fileExists(atPath: backupLibraryDirectory.path) {
|
||||
try FileManager.default.removeItem(at: backupLibraryDirectory)
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: libraryDirectory.path)
|
||||
{
|
||||
|
||||
if FileManager.default.fileExists(atPath: libraryDirectory.path) {
|
||||
try FileManager.default.copyItem(at: libraryDirectory, to: backupLibraryDirectory)
|
||||
}
|
||||
|
||||
|
||||
print("Copied Library directory from \(libraryDirectory) to \(backupLibraryDirectory)")
|
||||
}
|
||||
|
||||
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup
|
||||
{
|
||||
|
||||
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup {
|
||||
guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
||||
throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to create app group backup directory.", comment: ""))
|
||||
}
|
||||
|
||||
|
||||
let backupAppGroupURL = temporaryAppBackupDirectory.appendingPathComponent(appGroup)
|
||||
|
||||
|
||||
// There are several system hidden files that we don't have permission to read, so we just skip all hidden files in app group directories.
|
||||
try self.copyDirectoryContents(at: appGroupURL, to: backupAppGroupURL, options: [.skipsHiddenFiles])
|
||||
}
|
||||
|
||||
|
||||
// Replace previous backup with new backup.
|
||||
_ = try FileManager.default.replaceItemAt(appBackupDirectory, withItemAt: temporaryAppBackupDirectory)
|
||||
|
||||
|
||||
print("Replaced previous backup with new backup:", temporaryAppBackupDirectory)
|
||||
|
||||
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
catch
|
||||
{
|
||||
do { try FileManager.default.removeItem(at: temporaryAppBackupDirectory) }
|
||||
catch { print("Failed to remove temporary directory.", error) }
|
||||
|
||||
} catch {
|
||||
do { try FileManager.default.removeItem(at: temporaryAppBackupDirectory) } catch { print("Failed to remove temporary directory.", error) }
|
||||
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func restoreBackup(completionHandler: @escaping (Result<Void, Error>) -> Void)
|
||||
{
|
||||
do
|
||||
{
|
||||
|
||||
func restoreBackup(completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
do {
|
||||
guard let bundleIdentifier = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.altBundleID) as? String else {
|
||||
throw BackupError(.invalidBundleID, description: NSLocalizedString("Unable to access backup.", comment: ""))
|
||||
}
|
||||
|
||||
|
||||
guard
|
||||
let altstoreAppGroup = Bundle.main.altstoreAppGroup,
|
||||
let sharedDirectoryURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: altstoreAppGroup)
|
||||
else { throw BackupError(.appGroupNotFound(nil), description: NSLocalizedString("Unable to access backup.", comment: "")) }
|
||||
|
||||
|
||||
let backupsDirectory = sharedDirectoryURL.appendingPathComponent("Backups")
|
||||
let appBackupDirectory = backupsDirectory.appendingPathComponent(bundleIdentifier)
|
||||
|
||||
|
||||
let readingIntent = NSFileAccessIntent.readingIntent(with: appBackupDirectory, options: [])
|
||||
self.fileCoordinator.coordinate(with: [readingIntent], queue: self.operationQueue) { (error) in
|
||||
do
|
||||
{
|
||||
if let error = error
|
||||
{
|
||||
fileCoordinator.coordinate(with: [readingIntent], queue: operationQueue) { error in
|
||||
do {
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
|
||||
|
||||
let mainGroupBackupDirectory = appBackupDirectory.appendingPathComponent("App")
|
||||
|
||||
|
||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let backupDocumentsDirectory = mainGroupBackupDirectory.appendingPathComponent(documentsDirectory.lastPathComponent)
|
||||
|
||||
|
||||
let libraryDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask)[0]
|
||||
let backupLibraryDirectory = mainGroupBackupDirectory.appendingPathComponent(libraryDirectory.lastPathComponent)
|
||||
|
||||
|
||||
try self.copyDirectoryContents(at: backupDocumentsDirectory, to: documentsDirectory)
|
||||
try self.copyDirectoryContents(at: backupLibraryDirectory, to: libraryDirectory)
|
||||
|
||||
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup
|
||||
{
|
||||
|
||||
for appGroup in Bundle.main.appGroups where appGroup != altstoreAppGroup {
|
||||
guard let appGroupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
|
||||
throw BackupError(.appGroupNotFound(appGroup), description: NSLocalizedString("Unable to read app group backup.", comment: ""))
|
||||
}
|
||||
|
||||
|
||||
let backupAppGroupURL = appBackupDirectory.appendingPathComponent(appGroup)
|
||||
try self.copyDirectoryContents(at: backupAppGroupURL, to: appGroupURL)
|
||||
}
|
||||
|
||||
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
catch
|
||||
{
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackupController
|
||||
{
|
||||
func copyDirectoryContents(at sourceDirectoryURL: URL, to destinationDirectoryURL: URL, options: FileManager.DirectoryEnumerationOptions = []) throws
|
||||
{
|
||||
private extension BackupController {
|
||||
func copyDirectoryContents(at sourceDirectoryURL: URL, to destinationDirectoryURL: URL, options: FileManager.DirectoryEnumerationOptions = []) throws {
|
||||
guard FileManager.default.fileExists(atPath: sourceDirectoryURL.path) else { return }
|
||||
|
||||
if !FileManager.default.fileExists(atPath: destinationDirectoryURL.path)
|
||||
{
|
||||
|
||||
if !FileManager.default.fileExists(atPath: destinationDirectoryURL.path) {
|
||||
try FileManager.default.createDirectory(at: destinationDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
|
||||
for fileURL in try FileManager.default.contentsOfDirectory(at: sourceDirectoryURL, includingPropertiesForKeys: [.isDirectoryKey], options: options)
|
||||
{
|
||||
|
||||
for fileURL in try FileManager.default.contentsOfDirectory(at: sourceDirectoryURL, includingPropertiesForKeys: [.isDirectoryKey], options: options) {
|
||||
let isDirectory = try fileURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory ?? false
|
||||
let destinationURL = destinationDirectoryURL.appendingPathComponent(fileURL.lastPathComponent)
|
||||
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path)
|
||||
{
|
||||
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
catch CocoaError.fileWriteNoPermission where isDirectory {
|
||||
try self.copyDirectoryContents(at: fileURL, to: destinationURL, options: options)
|
||||
} catch CocoaError.fileWriteNoPermission where isDirectory {
|
||||
try copyDirectoryContents(at: fileURL, to: destinationURL, options: options)
|
||||
continue
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
print(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
try FileManager.default.copyItem(at: fileURL, to: destinationURL)
|
||||
print("Copied item from \(fileURL) to \(destinationURL)")
|
||||
}
|
||||
catch let error where fileURL.lastPathComponent == "Inbox" && fileURL.deletingLastPathComponent().lastPathComponent == "Documents" {
|
||||
} catch let error where fileURL.lastPathComponent == "Inbox" && fileURL.deletingLastPathComponent().lastPathComponent == "Documents" {
|
||||
// Ignore errors for /Documents/Inbox
|
||||
print("Failed to copy Inbox directory:", error)
|
||||
}
|
||||
catch {
|
||||
} catch {
|
||||
print(error)
|
||||
throw error
|
||||
}
|
||||
@@ -8,8 +8,7 @@
|
||||
|
||||
import UIKit
|
||||
|
||||
extension UIColor
|
||||
{
|
||||
extension UIColor {
|
||||
static let altstoreBackground = UIColor(named: "Background")!
|
||||
static let altstoreText = UIColor(named: "Text")!
|
||||
}
|
||||
189
Sources/SideBackup/ViewController.swift
Normal file
189
Sources/SideBackup/ViewController.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
//
|
||||
// ViewController.swift
|
||||
// AltBackup
|
||||
//
|
||||
// Created by Riley Testut on 5/11/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension Bundle {
|
||||
var appName: String? {
|
||||
let appName =
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ??
|
||||
Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String
|
||||
return appName
|
||||
}
|
||||
}
|
||||
|
||||
extension ViewController {
|
||||
enum BackupOperation {
|
||||
case backup
|
||||
case restore
|
||||
}
|
||||
}
|
||||
|
||||
class ViewController: UIViewController {
|
||||
private let backupController = BackupController()
|
||||
|
||||
private var currentOperation: BackupOperation? {
|
||||
didSet {
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var textLabel: UILabel!
|
||||
private var detailTextLabel: UILabel!
|
||||
private var activityIndicatorView: UIActivityIndicatorView!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.backup), name: AppDelegate.startBackupNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.restore), name: AppDelegate.startRestoreNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.didEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.backgroundColor = .altstoreBackground
|
||||
|
||||
textLabel = UILabel(frame: .zero)
|
||||
textLabel.font = UIFont.preferredFont(forTextStyle: .title2)
|
||||
textLabel.textColor = .altstoreText
|
||||
textLabel.textAlignment = .center
|
||||
textLabel.numberOfLines = 0
|
||||
|
||||
detailTextLabel = UILabel(frame: .zero)
|
||||
detailTextLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
detailTextLabel.textColor = .altstoreText
|
||||
detailTextLabel.textAlignment = .center
|
||||
detailTextLabel.numberOfLines = 0
|
||||
|
||||
activityIndicatorView = UIActivityIndicatorView(style: .whiteLarge)
|
||||
activityIndicatorView.color = .altstoreText
|
||||
activityIndicatorView.startAnimating()
|
||||
|
||||
#if DEBUG
|
||||
let button1 = UIButton(type: .system)
|
||||
button1.setTitle("Backup", for: .normal)
|
||||
button1.setTitleColor(.white, for: .normal)
|
||||
button1.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
button1.addTarget(self, action: #selector(ViewController.backup), for: .primaryActionTriggered)
|
||||
|
||||
let button2 = UIButton(type: .system)
|
||||
button2.setTitle("Restore", for: .normal)
|
||||
button2.setTitleColor(.white, for: .normal)
|
||||
button2.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
button2.addTarget(self, action: #selector(ViewController.restore), for: .primaryActionTriggered)
|
||||
|
||||
let arrangedSubviews = [textLabel!, detailTextLabel!, activityIndicatorView!, button1, button2]
|
||||
#else
|
||||
let arrangedSubviews = [textLabel!, detailTextLabel!, activityIndicatorView!]
|
||||
#endif
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.spacing = 22
|
||||
stackView.axis = .vertical
|
||||
stackView.alignment = .center
|
||||
view.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
stackView.leadingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: view.safeAreaLayoutGuide.leadingAnchor, multiplier: 1.0),
|
||||
view.safeAreaLayoutGuide.trailingAnchor.constraint(greaterThanOrEqualToSystemSpacingAfter: stackView.trailingAnchor, multiplier: 1.0)])
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ViewController {
|
||||
@objc func backup() {
|
||||
currentOperation = .backup
|
||||
|
||||
backupController.performBackup { result in
|
||||
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
|
||||
|
||||
let title = String(format: NSLocalizedString("%@ could not be backed up.", comment: ""), appName)
|
||||
self.process(result, errorTitle: title)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func restore() {
|
||||
currentOperation = .restore
|
||||
|
||||
backupController.restoreBackup { result in
|
||||
let appName = Bundle.main.appName ?? NSLocalizedString("App", comment: "")
|
||||
|
||||
let title = String(format: NSLocalizedString("%@ could not be restored.", comment: ""), appName)
|
||||
self.process(result, errorTitle: title)
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
switch currentOperation {
|
||||
case .backup:
|
||||
textLabel.text = NSLocalizedString("Backing up app data…", comment: "")
|
||||
detailTextLabel.isHidden = true
|
||||
activityIndicatorView.startAnimating()
|
||||
|
||||
case .restore:
|
||||
textLabel.text = NSLocalizedString("Restoring app data…", comment: "")
|
||||
detailTextLabel.isHidden = true
|
||||
activityIndicatorView.startAnimating()
|
||||
|
||||
case .none:
|
||||
textLabel.text = String(format: NSLocalizedString("%@ is inactive.", comment: ""),
|
||||
Bundle.main.appName ?? NSLocalizedString("App", comment: ""))
|
||||
|
||||
detailTextLabel.text = String(format: NSLocalizedString("Refresh %@ in SideStore to continue using it.", comment: ""),
|
||||
Bundle.main.appName ?? NSLocalizedString("this app", comment: ""))
|
||||
|
||||
detailTextLabel.isHidden = false
|
||||
activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ViewController {
|
||||
func process(_ result: Result<Void, Error>, errorTitle: String) {
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .success: break
|
||||
case let .failure(error as NSError):
|
||||
let message: String
|
||||
|
||||
if let sourceDescription = error.sourceDescription {
|
||||
message = error.localizedDescription + "\n\n" + sourceDescription
|
||||
} else {
|
||||
message = error.localizedDescription
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: errorTitle, message: message, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil))
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: AppDelegate.operationDidFinishNotification, object: nil, userInfo: [AppDelegate.operationResultKey: result])
|
||||
}
|
||||
}
|
||||
|
||||
@objc func didEnterBackground(_: Notification) {
|
||||
// Reset UI once we've left app (but not before).
|
||||
currentOperation = nil
|
||||
}
|
||||
}
|
||||
59
Sources/SideDaemon/AltDaemon-Bridging-Header.h
Normal file
59
Sources/SideDaemon/AltDaemon-Bridging-Header.h
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
// Shared
|
||||
#import "ALTConstants.h"
|
||||
#import "ALTConnection.h"
|
||||
#import "NSError+ALTServerError.h"
|
||||
#import "CFNotificationName+AltStore.h"
|
||||
|
||||
// libproc
|
||||
int proc_pidpath(int pid, void * buffer, uint32_t buffersize);
|
||||
|
||||
// Security.framework
|
||||
CF_ENUM(uint32_t) {
|
||||
kSecCSInternalInformation = 1 << 0,
|
||||
kSecCSSigningInformation = 1 << 1,
|
||||
kSecCSRequirementInformation = 1 << 2,
|
||||
kSecCSDynamicInformation = 1 << 3,
|
||||
kSecCSContentInformation = 1 << 4,
|
||||
kSecCSSkipResourceDirectory = 1 << 5,
|
||||
kSecCSCalculateCMSDigest = 1 << 6,
|
||||
};
|
||||
|
||||
OSStatus SecStaticCodeCreateWithPath(CFURLRef path, uint32_t flags, void ** __nonnull CF_RETURNS_RETAINED staticCode);
|
||||
OSStatus SecCodeCopySigningInformation(void *code, uint32_t flags, CFDictionaryRef * __nonnull CF_RETURNS_RETAINED information);
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface AKDevice : NSObject
|
||||
|
||||
@property (class, readonly) AKDevice *currentDevice;
|
||||
|
||||
@property (strong, readonly) NSString *serialNumber;
|
||||
@property (strong, readonly) NSString *uniqueDeviceIdentifier;
|
||||
@property (strong, readonly) NSString *serverFriendlyDescription;
|
||||
|
||||
@end
|
||||
|
||||
@interface AKAppleIDSession : NSObject
|
||||
|
||||
- (instancetype)initWithIdentifier:(NSString *)identifier;
|
||||
|
||||
- (NSDictionary<NSString *, NSString *> *)appleIDHeadersForRequest:(NSURLRequest *)request;
|
||||
|
||||
@end
|
||||
|
||||
@interface LSApplicationWorkspace : NSObject
|
||||
|
||||
@property (class, readonly) LSApplicationWorkspace *defaultWorkspace;
|
||||
|
||||
- (BOOL)installApplication:(NSURL *)fileURL withOptions:(nullable NSDictionary<NSString *, id> *)options error:(NSError *_Nullable *)error;
|
||||
- (BOOL)uninstallApplication:(NSString *)bundleIdentifier withOptions:(nullable NSDictionary *)options;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
22
Sources/SideDaemon/AltDaemon.entitlements
Normal file
22
Sources/SideDaemon/AltDaemon.entitlements
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>application-identifier</key>
|
||||
<string>$(DEVELOPMENT_TEAM).$(ORG_IDENTIFIER).AltDaemon</string>
|
||||
<key>get-task-allow</key>
|
||||
<true/>
|
||||
<key>platform-application</key>
|
||||
<true/>
|
||||
<key>com.apple.authkit.client.private</key>
|
||||
<true/>
|
||||
<key>com.apple.private.mobileinstall.allowedSPI</key>
|
||||
<array>
|
||||
<string>Install</string>
|
||||
<string>Uninstall</string>
|
||||
<string>InstallForLaunchServices</string>
|
||||
<string>UninstallForLaunchServices</string>
|
||||
<string>InstallLocalProvisioned</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
60
Sources/SideDaemon/AnisetteDataManager.swift
Normal file
60
Sources/SideDaemon/AnisetteDataManager.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// AnisetteDataManager.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
|
||||
private extension UserDefaults {
|
||||
@objc var localUserID: String? {
|
||||
get { string(forKey: #keyPath(UserDefaults.localUserID)) }
|
||||
set { set(newValue, forKey: #keyPath(UserDefaults.localUserID)) }
|
||||
}
|
||||
}
|
||||
|
||||
struct AnisetteDataManager {
|
||||
static let shared = AnisetteDataManager()
|
||||
|
||||
private let dateFormatter = ISO8601DateFormatter()
|
||||
|
||||
private init() {
|
||||
dlopen("/System/Library/PrivateFrameworks/AuthKit.framework/AuthKit", RTLD_NOW)
|
||||
}
|
||||
|
||||
func requestAnisetteData() throws -> ALTAnisetteData {
|
||||
var request = URLRequest(url: URL(string: "https://developerservices2.apple.com/services/QH65B2/listTeams.action?clientId=XABBG36SBA")!)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
let akAppleIDSession = unsafeBitCast(NSClassFromString("AKAppleIDSession")!, to: AKAppleIDSession.Type.self)
|
||||
let akDevice = unsafeBitCast(NSClassFromString("AKDevice")!, to: AKDevice.Type.self)
|
||||
|
||||
let session = akAppleIDSession.init(identifier: "com.apple.gs.xcode.auth")
|
||||
let headers = session.appleIDHeaders(for: request)
|
||||
|
||||
let device = akDevice.current
|
||||
let date = dateFormatter.date(from: headers["X-Apple-I-Client-Time"] ?? "") ?? Date()
|
||||
|
||||
var localUserID = UserDefaults.standard.localUserID
|
||||
if localUserID == nil {
|
||||
localUserID = UUID().uuidString
|
||||
UserDefaults.standard.localUserID = localUserID
|
||||
}
|
||||
|
||||
let anisetteData = ALTAnisetteData(machineID: headers["X-Apple-I-MD-M"] ?? "",
|
||||
oneTimePassword: headers["X-Apple-I-MD"] ?? "",
|
||||
localUserID: headers["X-Apple-I-MD-LU"] ?? localUserID ?? "",
|
||||
routingInfo: UInt64(headers["X-Apple-I-MD-RINFO"] ?? "") ?? 0,
|
||||
deviceUniqueIdentifier: device.uniqueDeviceIdentifier,
|
||||
deviceSerialNumber: device.serialNumber,
|
||||
deviceDescription: "<MacBookPro15,1> <Mac OS X;10.15.2;19C57> <com.apple.AuthKit/1 (com.apple.dt.Xcode/3594.4.19)>",
|
||||
date: date,
|
||||
locale: .current,
|
||||
timeZone: .current)
|
||||
return anisetteData
|
||||
}
|
||||
}
|
||||
116
Sources/SideDaemon/AppManager.swift
Normal file
116
Sources/SideDaemon/AppManager.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// AppManager.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import AltSign
|
||||
|
||||
private extension URL {
|
||||
static let profilesDirectoryURL = URL(fileURLWithPath: "/var/MobileDevice/ProvisioningProfiles", isDirectory: true)
|
||||
}
|
||||
|
||||
private extension CFNotificationName {
|
||||
static let updatedProvisioningProfiles = CFNotificationName("MISProvisioningProfileRemoved" as CFString)
|
||||
}
|
||||
|
||||
struct AppManager {
|
||||
static let shared = AppManager()
|
||||
|
||||
private let appQueue = DispatchQueue(label: "com.rileytestut.AltDaemon.appQueue", qos: .userInitiated)
|
||||
private let profilesQueue = OperationQueue()
|
||||
|
||||
private let fileCoordinator = NSFileCoordinator()
|
||||
|
||||
private init() {
|
||||
profilesQueue.name = "com.rileytestut.AltDaemon.profilesQueue"
|
||||
profilesQueue.qualityOfService = .userInitiated
|
||||
}
|
||||
|
||||
func installApp(at fileURL: URL, bundleIdentifier: String, activeProfiles _: Set<String>?, completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
appQueue.async {
|
||||
let lsApplicationWorkspace = unsafeBitCast(NSClassFromString("LSApplicationWorkspace")!, to: LSApplicationWorkspace.Type.self)
|
||||
|
||||
let options = ["CFBundleIdentifier": bundleIdentifier, "AllowInstallLocalProvisioned": NSNumber(value: true)] as [String: Any]
|
||||
let result = Result { try lsApplicationWorkspace.default.installApplication(fileURL, withOptions: options) }
|
||||
|
||||
completionHandler(result)
|
||||
}
|
||||
}
|
||||
|
||||
func removeApp(forBundleIdentifier bundleIdentifier: String, completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
appQueue.async {
|
||||
let lsApplicationWorkspace = unsafeBitCast(NSClassFromString("LSApplicationWorkspace")!, to: LSApplicationWorkspace.Type.self)
|
||||
lsApplicationWorkspace.default.uninstallApplication(bundleIdentifier, withOptions: nil)
|
||||
|
||||
completionHandler(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
func install(_ profiles: Set<ALTProvisioningProfile>, activeProfiles: Set<String>?, completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
let intent = NSFileAccessIntent.writingIntent(with: .profilesDirectoryURL, options: [])
|
||||
fileCoordinator.coordinate(with: [intent], queue: profilesQueue) { error in
|
||||
do {
|
||||
if let error = error {
|
||||
throw error
|
||||
}
|
||||
|
||||
let installingBundleIDs = Set(profiles.map(\.bundleIdentifier))
|
||||
|
||||
let profileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, includingPropertiesForKeys: nil, options: [])
|
||||
|
||||
// Remove all inactive profiles (if active profiles are provided), and the previous profiles.
|
||||
for fileURL in profileURLs {
|
||||
// Use memory mapping to reduce peak memory usage and stay within limit.
|
||||
guard let profile = try? ALTProvisioningProfile(url: fileURL, options: [.mappedIfSafe]) else { continue }
|
||||
|
||||
if installingBundleIDs.contains(profile.bundleIdentifier) || (activeProfiles?.contains(profile.bundleIdentifier) == false && profile.isFreeProvisioningProfile) {
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
} else {
|
||||
print("Ignoring:", profile.bundleIdentifier, profile.uuid)
|
||||
}
|
||||
}
|
||||
|
||||
for profile in profiles {
|
||||
let destinationURL = URL.profilesDirectoryURL.appendingPathComponent(profile.uuid.uuidString.lowercased())
|
||||
try profile.data.write(to: destinationURL, options: .atomic)
|
||||
}
|
||||
|
||||
completionHandler(.success(()))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
|
||||
// Notify system to prevent accidentally untrusting developer certificate.
|
||||
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), .updatedProvisioningProfiles, nil, nil, true)
|
||||
}
|
||||
}
|
||||
|
||||
func removeProvisioningProfiles(forBundleIdentifiers bundleIdentifiers: Set<String>, completionHandler: @escaping (Result<Void, Error>) -> Void) {
|
||||
let intent = NSFileAccessIntent.writingIntent(with: .profilesDirectoryURL, options: [])
|
||||
fileCoordinator.coordinate(with: [intent], queue: profilesQueue) { error in
|
||||
do {
|
||||
let profileURLs = try FileManager.default.contentsOfDirectory(at: intent.url, includingPropertiesForKeys: nil, options: [])
|
||||
|
||||
for fileURL in profileURLs {
|
||||
guard let profile = ALTProvisioningProfile(url: fileURL) else { continue }
|
||||
|
||||
if bundleIdentifiers.contains(profile.bundleIdentifier) {
|
||||
try FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler(.success(()))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
|
||||
// Notify system to prevent accidentally untrusting developer certificate.
|
||||
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), .updatedProvisioningProfiles, nil, nil, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
107
Sources/SideDaemon/DaemonRequestHandler.swift
Normal file
107
Sources/SideDaemon/DaemonRequestHandler.swift
Normal file
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// DaemonRequestHandler.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/1/20.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
typealias DaemonConnectionManager = ConnectionManager<DaemonRequestHandler>
|
||||
|
||||
private let connectionManager = ConnectionManager(requestHandler: DaemonRequestHandler(),
|
||||
connectionHandlers: [XPCConnectionHandler()])
|
||||
|
||||
extension DaemonConnectionManager {
|
||||
static var shared: ConnectionManager {
|
||||
connectionManager
|
||||
}
|
||||
}
|
||||
|
||||
struct DaemonRequestHandler: RequestHandler {
|
||||
func handleAnisetteDataRequest(_: AnisetteDataRequest, for _: Connection, completionHandler: @escaping (Result<AnisetteDataResponse, Error>) -> Void) {
|
||||
do {
|
||||
let anisetteData = try AnisetteDataManager.shared.requestAnisetteData()
|
||||
|
||||
let response = AnisetteDataResponse(anisetteData: anisetteData)
|
||||
completionHandler(.success(response))
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
func handlePrepareAppRequest(_ request: PrepareAppRequest, for connection: Connection, completionHandler: @escaping (Result<InstallationProgressResponse, Error>) -> Void) {
|
||||
guard let fileURL = request.fileURL else { return completionHandler(.failure(ALTServerError(.invalidRequest))) }
|
||||
|
||||
print("Awaiting begin installation request...")
|
||||
|
||||
connection.receiveRequest { result in
|
||||
print("Received begin installation request with result:", result)
|
||||
|
||||
do {
|
||||
guard case let .beginInstallation(request) = try result.get() else { throw ALTServerError(.unknownRequest) }
|
||||
guard let bundleIdentifier = request.bundleIdentifier else { throw ALTServerError(.invalidRequest) }
|
||||
|
||||
AppManager.shared.installApp(at: fileURL, bundleIdentifier: bundleIdentifier, activeProfiles: request.activeProfiles) { result in
|
||||
let result = result.map { InstallationProgressResponse(progress: 1.0) }
|
||||
print("Installed app with result:", result)
|
||||
|
||||
completionHandler(result)
|
||||
}
|
||||
} catch {
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleInstallProvisioningProfilesRequest(_ request: InstallProvisioningProfilesRequest, for _: Connection,
|
||||
completionHandler: @escaping (Result<InstallProvisioningProfilesResponse, Error>) -> Void) {
|
||||
AppManager.shared.install(request.provisioningProfiles, activeProfiles: request.activeProfiles) { result in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
print("Failed to install profiles \(request.provisioningProfiles.map { $0.bundleIdentifier }):", error)
|
||||
completionHandler(.failure(error))
|
||||
|
||||
case .success:
|
||||
print("Installed profiles:", request.provisioningProfiles.map { $0.bundleIdentifier })
|
||||
|
||||
let response = InstallProvisioningProfilesResponse()
|
||||
completionHandler(.success(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveProvisioningProfilesRequest(_ request: RemoveProvisioningProfilesRequest, for _: Connection,
|
||||
completionHandler: @escaping (Result<RemoveProvisioningProfilesResponse, Error>) -> Void) {
|
||||
AppManager.shared.removeProvisioningProfiles(forBundleIdentifiers: request.bundleIdentifiers) { result in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
print("Failed to remove profiles \(request.bundleIdentifiers):", error)
|
||||
completionHandler(.failure(error))
|
||||
|
||||
case .success:
|
||||
print("Removed profiles:", request.bundleIdentifiers)
|
||||
|
||||
let response = RemoveProvisioningProfilesResponse()
|
||||
completionHandler(.success(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemoveAppRequest(_ request: RemoveAppRequest, for _: Connection, completionHandler: @escaping (Result<RemoveAppResponse, Error>) -> Void) {
|
||||
AppManager.shared.removeApp(forBundleIdentifier: request.bundleIdentifier) { result in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
print("Failed to remove app \(request.bundleIdentifier):", error)
|
||||
completionHandler(.failure(error))
|
||||
|
||||
case .success:
|
||||
print("Removed app:", request.bundleIdentifier)
|
||||
|
||||
let response = RemoveAppResponse()
|
||||
completionHandler(.success(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
Sources/SideDaemon/XPCConnectionHandler.swift
Normal file
84
Sources/SideDaemon/XPCConnectionHandler.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// XPCConnectionHandler.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 9/14/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
class XPCConnectionHandler: NSObject, ConnectionHandler {
|
||||
var connectionHandler: ((Connection) -> Void)?
|
||||
var disconnectionHandler: ((Connection) -> Void)?
|
||||
|
||||
private let dispatchQueue = DispatchQueue(label: "io.altstore.XPCConnectionListener", qos: .utility)
|
||||
private let listeners = XPCConnection.machServiceNames.map { NSXPCListener.makeListener(machServiceName: $0) }
|
||||
|
||||
deinit {
|
||||
self.stopListening()
|
||||
}
|
||||
|
||||
func startListening() {
|
||||
for listener in listeners {
|
||||
listener.delegate = self
|
||||
listener.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func stopListening() {
|
||||
listeners.forEach { $0.suspend() }
|
||||
}
|
||||
}
|
||||
|
||||
private extension XPCConnectionHandler {
|
||||
func disconnect(_ connection: Connection) {
|
||||
connection.disconnect()
|
||||
|
||||
disconnectionHandler?(connection)
|
||||
}
|
||||
}
|
||||
|
||||
extension XPCConnectionHandler: NSXPCListenerDelegate {
|
||||
func listener(_: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
|
||||
let maximumPathLength = 4 * UInt32(MAXPATHLEN)
|
||||
|
||||
let pathBuffer = UnsafeMutablePointer<CChar>.allocate(capacity: Int(maximumPathLength))
|
||||
defer { pathBuffer.deallocate() }
|
||||
|
||||
proc_pidpath(newConnection.processIdentifier, pathBuffer, maximumPathLength)
|
||||
|
||||
let path = String(cString: pathBuffer)
|
||||
let fileURL = URL(fileURLWithPath: path)
|
||||
|
||||
var code: UnsafeMutableRawPointer?
|
||||
defer { code.map { Unmanaged<AnyObject>.fromOpaque($0).release() } }
|
||||
|
||||
var status = SecStaticCodeCreateWithPath(fileURL as CFURL, 0, &code)
|
||||
guard status == 0 else { return false }
|
||||
|
||||
var signingInfo: CFDictionary?
|
||||
defer { signingInfo.map { Unmanaged<AnyObject>.passUnretained($0).release() } }
|
||||
|
||||
status = SecCodeCopySigningInformation(code, kSecCSInternalInformation | kSecCSSigningInformation, &signingInfo)
|
||||
guard status == 0 else { return false }
|
||||
|
||||
// Only accept connections from AltStore.
|
||||
guard
|
||||
let codeSigningInfo = signingInfo as? [String: Any],
|
||||
let bundleIdentifier = codeSigningInfo["identifier"] as? String,
|
||||
bundleIdentifier.contains(Bundle.Info.appbundleIdentifier)
|
||||
else { return false }
|
||||
|
||||
let connection = XPCConnection(newConnection)
|
||||
newConnection.invalidationHandler = { [weak self, weak connection] in
|
||||
guard let self = self, let connection = connection else { return }
|
||||
self.disconnect(connection)
|
||||
}
|
||||
|
||||
connectionHandler?(connection)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
14
Sources/SideDaemon/main.swift
Normal file
14
Sources/SideDaemon/main.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// main.swift
|
||||
// AltDaemon
|
||||
//
|
||||
// Created by Riley Testut on 6/2/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
autoreleasepool {
|
||||
DaemonConnectionManager.shared.start()
|
||||
RunLoop.current.run()
|
||||
}
|
||||
10
Sources/SideDaemon/package/DEBIAN/control
Normal file
10
Sources/SideDaemon/package/DEBIAN/control
Normal file
@@ -0,0 +1,10 @@
|
||||
Package: com.rileytestut.altdaemon
|
||||
Name: AltDaemon
|
||||
Depends:
|
||||
Version: 1.0
|
||||
Architecture: iphoneos-arm
|
||||
Description: AltDaemon allows AltStore to install and refresh apps without a computer.
|
||||
Maintainer: Riley Testut
|
||||
Author: Riley Testut
|
||||
Homepage: https://altstore.io
|
||||
Section: System
|
||||
2
Sources/SideDaemon/package/DEBIAN/postinst
Executable file
2
Sources/SideDaemon/package/DEBIAN/postinst
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
launchctl load /Library/LaunchDaemons/com.rileytestut.altdaemon.plist
|
||||
2
Sources/SideDaemon/package/DEBIAN/preinst
Executable file
2
Sources/SideDaemon/package/DEBIAN/preinst
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
launchctl unload /Library/LaunchDaemons/com.rileytestut.altdaemon.plist >> /dev/null 2>&1
|
||||
2
Sources/SideDaemon/package/DEBIAN/prerm
Executable file
2
Sources/SideDaemon/package/DEBIAN/prerm
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
launchctl unload /Library/LaunchDaemons/com.rileytestut.altdaemon.plist
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.rileytestut.altdaemon</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/bin/env</string>
|
||||
<string>_MSSafeMode=1</string>
|
||||
<string>_SafeMode=1</string>
|
||||
<string>/usr/bin/AltDaemon</string>
|
||||
</array>
|
||||
<key>UserName</key>
|
||||
<string>mobile</string>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
<key>MachServices</key>
|
||||
<dict>
|
||||
<key>cy:io.altstore.altdaemon</key>
|
||||
<true/>
|
||||
<key>lh:io.altstore.altdaemon</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
Sources/SideDaemon/package/usr/bin/AltDaemon
Executable file
BIN
Sources/SideDaemon/package/usr/bin/AltDaemon
Executable file
Binary file not shown.
18
Sources/SideStore/ALTApplication+AltStoreApp.swift
Normal file
18
Sources/SideStore/ALTApplication+AltStoreApp.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// ALTApplication+AltStoreApp.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 11/11/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AltSign
|
||||
|
||||
extension ALTApplication {
|
||||
static let altstoreBundleID = Bundle.Info.appbundleIdentifier
|
||||
|
||||
var isAltStoreApp: Bool {
|
||||
let isAltStoreApp = bundleIdentifier.contains(ALTApplication.altstoreBundleID)
|
||||
return isAltStoreApp
|
||||
}
|
||||
}
|
||||
90
Sources/SideStore/Analytics/AnalyticsManager.swift
Normal file
90
Sources/SideStore/Analytics/AnalyticsManager.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// AnalyticsManager.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/31/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
import AppCenter
|
||||
import AppCenterAnalytics
|
||||
import AppCenterCrashes
|
||||
|
||||
private let appCenterAppSecret = "73532d3e-e573-4693-99a4-9f85840bbb44"
|
||||
|
||||
extension AnalyticsManager {
|
||||
enum EventProperty: String {
|
||||
case name
|
||||
case bundleIdentifier
|
||||
case developerName
|
||||
case version
|
||||
case size
|
||||
case tintColor
|
||||
case sourceIdentifier
|
||||
case sourceURL
|
||||
}
|
||||
|
||||
enum Event {
|
||||
case installedApp(InstalledApp)
|
||||
case updatedApp(InstalledApp)
|
||||
case refreshedApp(InstalledApp)
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .installedApp: return "installed_app"
|
||||
case .updatedApp: return "updated_app"
|
||||
case .refreshedApp: return "refreshed_app"
|
||||
}
|
||||
}
|
||||
|
||||
var properties: [EventProperty: String] {
|
||||
let properties: [EventProperty: String?]
|
||||
|
||||
switch self {
|
||||
case let .installedApp(app), let .updatedApp(app), let .refreshedApp(app):
|
||||
let appBundleURL = InstalledApp.fileURL(for: app)
|
||||
let appBundleSize = FileManager.default.directorySize(at: appBundleURL)
|
||||
|
||||
properties = [
|
||||
.name: app.name,
|
||||
.bundleIdentifier: app.bundleIdentifier,
|
||||
.developerName: app.storeApp?.developerName,
|
||||
.version: app.version,
|
||||
.size: appBundleSize?.description,
|
||||
.tintColor: app.storeApp?.tintColor?.hexString,
|
||||
.sourceIdentifier: app.storeApp?.sourceIdentifier,
|
||||
.sourceURL: app.storeApp?.source?.sourceURL.absoluteString
|
||||
]
|
||||
}
|
||||
|
||||
return properties.compactMapValues { $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AnalyticsManager {
|
||||
static let shared = AnalyticsManager()
|
||||
|
||||
private init() {}
|
||||
}
|
||||
|
||||
extension AnalyticsManager {
|
||||
func start() {
|
||||
AppCenter.start(withAppSecret: appCenterAppSecret, services: [
|
||||
Analytics.self,
|
||||
Crashes.self
|
||||
])
|
||||
}
|
||||
|
||||
func trackEvent(_ event: Event) {
|
||||
let properties = event.properties.reduce(into: [:]) { properties, item in
|
||||
properties[item.key.rawValue] = item.value
|
||||
}
|
||||
|
||||
Analytics.trackEvent(event.name, withProperties: properties)
|
||||
}
|
||||
}
|
||||
221
Sources/SideStore/App Detail/AppContentViewController.swift
Normal file
221
Sources/SideStore/App Detail/AppContentViewController.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
//
|
||||
// AppContentViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/22/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUI
|
||||
|
||||
import Nuke
|
||||
|
||||
extension AppContentViewController {
|
||||
private enum Row: Int, CaseIterable {
|
||||
case subtitle
|
||||
case screenshots
|
||||
case description
|
||||
case versionDescription
|
||||
case permissions
|
||||
}
|
||||
}
|
||||
|
||||
final class AppContentViewController: UITableViewController {
|
||||
var app: StoreApp!
|
||||
|
||||
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
||||
private lazy var permissionsDataSource = self.makePermissionsDataSource()
|
||||
|
||||
private lazy var dateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .none
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
private lazy var byteCountFormatter: ByteCountFormatter = {
|
||||
let formatter = ByteCountFormatter()
|
||||
return formatter
|
||||
}()
|
||||
|
||||
@IBOutlet private var subtitleLabel: UILabel!
|
||||
@IBOutlet private var descriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var versionDescriptionTextView: CollapsingTextView!
|
||||
@IBOutlet private var versionLabel: UILabel!
|
||||
@IBOutlet private var versionDateLabel: UILabel!
|
||||
@IBOutlet private var sizeLabel: UILabel!
|
||||
|
||||
@IBOutlet private var screenshotsCollectionView: UICollectionView!
|
||||
@IBOutlet private var permissionsCollectionView: UICollectionView!
|
||||
|
||||
var preferredScreenshotSize: CGSize? {
|
||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
|
||||
|
||||
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
|
||||
|
||||
let itemWidth = width / 1.5
|
||||
let itemHeight = itemWidth * aspectRatio
|
||||
|
||||
return CGSize(width: itemWidth, height: itemHeight)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.contentInset.bottom = 20
|
||||
|
||||
screenshotsCollectionView.dataSource = screenshotsDataSource
|
||||
screenshotsCollectionView.prefetchDataSource = screenshotsDataSource
|
||||
|
||||
permissionsCollectionView.dataSource = permissionsDataSource
|
||||
|
||||
subtitleLabel.text = app.subtitle
|
||||
descriptionTextView.text = app.localizedDescription
|
||||
|
||||
if let version = app.latestVersion {
|
||||
versionDescriptionTextView.text = version.localizedDescription
|
||||
versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.version)
|
||||
versionDateLabel.text = Date().relativeDateString(since: version.date, dateFormatter: dateFormatter)
|
||||
sizeLabel.text = byteCountFormatter.string(fromByteCount: version.size)
|
||||
} else {
|
||||
versionDescriptionTextView.text = nil
|
||||
versionLabel.text = nil
|
||||
versionDateLabel.text = nil
|
||||
sizeLabel.text = byteCountFormatter.string(fromByteCount: 0)
|
||||
}
|
||||
|
||||
descriptionTextView.maximumNumberOfLines = 5
|
||||
descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
|
||||
versionDescriptionTextView.maximumNumberOfLines = 3
|
||||
versionDescriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
guard var size = preferredScreenshotSize else { return }
|
||||
size.height = min(size.height, screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
|
||||
|
||||
let layout = screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
layout.itemSize = size
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
guard segue.identifier == "showPermission" else { return }
|
||||
|
||||
guard let cell = sender as? UICollectionViewCell, let indexPath = permissionsCollectionView.indexPath(for: cell) else { return }
|
||||
|
||||
let permission = permissionsDataSource.item(at: indexPath)
|
||||
|
||||
let maximumWidth = view.bounds.width - 20
|
||||
|
||||
let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController
|
||||
permissionPopoverViewController.permission = permission
|
||||
permissionPopoverViewController.view.widthAnchor.constraint(lessThanOrEqualToConstant: maximumWidth).isActive = true
|
||||
|
||||
let size = permissionPopoverViewController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
permissionPopoverViewController.preferredContentSize = size
|
||||
|
||||
permissionPopoverViewController.popoverPresentationController?.delegate = self
|
||||
permissionPopoverViewController.popoverPresentationController?.sourceRect = cell.frame
|
||||
permissionPopoverViewController.popoverPresentationController?.sourceView = permissionsCollectionView
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppContentViewController {
|
||||
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage> {
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: app.screenshotURLs as [NSURL])
|
||||
dataSource.cellConfigurationHandler = { cell, _, _ in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { imageURL, _, completionHandler in
|
||||
RSTAsyncBlockOperation { operation in
|
||||
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error {
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource<AppPermission> {
|
||||
let dataSource = RSTArrayCollectionViewDataSource(items: app.permissions)
|
||||
dataSource.cellConfigurationHandler = { cell, permission, _ in
|
||||
let cell = cell as! PermissionCollectionViewCell
|
||||
cell.button.setImage(permission.type.icon, for: .normal)
|
||||
cell.button.tintColor = .label
|
||||
cell.textLabel.text = permission.type.localizedShortName ?? permission.type.localizedName
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppContentViewController {
|
||||
@objc func toggleCollapsingSection(_ sender: UIButton) {
|
||||
let indexPath: IndexPath
|
||||
|
||||
switch sender {
|
||||
case descriptionTextView.moreButton: indexPath = IndexPath(row: Row.description.rawValue, section: 0)
|
||||
case versionDescriptionTextView.moreButton: indexPath = IndexPath(row: Row.versionDescription.rawValue, section: 0)
|
||||
default: return
|
||||
}
|
||||
|
||||
// Disable animations to prevent some potentially strange ones.
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppContentViewController {
|
||||
override func tableView(_: UITableView, willDisplay cell: UITableViewCell, forRowAt _: IndexPath) {
|
||||
cell.tintColor = app.tintColor
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
switch Row.allCases[indexPath.row] {
|
||||
case .screenshots:
|
||||
guard let size = preferredScreenshotSize else { return 0.0 }
|
||||
return size.height
|
||||
|
||||
case .permissions:
|
||||
guard !app.permissions.isEmpty else { return 0.0 }
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
|
||||
default:
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppContentViewController: UIPopoverPresentationControllerDelegate {
|
||||
func adaptivePresentationStyle(for _: UIPresentationController, traitCollection _: UITraitCollection) -> UIModalPresentationStyle {
|
||||
.none
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// AppContentViewControllerCells.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/24/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class PermissionCollectionViewCell: UICollectionViewCell {
|
||||
@IBOutlet var button: UIButton!
|
||||
@IBOutlet var textLabel: UILabel!
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
button.layer.cornerRadius = button.bounds.midY
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
button.backgroundColor = tintColor.withAlphaComponent(0.15)
|
||||
textLabel.textColor = tintColor
|
||||
}
|
||||
}
|
||||
|
||||
final class AppContentTableViewCell: UITableViewCell {
|
||||
override func systemLayoutSizeFitting(_ targetSize: CGSize, withHorizontalFittingPriority horizontalFittingPriority: UILayoutPriority, verticalFittingPriority: UILayoutPriority) -> CGSize {
|
||||
// Ensure cell is laid out so it will report correct size.
|
||||
layoutIfNeeded()
|
||||
|
||||
let size = super.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: horizontalFittingPriority, verticalFittingPriority: verticalFittingPriority)
|
||||
|
||||
return size
|
||||
}
|
||||
}
|
||||
503
Sources/SideStore/App Detail/AppViewController.swift
Normal file
503
Sources/SideStore/App Detail/AppViewController.swift
Normal file
@@ -0,0 +1,503 @@
|
||||
//
|
||||
// AppViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/22/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUI
|
||||
|
||||
import Nuke
|
||||
|
||||
final class AppViewController: UIViewController {
|
||||
var app: StoreApp!
|
||||
|
||||
private var contentViewController: AppContentViewController!
|
||||
private var contentViewControllerShadowView: UIView!
|
||||
|
||||
private var blurAnimator: UIViewPropertyAnimator?
|
||||
private var navigationBarAnimator: UIViewPropertyAnimator?
|
||||
|
||||
private var contentSizeObservation: NSKeyValueObservation?
|
||||
|
||||
@IBOutlet private var scrollView: UIScrollView!
|
||||
@IBOutlet private var contentView: UIView!
|
||||
|
||||
@IBOutlet private var bannerView: AppBannerView!
|
||||
|
||||
@IBOutlet private var backButton: UIButton!
|
||||
@IBOutlet private var backButtonContainerView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var backgroundAppIconImageView: UIImageView!
|
||||
@IBOutlet private var backgroundBlurView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var navigationBarTitleView: UIView!
|
||||
@IBOutlet private var navigationBarDownloadButton: PillButton!
|
||||
@IBOutlet private var navigationBarAppIconImageView: UIImageView!
|
||||
@IBOutlet private var navigationBarAppNameLabel: UILabel!
|
||||
|
||||
private var _shouldResetLayout = false
|
||||
private var _backgroundBlurEffect: UIBlurEffect?
|
||||
private var _backgroundBlurTintColor: UIColor?
|
||||
|
||||
private var _preferredStatusBarStyle: UIStatusBarStyle = .default
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
_preferredStatusBarStyle
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
navigationBarTitleView.sizeToFit()
|
||||
navigationItem.titleView = navigationBarTitleView
|
||||
|
||||
contentViewControllerShadowView = UIView()
|
||||
contentViewControllerShadowView.backgroundColor = .white
|
||||
contentViewControllerShadowView.layer.cornerRadius = 38
|
||||
contentViewControllerShadowView.layer.shadowColor = UIColor.black.cgColor
|
||||
contentViewControllerShadowView.layer.shadowOffset = CGSize(width: 0, height: -1)
|
||||
contentViewControllerShadowView.layer.shadowRadius = 10
|
||||
contentViewControllerShadowView.layer.shadowOpacity = 0.3
|
||||
contentViewController.view.superview?.insertSubview(contentViewControllerShadowView, at: 0)
|
||||
|
||||
contentView.addGestureRecognizer(scrollView.panGestureRecognizer)
|
||||
|
||||
contentViewController.view.layer.cornerRadius = 38
|
||||
contentViewController.view.layer.masksToBounds = true
|
||||
|
||||
contentViewController.tableView.panGestureRecognizer.require(toFail: scrollView.panGestureRecognizer)
|
||||
contentViewController.tableView.showsVerticalScrollIndicator = false
|
||||
|
||||
// Bring to front so the scroll indicators are visible.
|
||||
view.bringSubviewToFront(scrollView)
|
||||
scrollView.isUserInteractionEnabled = false
|
||||
|
||||
bannerView.frame = CGRect(x: 0, y: 0, width: 300, height: 93)
|
||||
bannerView.backgroundEffectView.effect = UIBlurEffect(style: .regular)
|
||||
bannerView.backgroundEffectView.backgroundColor = .clear
|
||||
bannerView.iconImageView.image = nil
|
||||
bannerView.iconImageView.tintColor = app.tintColor
|
||||
bannerView.button.tintColor = app.tintColor
|
||||
bannerView.tintColor = app.tintColor
|
||||
|
||||
bannerView.configure(for: app)
|
||||
bannerView.accessibilityTraits.remove(.button)
|
||||
|
||||
bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
|
||||
backButtonContainerView.tintColor = app.tintColor
|
||||
|
||||
navigationController?.navigationBar.tintColor = app.tintColor
|
||||
navigationBarDownloadButton.tintColor = app.tintColor
|
||||
navigationBarAppNameLabel.text = app.name
|
||||
navigationBarAppIconImageView.tintColor = app.tintColor
|
||||
|
||||
contentSizeObservation = contentViewController.tableView.observe(\.contentSize) { [weak self] _, _ in
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
update()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didChangeApp(_:)), name: .NSManagedObjectContextObjectsDidChange, object: DatabaseManager.shared.viewContext)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AppViewController.didBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)
|
||||
|
||||
_backgroundBlurEffect = backgroundBlurView.effect as? UIBlurEffect
|
||||
_backgroundBlurTintColor = backgroundBlurView.contentView.backgroundColor
|
||||
|
||||
// Load Images
|
||||
for imageView in [bannerView.iconImageView!, backgroundAppIconImageView!, navigationBarAppIconImageView!] {
|
||||
imageView.isIndicatingActivity = true
|
||||
|
||||
Nuke.loadImage(with: app.iconURL, options: .shared, into: imageView, progress: nil) { [weak imageView] response, _ in
|
||||
if response?.image != nil {
|
||||
imageView?.isIndicatingActivity = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
prepareBlur()
|
||||
|
||||
// Update blur immediately.
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
|
||||
transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||
self.hideNavigationBar()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
_shouldResetLayout = true
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Guard against "dismissing" when presenting via 3D Touch pop.
|
||||
guard self.navigationController != nil else { return }
|
||||
|
||||
// Store reference since self.navigationController will be nil after disappearing.
|
||||
let navigationController = self.navigationController
|
||||
navigationController?.navigationBar.barStyle = .default // Don't animate, or else status bar might appear messed-up.
|
||||
|
||||
transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||
self.showNavigationBar(for: navigationController)
|
||||
}, completion: { context in
|
||||
if !context.isCancelled {
|
||||
self.showNavigationBar(for: navigationController)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if navigationController == nil {
|
||||
resetNavigationBarAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
override func prepare(for segue: UIStoryboardSegue, sender _: Any?) {
|
||||
guard segue.identifier == "embedAppContentViewController" else { return }
|
||||
|
||||
contentViewController = segue.destination as? AppContentViewController
|
||||
contentViewController.app = app
|
||||
|
||||
if #available(iOS 15, *) {
|
||||
// Fix navigation bar + tab bar appearance on iOS 15.
|
||||
self.setContentScrollView(self.scrollView)
|
||||
self.navigationItem.scrollEdgeAppearance = self.navigationController?.navigationBar.standardAppearance
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
if _shouldResetLayout {
|
||||
// Various events can cause UI to mess up, so reset affected components now.
|
||||
|
||||
if navigationController?.topViewController == self {
|
||||
hideNavigationBar()
|
||||
}
|
||||
|
||||
prepareBlur()
|
||||
|
||||
// Reset navigation bar animation, and create a new one later in this method if necessary.
|
||||
resetNavigationBarAnimation()
|
||||
|
||||
_shouldResetLayout = false
|
||||
}
|
||||
|
||||
let statusBarHeight = UIApplication.shared.statusBarFrame.height
|
||||
let cornerRadius = contentViewControllerShadowView.layer.cornerRadius
|
||||
|
||||
let inset = 12 as CGFloat
|
||||
let padding = 20 as CGFloat
|
||||
|
||||
let backButtonSize = backButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
||||
var backButtonFrame = CGRect(x: inset, y: statusBarHeight,
|
||||
width: backButtonSize.width + 20, height: backButtonSize.height + 20)
|
||||
|
||||
var headerFrame = CGRect(x: inset, y: 0, width: view.bounds.width - inset * 2, height: bannerView.bounds.height)
|
||||
var contentFrame = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height)
|
||||
var backgroundIconFrame = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.width)
|
||||
|
||||
let minimumHeaderY = backButtonFrame.maxY + 8
|
||||
|
||||
let minimumContentY = minimumHeaderY + headerFrame.height + padding
|
||||
let maximumContentY = view.bounds.width * 0.667
|
||||
|
||||
// A full blur is too much, so we reduce the visible blur by 0.3, resulting in 70% blur.
|
||||
let minimumBlurFraction = 0.3 as CGFloat
|
||||
|
||||
contentFrame.origin.y = maximumContentY - scrollView.contentOffset.y
|
||||
headerFrame.origin.y = contentFrame.origin.y - padding - headerFrame.height
|
||||
|
||||
// Stretch the app icon image to fill additional vertical space if necessary.
|
||||
let height = max(contentFrame.origin.y + cornerRadius * 2, backgroundIconFrame.height)
|
||||
backgroundIconFrame.size.height = height
|
||||
|
||||
let blurThreshold = 0 as CGFloat
|
||||
if scrollView.contentOffset.y < blurThreshold {
|
||||
// Determine how much to lessen blur by.
|
||||
|
||||
let range = 75 as CGFloat
|
||||
let difference = -scrollView.contentOffset.y
|
||||
|
||||
let fraction = min(difference, range) / range
|
||||
|
||||
let fractionComplete = (fraction * (1.0 - minimumBlurFraction)) + minimumBlurFraction
|
||||
blurAnimator?.fractionComplete = fractionComplete
|
||||
} else {
|
||||
// Set blur to default.
|
||||
|
||||
blurAnimator?.fractionComplete = minimumBlurFraction
|
||||
}
|
||||
|
||||
// Animate navigation bar.
|
||||
let showNavigationBarThreshold = (maximumContentY - minimumContentY) + backButtonFrame.origin.y
|
||||
if scrollView.contentOffset.y > showNavigationBarThreshold {
|
||||
if navigationBarAnimator == nil {
|
||||
prepareNavigationBarAnimation()
|
||||
}
|
||||
|
||||
let difference = scrollView.contentOffset.y - showNavigationBarThreshold
|
||||
let range = (headerFrame.height + padding) - (navigationController?.navigationBar.bounds.height ?? view.safeAreaInsets.top)
|
||||
|
||||
let fractionComplete = min(difference, range) / range
|
||||
navigationBarAnimator?.fractionComplete = fractionComplete
|
||||
} else {
|
||||
resetNavigationBarAnimation()
|
||||
}
|
||||
|
||||
let beginMovingBackButtonThreshold = (maximumContentY - minimumContentY)
|
||||
if scrollView.contentOffset.y > beginMovingBackButtonThreshold {
|
||||
let difference = scrollView.contentOffset.y - beginMovingBackButtonThreshold
|
||||
backButtonFrame.origin.y -= difference
|
||||
}
|
||||
|
||||
let pinContentToTopThreshold = maximumContentY
|
||||
if scrollView.contentOffset.y > pinContentToTopThreshold {
|
||||
contentFrame.origin.y = 0
|
||||
backgroundIconFrame.origin.y = 0
|
||||
|
||||
let difference = scrollView.contentOffset.y - pinContentToTopThreshold
|
||||
contentViewController.tableView.contentOffset.y = difference
|
||||
} else {
|
||||
// Keep content table view's content offset at the top.
|
||||
contentViewController.tableView.contentOffset.y = 0
|
||||
}
|
||||
|
||||
// Keep background app icon centered in gap between top of content and top of screen.
|
||||
backgroundIconFrame.origin.y = (contentFrame.origin.y / 2) - backgroundIconFrame.height / 2
|
||||
|
||||
// Set frames.
|
||||
contentViewController.view.superview?.frame = contentFrame
|
||||
bannerView.frame = headerFrame
|
||||
backgroundAppIconImageView.frame = backgroundIconFrame
|
||||
backgroundBlurView.frame = backgroundIconFrame
|
||||
backButtonContainerView.frame = backButtonFrame
|
||||
|
||||
contentViewControllerShadowView.frame = contentViewController.view.frame
|
||||
|
||||
backButtonContainerView.layer.cornerRadius = backButtonContainerView.bounds.midY
|
||||
|
||||
scrollView.scrollIndicatorInsets.top = statusBarHeight
|
||||
|
||||
// Adjust content offset + size.
|
||||
let contentOffset = scrollView.contentOffset
|
||||
|
||||
var contentSize = contentViewController.tableView.contentSize
|
||||
contentSize.height += maximumContentY
|
||||
|
||||
scrollView.contentSize = contentSize
|
||||
scrollView.contentOffset = contentOffset
|
||||
|
||||
bannerView.backgroundEffectView.backgroundColor = .clear
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
_shouldResetLayout = true
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.blurAnimator?.stopAnimation(true)
|
||||
self.navigationBarAnimator?.stopAnimation(true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AppViewController {
|
||||
final class func makeAppViewController(app: StoreApp) -> AppViewController {
|
||||
let storyboard = UIStoryboard(name: "Main", bundle: nil)
|
||||
|
||||
let appViewController = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
|
||||
appViewController.app = app
|
||||
return appViewController
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppViewController {
|
||||
func update() {
|
||||
for button in [bannerView.button!, navigationBarDownloadButton!] {
|
||||
button.tintColor = app.tintColor
|
||||
button.isIndicatingActivity = false
|
||||
|
||||
if app.installedApp == nil {
|
||||
button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal)
|
||||
} else {
|
||||
button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
}
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
button.progress = progress
|
||||
}
|
||||
|
||||
if let versionDate = app.latestVersion?.date, versionDate > Date() {
|
||||
bannerView.button.countdownDate = versionDate
|
||||
navigationBarDownloadButton.countdownDate = versionDate
|
||||
} else {
|
||||
bannerView.button.countdownDate = nil
|
||||
navigationBarDownloadButton.countdownDate = nil
|
||||
}
|
||||
|
||||
let barButtonItem = navigationItem.rightBarButtonItem
|
||||
navigationItem.rightBarButtonItem = nil
|
||||
navigationItem.rightBarButtonItem = barButtonItem
|
||||
}
|
||||
|
||||
func showNavigationBar(for navigationController: UINavigationController? = nil) {
|
||||
let navigationController = navigationController ?? self.navigationController
|
||||
navigationController?.navigationBar.alpha = 1.0
|
||||
navigationController?.navigationBar.tintColor = .altPrimary
|
||||
navigationController?.navigationBar.setNeedsLayout()
|
||||
|
||||
if traitCollection.userInterfaceStyle == .dark {
|
||||
_preferredStatusBarStyle = .lightContent
|
||||
} else {
|
||||
_preferredStatusBarStyle = .default
|
||||
}
|
||||
|
||||
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
func hideNavigationBar(for navigationController: UINavigationController? = nil) {
|
||||
let navigationController = navigationController ?? self.navigationController
|
||||
navigationController?.navigationBar.alpha = 0.0
|
||||
|
||||
_preferredStatusBarStyle = .lightContent
|
||||
navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
}
|
||||
|
||||
func prepareBlur() {
|
||||
if let animator = blurAnimator {
|
||||
animator.stopAnimation(true)
|
||||
}
|
||||
|
||||
backgroundBlurView.effect = _backgroundBlurEffect
|
||||
backgroundBlurView.contentView.backgroundColor = _backgroundBlurTintColor
|
||||
|
||||
blurAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||
self?.backgroundBlurView.effect = nil
|
||||
self?.backgroundBlurView.contentView.backgroundColor = .clear
|
||||
}
|
||||
|
||||
blurAnimator?.startAnimation()
|
||||
blurAnimator?.pauseAnimation()
|
||||
}
|
||||
|
||||
func prepareNavigationBarAnimation() {
|
||||
resetNavigationBarAnimation()
|
||||
|
||||
navigationBarAnimator = UIViewPropertyAnimator(duration: 1.0, curve: .linear) { [weak self] in
|
||||
self?.showNavigationBar()
|
||||
self?.navigationController?.navigationBar.tintColor = self?.app.tintColor
|
||||
self?.navigationController?.navigationBar.barTintColor = nil
|
||||
self?.contentViewController.view.layer.cornerRadius = 0
|
||||
}
|
||||
|
||||
navigationBarAnimator?.startAnimation()
|
||||
navigationBarAnimator?.pauseAnimation()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
func resetNavigationBarAnimation() {
|
||||
navigationBarAnimator?.stopAnimation(true)
|
||||
navigationBarAnimator = nil
|
||||
|
||||
hideNavigationBar()
|
||||
|
||||
contentViewController.view.layer.cornerRadius = contentViewControllerShadowView.layer.cornerRadius
|
||||
}
|
||||
}
|
||||
|
||||
extension AppViewController {
|
||||
@IBAction func popViewController(_: UIButton) {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
@IBAction func performAppAction(_: PillButton) {
|
||||
if let installedApp = app.installedApp {
|
||||
open(installedApp)
|
||||
} else {
|
||||
downloadApp()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadApp() {
|
||||
guard app.installedApp == nil else { return }
|
||||
|
||||
let group = AppManager.shared.install(app, presentingViewController: self) { result in
|
||||
do {
|
||||
_ = try result.get()
|
||||
} catch OperationError.cancelled {
|
||||
// Ignore
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.bannerView.button.progress = nil
|
||||
self.navigationBarDownloadButton.progress = nil
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
bannerView.button.progress = group.progress
|
||||
navigationBarDownloadButton.progress = group.progress
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp) {
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppViewController {
|
||||
@objc func didChangeApp(_: Notification) {
|
||||
// Async so that AppManager.installationProgress(for:) is nil when we update.
|
||||
DispatchQueue.main.async {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func willEnterForeground(_: Notification) {
|
||||
guard let navigationController = navigationController, navigationController.topViewController == self else { return }
|
||||
|
||||
_shouldResetLayout = true
|
||||
view.setNeedsLayout()
|
||||
}
|
||||
|
||||
@objc func didBecomeActive(_: Notification) {
|
||||
guard let navigationController = navigationController, navigationController.topViewController == self else { return }
|
||||
|
||||
// Fixes Navigation Bar appearing after app becomes inactive -> active again.
|
||||
_shouldResetLayout = true
|
||||
view.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
extension AppViewController: UIScrollViewDelegate {
|
||||
func scrollViewDidScroll(_: UIScrollView) {
|
||||
view.setNeedsLayout()
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// PermissionPopoverViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
|
||||
final class PermissionPopoverViewController: UIViewController {
|
||||
var permission: AppPermission!
|
||||
|
||||
@IBOutlet private var nameLabel: UILabel!
|
||||
@IBOutlet private var descriptionLabel: UILabel!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
nameLabel.text = permission.type.localizedName
|
||||
descriptionLabel.text = permission.usageDescription
|
||||
}
|
||||
}
|
||||
210
Sources/SideStore/App IDs/AppIDsViewController.swift
Normal file
210
Sources/SideStore/App IDs/AppIDsViewController.swift
Normal file
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// AppIDsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 1/27/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUI
|
||||
|
||||
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 attributedText = NSAttributedString(markdownRepresentation: text, attributes: [.font: headerView.textLabel.font as Any])
|
||||
headerView.textLabel.attributedText = attributedText
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
380
Sources/SideStore/AppDelegate.swift
Normal file
380
Sources/SideStore/AppDelegate.swift
Normal file
@@ -0,0 +1,380 @@
|
||||
//
|
||||
// AppDelegate.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Intents
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import EmotionalDamage
|
||||
import RoxasUI
|
||||
|
||||
extension AppDelegate {
|
||||
static let openPatreonSettingsDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".OpenPatreonSettingsDeepLinkNotification")
|
||||
static let importAppDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".ImportAppDeepLinkNotification")
|
||||
static let addSourceDeepLinkNotification = Notification.Name(Bundle.Info.appbundleIdentifier + ".AddSourceDeepLinkNotification")
|
||||
|
||||
static let appBackupDidFinish = Notification.Name(Bundle.Info.appbundleIdentifier + ".AppBackupDidFinish")
|
||||
|
||||
static let importAppDeepLinkURLKey = "fileURL"
|
||||
static let appBackupResultKey = "result"
|
||||
static let addSourceDeepLinkURLKey = "sourceURL"
|
||||
}
|
||||
|
||||
@UIApplicationMain
|
||||
final class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
@available(iOS 14, *)
|
||||
private var intentHandler: IntentHandler {
|
||||
get { _intentHandler as! IntentHandler }
|
||||
set { _intentHandler = newValue }
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
private var viewAppIntentHandler: ViewAppIntentHandler {
|
||||
get { _viewAppIntentHandler as! ViewAppIntentHandler }
|
||||
set { _viewAppIntentHandler = newValue }
|
||||
}
|
||||
|
||||
private lazy var _intentHandler: Any = {
|
||||
guard #available(iOS 14, *) else { fatalError() }
|
||||
return IntentHandler()
|
||||
}()
|
||||
|
||||
private lazy var _viewAppIntentHandler: Any = {
|
||||
guard #available(iOS 14, *) else { fatalError() }
|
||||
return ViewAppIntentHandler()
|
||||
}()
|
||||
|
||||
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Register default settings before doing anything else.
|
||||
UserDefaults.registerDefaults()
|
||||
|
||||
DatabaseManager.shared.start { error in
|
||||
if let error = error {
|
||||
print("Failed to start DatabaseManager. Error:", error as Any)
|
||||
} else {
|
||||
print("Started DatabaseManager.")
|
||||
}
|
||||
}
|
||||
|
||||
AnalyticsManager.shared.start()
|
||||
|
||||
setTintColor()
|
||||
|
||||
SecureValueTransformer.register()
|
||||
|
||||
if UserDefaults.standard.firstLaunch == nil {
|
||||
Keychain.shared.reset()
|
||||
UserDefaults.standard.firstLaunch = Date()
|
||||
}
|
||||
|
||||
UserDefaults.standard.preferredServerID = Bundle.main.object(forInfoDictionaryKey: Bundle.Info.serverID) as? String
|
||||
|
||||
#if DEBUG || BETA
|
||||
UserDefaults.standard.isDebugModeEnabled = true
|
||||
#endif
|
||||
|
||||
prepareForBackgroundFetch()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func applicationDidEnterBackground(_: UIApplication) {
|
||||
// Make sure to update SceneDelegate.sceneDidEnterBackground() as well.
|
||||
|
||||
guard let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { return }
|
||||
|
||||
let midnightOneMonthAgo = Calendar.current.startOfDay(for: oneMonthAgo)
|
||||
DatabaseManager.shared.purgeLoggedErrors(before: midnightOneMonthAgo) { result in
|
||||
switch result {
|
||||
case .success: break
|
||||
case let .failure(error): print("[ALTLog] Failed to purge logged errors before \(midnightOneMonthAgo).", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_: UIApplication) {
|
||||
AppManager.shared.update()
|
||||
start_em_proxy(bind_addr: Consts.Proxy.serverURL)
|
||||
}
|
||||
|
||||
func application(_: UIApplication, open url: URL, options _: [UIApplication.OpenURLOptionsKey: Any]) -> Bool {
|
||||
open(url)
|
||||
}
|
||||
|
||||
func application(_: UIApplication, handlerFor intent: INIntent) -> Any? {
|
||||
guard #available(iOS 14, *) else { return nil }
|
||||
|
||||
switch intent {
|
||||
case is RefreshAllIntent: return intentHandler
|
||||
case is ViewAppIntent: return viewAppIntentHandler
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension AppDelegate {
|
||||
func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration {
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
|
||||
func application(_: UIApplication, didDiscardSceneSessions _: Set<UISceneSession>) {
|
||||
// Called when the user discards a scene session.
|
||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDelegate {
|
||||
func setTintColor() {
|
||||
window?.tintColor = .altPrimary
|
||||
}
|
||||
|
||||
func open(_ url: URL) -> Bool {
|
||||
if url.isFileURL {
|
||||
guard url.pathExtension.lowercased() == "ipa" else { return false }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: AppDelegate.importAppDeepLinkNotification, object: nil, userInfo: [AppDelegate.importAppDeepLinkURLKey: url])
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
|
||||
guard let host = components.host?.lowercased() else { return false }
|
||||
|
||||
switch host {
|
||||
case "patreon":
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: AppDelegate.openPatreonSettingsDeepLinkNotification, object: nil)
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case "appbackupresponse":
|
||||
let result: Result<Void, Error>
|
||||
|
||||
switch url.path.lowercased() {
|
||||
case "/success": result = .success(())
|
||||
case "/failure":
|
||||
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name] = $1.value } ?? [:]
|
||||
guard
|
||||
let errorDomain = queryItems["errorDomain"],
|
||||
let errorCodeString = queryItems["errorCode"], let errorCode = Int(errorCodeString),
|
||||
let errorDescription = queryItems["errorDescription"]
|
||||
else { return false }
|
||||
|
||||
let error = NSError(domain: errorDomain, code: errorCode, userInfo: [NSLocalizedDescriptionKey: errorDescription])
|
||||
result = .failure(error)
|
||||
|
||||
default: return false
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: AppDelegate.appBackupDidFinish, object: nil, userInfo: [AppDelegate.appBackupResultKey: result])
|
||||
|
||||
return true
|
||||
|
||||
case "install":
|
||||
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||
guard let downloadURLString = queryItems["url"], let downloadURL = URL(string: downloadURLString) else { return false }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: AppDelegate.importAppDeepLinkNotification, object: nil, userInfo: [AppDelegate.importAppDeepLinkURLKey: downloadURL])
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case "source":
|
||||
let queryItems = components.queryItems?.reduce(into: [String: String]()) { $0[$1.name.lowercased()] = $1.value } ?? [:]
|
||||
guard let sourceURLString = queryItems["url"], let sourceURL = URL(string: sourceURLString) else { return false }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: AppDelegate.addSourceDeepLinkNotification, object: nil, userInfo: [AppDelegate.addSourceDeepLinkURLKey: sourceURL])
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate {
|
||||
private func prepareForBackgroundFetch() {
|
||||
// "Fetch" every hour, but then refresh only those that need to be refreshed (so we don't drain the battery).
|
||||
UIApplication.shared.setMinimumBackgroundFetchInterval(1 * 60 * 60)
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
#endif
|
||||
}
|
||||
|
||||
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data)
|
||||
{
|
||||
let tokenParts = deviceToken.map { data -> String in
|
||||
String(format: "%02.2hhx", data)
|
||||
}
|
||||
|
||||
let token = tokenParts.joined()
|
||||
print("Push Token:", token)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification _: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
self.application(application, performFetchWithCompletionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func application(_: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
if UserDefaults.standard.isBackgroundRefreshEnabled && !UserDefaults.standard.presentedLaunchReminderNotification {
|
||||
let threeHours: TimeInterval = 3 * 60 * 60
|
||||
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: threeHours, repeats: false)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("App Refresh Tip", comment: "")
|
||||
content.body = NSLocalizedString("The more you open SideStore, the more chances it's given to refresh apps in the background.", comment: "")
|
||||
|
||||
let request = UNNotificationRequest(identifier: "background-refresh-reminder5", content: content, trigger: trigger)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
|
||||
UserDefaults.standard.presentedLaunchReminderNotification = true
|
||||
}
|
||||
|
||||
BackgroundTaskManager.shared.performExtendedBackgroundTask { taskResult, taskCompletionHandler in
|
||||
if let error = taskResult.error {
|
||||
print("Error starting extended background task. Aborting.", error)
|
||||
backgroundFetchCompletionHandler(.failed)
|
||||
taskCompletionHandler()
|
||||
return
|
||||
}
|
||||
|
||||
if !DatabaseManager.shared.isStarted {
|
||||
DatabaseManager.shared.start { error in
|
||||
if error != nil {
|
||||
backgroundFetchCompletionHandler(.failed)
|
||||
taskCompletionHandler()
|
||||
} else {
|
||||
self.performBackgroundFetch { backgroundFetchResult in
|
||||
backgroundFetchCompletionHandler(backgroundFetchResult)
|
||||
} refreshAppsCompletionHandler: { _ in
|
||||
taskCompletionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.performBackgroundFetch { backgroundFetchResult in
|
||||
backgroundFetchCompletionHandler(backgroundFetchResult)
|
||||
} refreshAppsCompletionHandler: { _ in
|
||||
taskCompletionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performBackgroundFetch(backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void,
|
||||
refreshAppsCompletionHandler: @escaping (Result<[String: Result<InstalledApp, Error>], Error>) -> Void) {
|
||||
fetchSources { result in
|
||||
switch result {
|
||||
case .failure: backgroundFetchCompletionHandler(.failed)
|
||||
case .success: backgroundFetchCompletionHandler(.newData)
|
||||
}
|
||||
|
||||
if !UserDefaults.standard.isBackgroundRefreshEnabled {
|
||||
refreshAppsCompletionHandler(.success([:]))
|
||||
}
|
||||
}
|
||||
|
||||
guard UserDefaults.standard.isBackgroundRefreshEnabled else { return }
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
|
||||
let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context)
|
||||
AppManager.shared.backgroundRefresh(installedApps, completionHandler: refreshAppsCompletionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDelegate {
|
||||
func fetchSources(completionHandler: @escaping (Result<Set<Source>, Error>) -> Void) {
|
||||
AppManager.shared.fetchSources { result in
|
||||
do {
|
||||
let (sources, context) = try result.get()
|
||||
|
||||
let previousUpdatesFetchRequest = InstalledApp.updatesFetchRequest() as! NSFetchRequest<NSFetchRequestResult>
|
||||
previousUpdatesFetchRequest.includesPendingChanges = false
|
||||
previousUpdatesFetchRequest.resultType = .dictionaryResultType
|
||||
previousUpdatesFetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)]
|
||||
|
||||
let previousNewsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NSFetchRequestResult>
|
||||
previousNewsItemsFetchRequest.includesPendingChanges = false
|
||||
previousNewsItemsFetchRequest.resultType = .dictionaryResultType
|
||||
previousNewsItemsFetchRequest.propertiesToFetch = [#keyPath(NewsItem.identifier)]
|
||||
|
||||
let previousUpdates = try context.fetch(previousUpdatesFetchRequest) as! [[String: String]]
|
||||
let previousNewsItems = try context.fetch(previousNewsItemsFetchRequest) as! [[String: String]]
|
||||
|
||||
try context.save()
|
||||
|
||||
let updatesFetchRequest = InstalledApp.updatesFetchRequest()
|
||||
let newsItemsFetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
|
||||
|
||||
let updates = try context.fetch(updatesFetchRequest)
|
||||
let newsItems = try context.fetch(newsItemsFetchRequest)
|
||||
|
||||
for update in updates {
|
||||
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
|
||||
guard let storeApp = update.storeApp, let version = storeApp.version else { continue }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("New Update Available", comment: "")
|
||||
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, version)
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
for newsItem in newsItems {
|
||||
guard !previousNewsItems.contains(where: { $0[#keyPath(NewsItem.identifier)] == newsItem.identifier }) else { continue }
|
||||
guard !newsItem.isSilent else { continue }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
if let app = newsItem.storeApp {
|
||||
content.title = String(format: NSLocalizedString("%@ News", comment: ""), app.name)
|
||||
} else {
|
||||
content.title = NSLocalizedString("SideStore News", comment: "")
|
||||
}
|
||||
|
||||
content.body = newsItem.title
|
||||
content.sound = .default
|
||||
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.applicationIconBadgeNumber = updates.count
|
||||
}
|
||||
|
||||
completionHandler(.success(sources))
|
||||
} catch {
|
||||
print("Error fetching apps:", error)
|
||||
completionHandler(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
569
Sources/SideStore/Authentication/Authentication.storyboard
Normal file
569
Sources/SideStore/Authentication/Authentication.storyboard
Normal file
@@ -0,0 +1,569 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Navigation Controller-->
|
||||
<scene sceneID="lNR-II-WoW">
|
||||
<objects>
|
||||
<navigationController storyboardIdentifier="navigationController" id="ZTo-53-dSL" sceneMemberID="viewController">
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" barStyle="black" largeTitles="YES" id="Aej-RF-PfV" customClass="NavigationBar" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="96"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="barTintColor" name="SettingsBackground"/>
|
||||
<textAttributes key="titleTextAttributes">
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</textAttributes>
|
||||
<textAttributes key="largeTitleTextAttributes">
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</textAttributes>
|
||||
<userDefinedRuntimeAttributes>
|
||||
<userDefinedRuntimeAttribute type="boolean" keyPath="automaticallyAdjustsItemPositions" value="NO"/>
|
||||
</userDefinedRuntimeAttributes>
|
||||
</navigationBar>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="9J6-jc-46k" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-164" y="735"/>
|
||||
</scene>
|
||||
<!--Authentication View Controller-->
|
||||
<scene sceneID="OCd-xc-Ms7">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="authenticationViewController" id="yO1-iT-7NP" customClass="AuthenticationViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="mjy-4S-hyH">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="oyW-Fd-ojD" userLabel="Sizing View">
|
||||
<rect key="frame" x="0.0" y="44" width="375" height="623"/>
|
||||
</view>
|
||||
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" alwaysBounceVertical="YES" indicatorStyle="white" keyboardDismissMode="onDrag" translatesAutoresizingMaskIntoConstraints="NO" id="WXx-hX-AXv">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2wp-qG-f0Z">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="623"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" spacing="50" translatesAutoresizingMaskIntoConstraints="NO" id="YmX-7v-pxh">
|
||||
<rect key="frame" x="16" y="6" width="343" height="359.5"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="Yfu-hI-0B7" userLabel="Welcome">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="67.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Welcome to SideStore." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="EI2-V3-zQZ">
|
||||
<rect key="frame" x="0.0" y="0.0" width="332" height="41"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="34"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Sign in with your Apple ID to get started." textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="SNU-tv-8Au">
|
||||
<rect key="frame" x="0.0" y="47" width="306.5" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="32" translatesAutoresizingMaskIntoConstraints="NO" id="Aqh-MD-HFf">
|
||||
<rect key="frame" x="0.0" y="117.5" width="343" height="242"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="Oy6-xr-cZ7">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="159"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="H95-7V-Kk8" userLabel="Apple ID">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="72"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="KN1-Kp-M1q">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="17"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="APPLE ID" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="59N-O1-6bM">
|
||||
<rect key="frame" x="14" y="0.0" width="329" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" white="1" alpha="0.75" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="0.0"/>
|
||||
</stackView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="gNe-dC-oI1">
|
||||
<rect key="frame" x="0.0" y="21" width="343" height="51"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="name@email.com" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="DBu-vt-hlo">
|
||||
<rect key="frame" x="14" y="0.0" width="315" height="51"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="19"/>
|
||||
<textInputTraits key="textInputTraits" returnKeyType="next" textContentType="email"/>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="yO1-iT-7NP" id="G13-jV-DLX"/>
|
||||
</connections>
|
||||
</textField>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="1" alpha="0.29999999999999999" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="DBu-vt-hlo" secondAttribute="trailing" id="0Lf-vH-juh"/>
|
||||
<constraint firstItem="DBu-vt-hlo" firstAttribute="centerY" secondItem="gNe-dC-oI1" secondAttribute="centerY" id="kgs-hg-ECM"/>
|
||||
<constraint firstItem="DBu-vt-hlo" firstAttribute="height" secondItem="gNe-dC-oI1" secondAttribute="height" id="n7y-Xg-8MP"/>
|
||||
<constraint firstItem="DBu-vt-hlo" firstAttribute="leading" secondItem="gNe-dC-oI1" secondAttribute="leadingMargin" id="sat-rb-OIu"/>
|
||||
<constraint firstAttribute="height" constant="51" id="tuP-Uo-6qp"/>
|
||||
</constraints>
|
||||
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="14"/>
|
||||
</view>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="hd5-yc-rcq" userLabel="Password">
|
||||
<rect key="frame" x="0.0" y="87" width="343" height="72"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="lvX-im-C95">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="17"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="PASSWORD" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Ava-XY-7vs">
|
||||
<rect key="frame" x="14" y="0.0" width="329" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" white="1" alpha="0.75" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="0.0"/>
|
||||
</stackView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="cLc-iA-yq5">
|
||||
<rect key="frame" x="0.0" y="21" width="343" height="51"/>
|
||||
<subviews>
|
||||
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="••••••••" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="R77-TQ-lVT">
|
||||
<rect key="frame" x="14" y="0.0" width="315" height="51"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="19"/>
|
||||
<textInputTraits key="textInputTraits" returnKeyType="go" enablesReturnKeyAutomatically="YES" secureTextEntry="YES" textContentType="password"/>
|
||||
<connections>
|
||||
<outlet property="delegate" destination="yO1-iT-7NP" id="Wpg-DV-BNL"/>
|
||||
</connections>
|
||||
</textField>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="1" alpha="0.29999999999999999" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="R77-TQ-lVT" firstAttribute="leading" secondItem="cLc-iA-yq5" secondAttribute="leadingMargin" id="130-RD-MwU"/>
|
||||
<constraint firstAttribute="height" constant="51" id="9Jw-2V-fgf"/>
|
||||
<constraint firstItem="R77-TQ-lVT" firstAttribute="height" secondItem="cLc-iA-yq5" secondAttribute="height" id="FFf-Bp-LPT"/>
|
||||
<constraint firstItem="R77-TQ-lVT" firstAttribute="centerY" secondItem="cLc-iA-yq5" secondAttribute="centerY" id="agB-KM-ba3"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="R77-TQ-lVT" secondAttribute="trailing" id="jB5-Ye-cJB"/>
|
||||
</constraints>
|
||||
<edgeInsets key="layoutMargins" top="0.0" left="14" bottom="0.0" right="14"/>
|
||||
</view>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="2N5-zd-fUj">
|
||||
<rect key="frame" x="0.0" y="191" width="343" height="51"/>
|
||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="51" id="4BK-Un-5pl"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||
<state key="normal" title="Sign in">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="authenticate" destination="yO1-iT-7NP" eventType="primaryActionTriggered" id="LER-a2-CbC"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="250" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="DBk-rT-ZE8">
|
||||
<rect key="frame" x="16" y="518.5" width="343" height="96.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Why do we need this?" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="p9U-0q-Kn8">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="249" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumScaleFactor="0.25" translatesAutoresizingMaskIntoConstraints="NO" id="on2-62-waY">
|
||||
<rect key="frame" x="0.0" y="24.5" width="343" height="72"/>
|
||||
<string key="text">Your Apple ID is used to configure apps so they can be installed on this device. Your credentials will be stored securely in this device's Keychain and sent only to Apple for authentication.</string>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="15"/>
|
||||
<color key="textColor" white="1" alpha="0.75" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="DBk-rT-ZE8" firstAttribute="top" relation="greaterThanOrEqual" secondItem="YmX-7v-pxh" secondAttribute="bottom" constant="8" symbolic="YES" id="zTU-eY-DWd"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="leading" secondItem="WXx-hX-AXv" secondAttribute="leading" id="13j-ii-X7W"/>
|
||||
<constraint firstAttribute="bottom" secondItem="2wp-qG-f0Z" secondAttribute="bottom" id="Ggl-es-C4C"/>
|
||||
<constraint firstAttribute="trailing" secondItem="2wp-qG-f0Z" secondAttribute="trailing" id="nl1-88-5mM"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="top" secondItem="WXx-hX-AXv" secondAttribute="top" id="wiH-lv-L9P"/>
|
||||
</constraints>
|
||||
</scrollView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="zMn-DV-fpy"/>
|
||||
<color key="backgroundColor" name="SettingsBackground"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="bottom" secondItem="WXx-hX-AXv" secondAttribute="bottom" id="0jL-Ky-ju6"/>
|
||||
<constraint firstAttribute="leadingMargin" secondItem="YmX-7v-pxh" secondAttribute="leading" id="2PO-lG-dmB"/>
|
||||
<constraint firstItem="DBk-rT-ZE8" firstAttribute="leading" secondItem="2wp-qG-f0Z" secondAttribute="leadingMargin" id="5AT-nV-ZP9"/>
|
||||
<constraint firstItem="oyW-Fd-ojD" firstAttribute="top" secondItem="zMn-DV-fpy" secondAttribute="top" id="730-db-ukB"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="bottomMargin" secondItem="DBk-rT-ZE8" secondAttribute="bottom" id="HgY-oY-8KM"/>
|
||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="oyW-Fd-ojD" secondAttribute="trailing" id="KGE-CN-SWf"/>
|
||||
<constraint firstItem="WXx-hX-AXv" firstAttribute="top" secondItem="mjy-4S-hyH" secondAttribute="top" id="LPQ-bF-ic0"/>
|
||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="trailing" secondItem="WXx-hX-AXv" secondAttribute="trailing" id="MG7-A6-pKp"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="YmX-7v-pxh" secondAttribute="trailing" id="O4T-nu-o3e"/>
|
||||
<constraint firstItem="zMn-DV-fpy" firstAttribute="bottom" secondItem="oyW-Fd-ojD" secondAttribute="bottom" id="PuX-ab-cEq"/>
|
||||
<constraint firstItem="oyW-Fd-ojD" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="SzC-gC-Nvi"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="trailingMargin" secondItem="DBk-rT-ZE8" secondAttribute="trailing" id="VCf-bW-2K4"/>
|
||||
<constraint firstItem="WXx-hX-AXv" firstAttribute="leading" secondItem="zMn-DV-fpy" secondAttribute="leading" id="d08-zF-5X6"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="height" secondItem="oyW-Fd-ojD" secondAttribute="height" id="dFN-pw-TWt"/>
|
||||
<constraint firstItem="YmX-7v-pxh" firstAttribute="top" secondItem="2wp-qG-f0Z" secondAttribute="top" constant="6" id="iUr-Nd-tkt"/>
|
||||
<constraint firstItem="2wp-qG-f0Z" firstAttribute="width" secondItem="oyW-Fd-ojD" secondAttribute="width" id="rYO-GN-0Lk"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<toolbarItems/>
|
||||
<navigationItem key="navigationItem" largeTitleDisplayMode="never" id="jCf-N4-xVD">
|
||||
<barButtonItem key="leftBarButtonItem" title="Close" id="nDc-Zs-wnK">
|
||||
<connections>
|
||||
<action selector="cancel:" destination="yO1-iT-7NP" id="xls-in-Pre"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||
<nil key="simulatedBottomBarMetrics"/>
|
||||
<connections>
|
||||
<outlet property="appleIDBackgroundView" destination="gNe-dC-oI1" id="lab-WG-pyJ"/>
|
||||
<outlet property="appleIDTextField" destination="DBu-vt-hlo" id="ZMK-9K-phY"/>
|
||||
<outlet property="contentStackView" destination="YmX-7v-pxh" id="ZX5-Af-cEB"/>
|
||||
<outlet property="passwordBackgroundView" destination="cLc-iA-yq5" id="2JD-nS-Gf7"/>
|
||||
<outlet property="passwordTextField" destination="R77-TQ-lVT" id="cLQ-Wn-MsE"/>
|
||||
<outlet property="scrollView" destination="WXx-hX-AXv" id="hOb-gl-0OP"/>
|
||||
<outlet property="signInButton" destination="2N5-zd-fUj" id="ul1-bh-4l4"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="U7A-Cx-Bo9" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="605.60000000000002" y="736.28185907046486"/>
|
||||
</scene>
|
||||
<!--How it works-->
|
||||
<scene sceneID="dMt-EA-SGy">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="instructionsViewController" hidesBottomBarWhenPushed="YES" id="aFi-fb-W0B" customClass="InstructionsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="Otz-hn-WGS">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="bp6-55-IG2">
|
||||
<rect key="frame" x="0.0" y="44" width="375" height="564"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="FjP-tm-w7K">
|
||||
<rect key="frame" x="16" y="35" width="343" height="95.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="1" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i9V-3h-B8f">
|
||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="59" id="ILg-0e-PW8"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
|
||||
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Q20-ml-9D0">
|
||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Launch SideStore" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="XKD-XH-eB0">
|
||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Leave SideStore running in the background on your idevice." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="6HP-Xh-sAH">
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="LpI-Jt-SzX">
|
||||
<rect key="frame" x="16" y="168" width="343" height="95.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="2" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0LW-eE-qHa">
|
||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="59" id="HzE-AA-eE5"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
|
||||
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="dMu-eg-gIO">
|
||||
<rect key="frame" x="79" y="17" width="264" height="61.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Connect to Wi-Fi and VPN" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="esj-pD-D4A">
|
||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Enable SideStore VPN in Wireguard and be able to use Sidestore on the go." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="4rk-ge-FSj">
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="36"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="tfb-ja-9UC">
|
||||
<rect key="frame" x="16" y="300.5" width="343" height="95.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="3" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="nVr-El-Csi">
|
||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="59" id="fRj-b4-VTe"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
|
||||
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="z6Y-zi-teL">
|
||||
<rect key="frame" x="79" y="16" width="264" height="64"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Download Apps" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="JeJ-bk-UCA">
|
||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Browse and download apps directly from SideStore." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="M7T-9j-uyt">
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="38.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="X3r-G1-vf2">
|
||||
<rect key="frame" x="16" y="433.5" width="343" height="95.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="4" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="i2U-NL-plG">
|
||||
<rect key="frame" x="0.0" y="0.0" width="59" height="95.5"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="59" id="4Qg-s9-p7s"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="black" pointSize="80"/>
|
||||
<color key="textColor" white="1" alpha="0.5" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="5" translatesAutoresizingMaskIntoConstraints="NO" id="Xs6-pJ-PUz">
|
||||
<rect key="frame" x="79" y="17" width="264" height="62"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps Refresh Automatically" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="nvb-Aq-sYa">
|
||||
<rect key="frame" x="0.0" y="0.0" width="264" height="20.5"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Apps are refreshed in the background while you are on SideStore VPN!" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="HU5-Hv-E3d">
|
||||
<rect key="frame" x="0.0" y="25.5" width="264" height="36.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="35" left="0.0" bottom="35" right="0.0"/>
|
||||
</stackView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qZ9-AR-2zK">
|
||||
<rect key="frame" x="16" y="608" width="343" height="51"/>
|
||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="51" id="LQz-qG-ZJK"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||
<state key="normal" title="Got it">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="dismiss" destination="aFi-fb-W0B" eventType="primaryActionTriggered" id="sBq-zj-Mln"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="Zek-aC-HOO"/>
|
||||
<color key="backgroundColor" name="SettingsBackground"/>
|
||||
<constraints>
|
||||
<constraint firstItem="qZ9-AR-2zK" firstAttribute="top" secondItem="bp6-55-IG2" secondAttribute="bottom" id="3yt-cr-swd"/>
|
||||
<constraint firstItem="bp6-55-IG2" firstAttribute="top" secondItem="Zek-aC-HOO" secondAttribute="top" id="42S-q2-YZn"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="qZ9-AR-2zK" secondAttribute="trailing" id="8b4-iU-U7R"/>
|
||||
<constraint firstItem="bp6-55-IG2" firstAttribute="leading" secondItem="Zek-aC-HOO" secondAttribute="leading" id="K1R-1r-FP3"/>
|
||||
<constraint firstItem="Zek-aC-HOO" firstAttribute="trailing" secondItem="bp6-55-IG2" secondAttribute="trailing" id="aKV-sS-alh"/>
|
||||
<constraint firstAttribute="bottomMargin" secondItem="qZ9-AR-2zK" secondAttribute="bottom" id="e8e-9l-Mkt"/>
|
||||
<constraint firstItem="qZ9-AR-2zK" firstAttribute="leading" secondItem="Otz-hn-WGS" secondAttribute="leadingMargin" id="t2b-3e-6ld"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<navigationItem key="navigationItem" title="How it works" largeTitleDisplayMode="always" id="bCq-Jq-gf1"/>
|
||||
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||
<connections>
|
||||
<outlet property="contentStackView" destination="bp6-55-IG2" id="k0Q-yS-Dxp"/>
|
||||
<outlet property="dismissButton" destination="qZ9-AR-2zK" id="w5c-v6-TcC"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="3Q4-ya-qhc" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1353" y="736"/>
|
||||
</scene>
|
||||
<!--Refresh AltStore-->
|
||||
<scene sceneID="9Vh-dM-OqX">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="refreshAltStoreViewController" id="aoK-yE-UVT" customClass="RefreshAltStoreViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" id="R83-kV-365">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fpO-Bf-gFY" customClass="RSTPlaceholderView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
</view>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="tDQ-ao-1Jg">
|
||||
<rect key="frame" x="16" y="570" width="343" height="89"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="xcg-hT-tDe" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="51"/>
|
||||
<color key="backgroundColor" name="SettingsHighlighted"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="51" id="SJA-N9-Z6u"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="19"/>
|
||||
<color key="tintColor" name="SettingsHighlighted"/>
|
||||
<state key="normal" title="Refresh Now">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="refreshAltStore:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="WQu-9b-Zgg"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="qua-VA-asJ">
|
||||
<rect key="frame" x="0.0" y="59" width="343" height="30"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="15"/>
|
||||
<state key="normal" title="Refresh Later">
|
||||
<color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</state>
|
||||
<connections>
|
||||
<action selector="cancel:" destination="aoK-yE-UVT" eventType="primaryActionTriggered" id="ffO-0a-LdE"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="iwE-xE-ziz"/>
|
||||
<color key="backgroundColor" name="SettingsBackground"/>
|
||||
<constraints>
|
||||
<constraint firstItem="fpO-Bf-gFY" firstAttribute="leading" secondItem="iwE-xE-ziz" secondAttribute="leading" id="A77-nX-Wg2"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="tDQ-ao-1Jg" secondAttribute="trailing" id="KPg-sO-Rnc"/>
|
||||
<constraint firstItem="fpO-Bf-gFY" firstAttribute="trailing" secondItem="iwE-xE-ziz" secondAttribute="trailing" id="SGI-1D-Eaw"/>
|
||||
<constraint firstItem="fpO-Bf-gFY" firstAttribute="bottom" secondItem="R83-kV-365" secondAttribute="bottom" id="cHl-7X-dW1"/>
|
||||
<constraint firstAttribute="bottomMargin" secondItem="tDQ-ao-1Jg" secondAttribute="bottom" id="kLN-e7-BJE"/>
|
||||
<constraint firstItem="fpO-Bf-gFY" firstAttribute="top" secondItem="R83-kV-365" secondAttribute="top" id="oKo-10-7kD"/>
|
||||
<constraint firstItem="tDQ-ao-1Jg" firstAttribute="leading" secondItem="R83-kV-365" secondAttribute="leadingMargin" id="zEt-Xr-kJx"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<navigationItem key="navigationItem" title="Refresh AltStore" largeTitleDisplayMode="always" id="5nk-NR-jtV"/>
|
||||
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||
<connections>
|
||||
<outlet property="placeholderView" destination="fpO-Bf-gFY" id="q7d-au-d94"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Chr-7g-qEw" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2967" y="736"/>
|
||||
</scene>
|
||||
<!--Select a Team-->
|
||||
<scene sceneID="ioQ-WB-CLJ">
|
||||
<objects>
|
||||
<viewController storyboardIdentifier="selectTeamViewController" hidesBottomBarWhenPushed="YES" id="kOD-4P-a6L" customClass="SelectTeamViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" indicatorStyle="white" dataMode="prototypes" style="grouped" separatorStyle="none" rowHeight="60" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="fWW-kX-ifH">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" name="SettingsBackground"/>
|
||||
<color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="separatorColor" white="1" alpha="0.25" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<prototypes>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" reuseIdentifier="TeamCell" textLabel="6ip-34-gmM" detailTextLabel="knk-Wf-PKf" style="IBUITableViewCellStyleSubtitle" id="qeQ-eb-2SC" customClass="InsetGroupTableViewCell" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="55.5" width="375" height="60"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="qeQ-eb-2SC" id="bT4-Fc-u6I">
|
||||
<rect key="frame" x="0.0" y="0.0" width="334" height="60"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Team 1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="6ip-34-gmM">
|
||||
<rect key="frame" x="30" y="10" width="56.5" height="20.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Description" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="knk-Wf-PKf">
|
||||
<rect key="frame" x="30" y="33.5" width="70" height="14.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="12"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</tableViewCellContentView>
|
||||
<color key="backgroundColor" white="1" alpha="0.14999999999999999" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<edgeInsets key="layoutMargins" top="8" left="30" bottom="8" right="30"/>
|
||||
<userDefinedRuntimeAttributes>
|
||||
<userDefinedRuntimeAttribute type="number" keyPath="style">
|
||||
<integer key="value" value="0"/>
|
||||
</userDefinedRuntimeAttribute>
|
||||
<userDefinedRuntimeAttribute type="boolean" keyPath="isSelectable" value="YES"/>
|
||||
</userDefinedRuntimeAttributes>
|
||||
</tableViewCell>
|
||||
</prototypes>
|
||||
<sections/>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="kOD-4P-a6L" id="OLE-fk-1MD"/>
|
||||
<outlet property="delegate" destination="kOD-4P-a6L" id="t9T-jO-TrR"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<navigationItem key="navigationItem" title="Select a Team" largeTitleDisplayMode="always" id="qxJ-Go-OPq"/>
|
||||
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="yH5-jU-aez" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1401" y="734"/>
|
||||
|
||||
</scene>
|
||||
</scenes>
|
||||
<color key="tintColor" name="Primary"/>
|
||||
<resources>
|
||||
<namedColor name="Primary">
|
||||
<color red="0.0040000001899898052" green="0.50199997425079346" blue="0.51800000667572021" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="SettingsBackground">
|
||||
<color red="0.0039215686274509803" green="0.50196078431372548" blue="0.51764705882352946" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<namedColor name="SettingsHighlighted">
|
||||
<color red="0.0080000003799796104" green="0.32199999690055847" blue="0.40400001406669617" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// AuthenticationViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/5/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
|
||||
final class AuthenticationViewController: UIViewController {
|
||||
var authenticationHandler: ((String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void)?
|
||||
var completionHandler: (((ALTAccount, ALTAppleAPISession, String)?) -> Void)?
|
||||
|
||||
private weak var toastView: ToastView?
|
||||
|
||||
@IBOutlet private var appleIDTextField: UITextField!
|
||||
@IBOutlet private var passwordTextField: UITextField!
|
||||
@IBOutlet private var signInButton: UIButton!
|
||||
|
||||
@IBOutlet private var appleIDBackgroundView: UIView!
|
||||
@IBOutlet private var passwordBackgroundView: UIView!
|
||||
|
||||
@IBOutlet private var scrollView: UIScrollView!
|
||||
@IBOutlet private var contentStackView: UIStackView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
signInButton.activityIndicatorView.style = .medium
|
||||
|
||||
for view in [appleIDBackgroundView!, passwordBackgroundView!, signInButton!] {
|
||||
view.clipsToBounds = true
|
||||
view.layer.cornerRadius = 16
|
||||
}
|
||||
|
||||
if UIScreen.main.isExtraCompactHeight {
|
||||
contentStackView.spacing = 20
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: appleIDTextField)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(AuthenticationViewController.textFieldDidChangeText(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
signInButton.isIndicatingActivity = false
|
||||
toastView?.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationViewController {
|
||||
func update() {
|
||||
if let _ = validate() {
|
||||
signInButton.isEnabled = true
|
||||
signInButton.alpha = 1.0
|
||||
} else {
|
||||
signInButton.isEnabled = false
|
||||
signInButton.alpha = 0.6
|
||||
}
|
||||
}
|
||||
|
||||
func validate() -> (String, String)? {
|
||||
guard
|
||||
let emailAddress = appleIDTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !emailAddress.isEmpty,
|
||||
let password = passwordTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty
|
||||
else { return nil }
|
||||
|
||||
return (emailAddress, password)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthenticationViewController {
|
||||
@IBAction func authenticate() {
|
||||
guard let (emailAddress, password) = validate() else { return }
|
||||
|
||||
appleIDTextField.resignFirstResponder()
|
||||
passwordTextField.resignFirstResponder()
|
||||
|
||||
signInButton.isIndicatingActivity = true
|
||||
|
||||
authenticationHandler?(emailAddress, password) { result in
|
||||
switch result {
|
||||
case .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
|
||||
// Ignore
|
||||
DispatchQueue.main.async {
|
||||
self.signInButton.isIndicatingActivity = false
|
||||
}
|
||||
|
||||
case let .failure(error as NSError):
|
||||
DispatchQueue.main.async {
|
||||
let error = error.withLocalizedFailure(NSLocalizedString("Failed to Log In", comment: ""))
|
||||
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.textLabel.textColor = .altPink
|
||||
toastView.detailTextLabel.textColor = .altPink
|
||||
toastView.show(in: self)
|
||||
self.toastView = toastView
|
||||
|
||||
self.signInButton.isIndicatingActivity = false
|
||||
}
|
||||
|
||||
case let .success((account, session)):
|
||||
self.completionHandler?((account, session, password))
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.scrollView.setContentOffset(CGPoint(x: 0, y: -self.view.safeAreaInsets.top), animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func cancel(_: UIBarButtonItem) {
|
||||
completionHandler?(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationViewController: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
switch textField {
|
||||
case appleIDTextField: passwordTextField.becomeFirstResponder()
|
||||
case passwordTextField: authenticate()
|
||||
default: break
|
||||
}
|
||||
|
||||
update()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func textFieldDidBeginEditing(_: UITextField) {
|
||||
guard UIScreen.main.isExtraCompactHeight else { return }
|
||||
|
||||
// Position all the controls within visible frame.
|
||||
var contentOffset = scrollView.contentOffset
|
||||
contentOffset.y = 44
|
||||
scrollView.setContentOffset(contentOffset, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthenticationViewController {
|
||||
@objc func textFieldDidChangeText(_: Notification) {
|
||||
update()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//
|
||||
// InstructionsViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 9/6/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class InstructionsViewController: UIViewController {
|
||||
var completionHandler: (() -> Void)?
|
||||
|
||||
var showsBottomButton: Bool = false
|
||||
|
||||
@IBOutlet private var contentStackView: UIStackView!
|
||||
@IBOutlet private var dismissButton: UIButton!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
if UIScreen.main.isExtraCompactHeight {
|
||||
contentStackView.layoutMargins.top = 0
|
||||
contentStackView.layoutMargins.bottom = contentStackView.layoutMargins.left
|
||||
}
|
||||
|
||||
dismissButton.clipsToBounds = true
|
||||
dismissButton.layer.cornerRadius = 16
|
||||
|
||||
if showsBottomButton {
|
||||
navigationItem.hidesBackButton = true
|
||||
} else {
|
||||
dismissButton.isHidden = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension InstructionsViewController {
|
||||
@IBAction func dismiss() {
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//
|
||||
// RefreshAltStoreViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 10/26/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
import SideStoreCore
|
||||
import RoxasUI
|
||||
|
||||
final class RefreshAltStoreViewController: UIViewController {
|
||||
var context: AuthenticatedOperationContext!
|
||||
|
||||
var completionHandler: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
@IBOutlet private var placeholderView: RSTPlaceholderView!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
placeholderView.textLabel.isHidden = true
|
||||
|
||||
placeholderView.detailTextLabel.textAlignment = .left
|
||||
placeholderView.detailTextLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("SideStore was unable to use an existing signing certificate, so it had to create a new one. This will cause any apps installed with an existing certificate to expire — including SideStore.\n\nTo prevent SideStore from expiring early, please refresh the app now. SideStore will quit once refreshing is complete.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
private extension RefreshAltStoreViewController {
|
||||
@IBAction func refreshAltStore(_ sender: PillButton) {
|
||||
guard let altStore = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext) else { return }
|
||||
|
||||
func refresh() {
|
||||
sender.isIndicatingActivity = true
|
||||
|
||||
if let progress = AppManager.shared.installationProgress(for: altStore) {
|
||||
// Cancel pending AltStore installation so we can start a new one.
|
||||
progress.cancel()
|
||||
}
|
||||
|
||||
// Install, _not_ refresh, to ensure we are installing with a non-revoked certificate.
|
||||
let group = AppManager.shared.install(altStore, presentingViewController: self, context: context) { result in
|
||||
switch result {
|
||||
case .success: self.completionHandler?(.success(()))
|
||||
case let .failure(error as NSError):
|
||||
DispatchQueue.main.async {
|
||||
sender.progress = nil
|
||||
sender.isIndicatingActivity = false
|
||||
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Failed to Refresh SideStore", comment: ""), message: error.localizedFailureReason ?? error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Try Again", comment: ""), style: .default, handler: { _ in
|
||||
refresh()
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh Later", comment: ""), style: .cancel, handler: { _ in
|
||||
self.completionHandler?(.failure(error))
|
||||
}))
|
||||
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sender.progress = group.progress
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
|
||||
@IBAction func cancel(_: UIButton) {
|
||||
completionHandler?(.failure(OperationError.cancelled))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// SelectTeamViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Megarushing on 4/26/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Intents
|
||||
import IntentsUI
|
||||
import MessageUI
|
||||
import SafariServices
|
||||
import UIKit
|
||||
|
||||
import AltSign
|
||||
|
||||
final class SelectTeamViewController: UITableViewController {
|
||||
public var teams: [ALTTeam]?
|
||||
public var completionHandler: ((Result<ALTTeam, Swift.Error>) -> Void)?
|
||||
|
||||
private var prototypeHeaderFooterView: SettingsHeaderFooterView!
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
.lightContent
|
||||
}
|
||||
|
||||
override func numberOfSections(in _: UITableView) -> Int {
|
||||
1
|
||||
}
|
||||
|
||||
override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
|
||||
teams?.count ?? 0
|
||||
}
|
||||
|
||||
override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
completionHandler!(.success(teams?[indexPath.row]!))
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "TeamCell", for: indexPath) as! InsetGroupTableViewCell
|
||||
|
||||
cell.textLabel?.text = teams?[indexPath.row].name
|
||||
cell.detailTextLabel?.text = teams?[indexPath.row].type.localizedDescription
|
||||
if indexPath.row == 0 {
|
||||
cell.style = InsetGroupTableViewCell.Style.top
|
||||
} else if indexPath.row == self.tableView(self.tableView, numberOfRowsInSection: indexPath.section) - 1 {
|
||||
cell.style = InsetGroupTableViewCell.Style.bottom
|
||||
} else {
|
||||
cell.style = InsetGroupTableViewCell.Style.middle
|
||||
}
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_: UITableView, titleForHeaderInSection _: Int) -> String? {
|
||||
"Teams"
|
||||
}
|
||||
}
|
||||
90
Sources/SideStore/Browse/BrowseCollectionViewCell.swift
Normal file
90
Sources/SideStore/Browse/BrowseCollectionViewCell.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// BrowseCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUI
|
||||
|
||||
import Nuke
|
||||
|
||||
@objc final class BrowseCollectionViewCell: UICollectionViewCell {
|
||||
var imageURLs: [URL] = [] {
|
||||
didSet {
|
||||
dataSource.items = imageURLs as [NSURL]
|
||||
}
|
||||
}
|
||||
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
|
||||
@IBOutlet var bannerView: AppBannerView!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
|
||||
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷♂️.
|
||||
screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
screenshotsCollectionView.delegate = self
|
||||
screenshotsCollectionView.dataSource = dataSource
|
||||
screenshotsCollectionView.prefetchDataSource = dataSource
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseCollectionViewCell {
|
||||
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage> {
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
|
||||
dataSource.cellConfigurationHandler = { cell, _, _ in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { imageURL, _, completionHandler in
|
||||
RSTAsyncBlockOperation { operation in
|
||||
let request = ImageRequest(url: imageURL as URL, processor: .screenshot)
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error {
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt _: IndexPath) -> CGSize {
|
||||
// Assuming 9.0 / 16.0 ratio for now.
|
||||
let aspectRatio: CGFloat = 9.0 / 16.0
|
||||
|
||||
let itemHeight = collectionView.bounds.height
|
||||
let itemWidth = itemHeight * aspectRatio
|
||||
|
||||
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
|
||||
return size
|
||||
}
|
||||
}
|
||||
64
Sources/SideStore/Browse/BrowseCollectionViewCell.xib
Normal file
64
Sources/SideStore/Browse/BrowseCollectionViewCell.xib
Normal file
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Cell" id="ln4-pC-7KY" customClass="BrowseCollectionViewCell" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="5gU-g3-Fsy">
|
||||
<rect key="frame" x="16" y="0.0" width="343" height="369"/>
|
||||
<subviews>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ziA-mP-AY2" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="343" height="88"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="88" id="z3D-cI-jhp"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Classic Nintendo games in your pocket." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Imx-Le-bcy">
|
||||
<rect key="frame" x="0.0" y="103" width="343" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" dataMode="none" translatesAutoresizingMaskIntoConstraints="NO" id="RFs-qp-Ca4">
|
||||
<rect key="frame" x="0.0" y="135" width="343" height="234"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="15" id="jH9-Jo-IHA">
|
||||
<size key="itemSize" width="120" height="213"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="8" minY="0.0" maxX="8" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells/>
|
||||
</collectionView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
</view>
|
||||
<constraints>
|
||||
<constraint firstItem="5gU-g3-Fsy" firstAttribute="top" secondItem="ln4-pC-7KY" secondAttribute="top" id="DnT-vq-BOc"/>
|
||||
<constraint firstItem="5gU-g3-Fsy" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leadingMargin" id="YPy-xL-iUn"/>
|
||||
<constraint firstAttribute="bottom" secondItem="5gU-g3-Fsy" secondAttribute="bottom" id="gRu-Hz-CNL"/>
|
||||
<constraint firstAttribute="trailingMargin" secondItem="5gU-g3-Fsy" secondAttribute="trailing" id="vf4-ql-4Vq"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<outlet property="bannerView" destination="ziA-mP-AY2" id="yxo-ar-Cha"/>
|
||||
<outlet property="screenshotsCollectionView" destination="RFs-qp-Ca4" id="xfi-AN-l17"/>
|
||||
<outlet property="subtitleLabel" destination="Imx-Le-bcy" id="JVW-ZZ-51O"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="136.95652173913044" y="152.67857142857142"/>
|
||||
</collectionViewCell>
|
||||
</objects>
|
||||
</document>
|
||||
322
Sources/SideStore/Browse/BrowseViewController.swift
Normal file
322
Sources/SideStore/Browse/BrowseViewController.swift
Normal file
@@ -0,0 +1,322 @@
|
||||
//
|
||||
// BrowseViewController.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUI
|
||||
|
||||
import Nuke
|
||||
|
||||
class BrowseViewController: UICollectionViewController {
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||
|
||||
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
||||
|
||||
private var loadingState: LoadingState = .loading {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedItemSizes = [String: CGSize]()
|
||||
|
||||
@IBOutlet private var sourcesBarButtonItem: UIBarButtonItem!
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
#if BETA
|
||||
dataSource.searchController.searchableKeyPaths = [#keyPath(InstalledApp.name)]
|
||||
navigationItem.searchController = dataSource.searchController
|
||||
#endif
|
||||
|
||||
prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
collectionView.dataSource = dataSource
|
||||
collectionView.prefetchDataSource = dataSource
|
||||
|
||||
registerForPreviewing(with: self, sourceView: collectionView)
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
fetchSource()
|
||||
updateDataSource()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
@IBAction private func unwindFromSourcesViewController(_: UIStoryboardSegue) {
|
||||
fetchSource()
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseViewController {
|
||||
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage> {
|
||||
let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest<StoreApp>
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
|
||||
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID)
|
||||
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
|
||||
dataSource.cellConfigurationHandler = { cell, app, _ in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
cell.subtitleLabel.text = app.subtitle
|
||||
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
|
||||
|
||||
cell.bannerView.configure(for: app)
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
|
||||
cell.bannerView.button.addTarget(self, action: #selector(BrowseViewController.performAppAction(_:)), for: .primaryActionTriggered)
|
||||
cell.bannerView.button.activityIndicatorView.style = .medium
|
||||
|
||||
// Explicitly set to false to ensure we're starting from a non-activity indicating state.
|
||||
// Otherwise, cell reuse can mess up some cached values.
|
||||
cell.bannerView.button.isIndicatingActivity = false
|
||||
|
||||
let tintColor = app.tintColor ?? .altPrimary
|
||||
cell.tintColor = tintColor
|
||||
|
||||
if app.installedApp == nil {
|
||||
let buttonTitle = NSLocalizedString("Free", comment: "")
|
||||
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = buttonTitle
|
||||
|
||||
let progress = AppManager.shared.installationProgress(for: app)
|
||||
cell.bannerView.button.progress = progress
|
||||
|
||||
if let versionDate = app.latestVersion?.date, versionDate > Date() {
|
||||
cell.bannerView.button.countdownDate = app.versionDate
|
||||
} else {
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
} else {
|
||||
cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
|
||||
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name)
|
||||
cell.bannerView.button.accessibilityValue = nil
|
||||
cell.bannerView.button.progress = nil
|
||||
cell.bannerView.button.countdownDate = nil
|
||||
}
|
||||
}
|
||||
dataSource.prefetchHandler = { storeApp, _, completionHandler -> Foundation.Operation? in
|
||||
let iconURL = storeApp.iconURL
|
||||
|
||||
return RSTAsyncBlockOperation { operation in
|
||||
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { response, error in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
if let image = response?.image {
|
||||
completionHandler(image, nil)
|
||||
} else {
|
||||
completionHandler(nil, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { cell, image, _, error in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
cell.bannerView.iconImageView.image = image
|
||||
|
||||
if let error = error {
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.placeholderView = placeholderView
|
||||
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func updateDataSource() {
|
||||
dataSource.predicate = nil
|
||||
}
|
||||
|
||||
func fetchSource() {
|
||||
loadingState = .loading
|
||||
|
||||
AppManager.shared.fetchSources { result in
|
||||
do {
|
||||
do {
|
||||
let (_, context) = try result.get()
|
||||
try context.save()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.loadingState = .finished(.success(()))
|
||||
}
|
||||
} catch let error as AppManager.FetchSourcesError {
|
||||
try error.managedObjectContext?.save()
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
if self.dataSource.itemCount > 0 {
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
|
||||
toastView.show(in: self)
|
||||
}
|
||||
|
||||
self.loadingState = .finished(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update() {
|
||||
switch loadingState {
|
||||
case .loading:
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
|
||||
|
||||
placeholderView.activityIndicatorView.startAnimating()
|
||||
|
||||
case let .finished(.failure(error)):
|
||||
placeholderView.textLabel.isHidden = false
|
||||
placeholderView.detailTextLabel.isHidden = false
|
||||
|
||||
placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch Apps", comment: "")
|
||||
placeholderView.detailTextLabel.text = error.localizedDescription
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
|
||||
case .finished(.success):
|
||||
placeholderView.textLabel.isHidden = true
|
||||
placeholderView.detailTextLabel.isHidden = true
|
||||
|
||||
placeholderView.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowseViewController {
|
||||
@IBAction func performAppAction(_ sender: PillButton) {
|
||||
let point = collectionView.convert(sender.center, from: sender.superview)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
|
||||
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
if let installedApp = app.installedApp {
|
||||
open(installedApp)
|
||||
} else {
|
||||
install(app, at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
func install(_ app: StoreApp, at indexPath: IndexPath) {
|
||||
let previousProgress = AppManager.shared.installationProgress(for: app)
|
||||
guard previousProgress == nil else {
|
||||
previousProgress?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
_ = AppManager.shared.install(app, presentingViewController: self) { result in
|
||||
DispatchQueue.main.async {
|
||||
switch result {
|
||||
case .failure(OperationError.cancelled): break // Ignore
|
||||
case let .failure(error):
|
||||
let toastView = ToastView(error: error)
|
||||
toastView.show(in: self)
|
||||
|
||||
case .success: print("Installed app:", app.bundleIdentifier)
|
||||
}
|
||||
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
}
|
||||
|
||||
collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
func open(_ installedApp: InstalledApp) {
|
||||
UIApplication.shared.open(installedApp.openAppURL)
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
let item = dataSource.item(at: indexPath)
|
||||
|
||||
if let previousSize = cachedItemSizes[item.bundleIdentifier] {
|
||||
return previousSize
|
||||
}
|
||||
|
||||
let maxVisibleScreenshots = 2 as CGFloat
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0
|
||||
|
||||
let layout = prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
|
||||
|
||||
dataSource.cellConfigurationHandler(prototypeCell, item, indexPath)
|
||||
|
||||
let widthConstraint = prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||
widthConstraint.isActive = true
|
||||
defer { widthConstraint.isActive = false }
|
||||
|
||||
// Manually update cell width & layout so we can accurately calculate screenshot sizes.
|
||||
prototypeCell.frame.size.width = widthConstraint.constant
|
||||
prototypeCell.layoutIfNeeded()
|
||||
|
||||
let collectionViewWidth = prototypeCell.screenshotsCollectionView.bounds.width
|
||||
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
|
||||
let screenshotHeight = screenshotWidth * aspectRatio
|
||||
|
||||
let heightConstraint = prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
|
||||
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
|
||||
heightConstraint.isActive = true
|
||||
defer { heightConstraint.isActive = false }
|
||||
|
||||
let itemSize = prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
cachedItemSizes[item.bundleIdentifier] = itemSize
|
||||
return itemSize
|
||||
}
|
||||
|
||||
override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
navigationController?.pushViewController(appViewController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowseViewController: UIViewControllerPreviewingDelegate {
|
||||
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
|
||||
guard
|
||||
let indexPath = collectionView.indexPathForItem(at: location),
|
||||
let cell = collectionView.cellForItem(at: indexPath)
|
||||
else { return nil }
|
||||
|
||||
previewingContext.sourceRect = cell.frame
|
||||
|
||||
let app = dataSource.item(at: indexPath)
|
||||
|
||||
let appViewController = AppViewController.makeAppViewController(app: app)
|
||||
return appViewController
|
||||
}
|
||||
|
||||
func previewingContext(_: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
|
||||
navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||
}
|
||||
}
|
||||
39
Sources/SideStore/Browse/ScreenshotCollectionViewCell.swift
Normal file
39
Sources/SideStore/Browse/ScreenshotCollectionViewCell.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// ScreenshotCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/15/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import RoxasUI
|
||||
|
||||
@objc(ScreenshotCollectionViewCell)
|
||||
class ScreenshotCollectionViewCell: UICollectionViewCell {
|
||||
let imageView = UIImageView(image: nil)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
imageView.layer.masksToBounds = true
|
||||
addSubview(imageView, pinningEdgesWith: .zero)
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
imageView.layer.cornerRadius = 4
|
||||
}
|
||||
}
|
||||
128
Sources/SideStore/Components/AppBannerView.swift
Normal file
128
Sources/SideStore/Components/AppBannerView.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// AppBannerView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/29/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import SideStoreCore
|
||||
import RoxasUI
|
||||
|
||||
class AppBannerView: RSTNibView {
|
||||
override var accessibilityLabel: String? {
|
||||
get { self.accessibilityView?.accessibilityLabel }
|
||||
set { self.accessibilityView?.accessibilityLabel = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedLabel: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedLabel }
|
||||
set { self.accessibilityView?.accessibilityAttributedLabel = newValue }
|
||||
}
|
||||
|
||||
override var accessibilityValue: String? {
|
||||
get { self.accessibilityView?.accessibilityValue }
|
||||
set { self.accessibilityView?.accessibilityValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityAttributedValue: NSAttributedString? {
|
||||
get { self.accessibilityView?.accessibilityAttributedValue }
|
||||
set { self.accessibilityView?.accessibilityAttributedValue = newValue }
|
||||
}
|
||||
|
||||
override open var accessibilityTraits: UIAccessibilityTraits {
|
||||
get { accessibilityView?.accessibilityTraits ?? [] }
|
||||
set { accessibilityView?.accessibilityTraits = newValue }
|
||||
}
|
||||
|
||||
private var originalTintColor: UIColor?
|
||||
|
||||
@IBOutlet var titleLabel: UILabel!
|
||||
@IBOutlet var subtitleLabel: UILabel!
|
||||
@IBOutlet var iconImageView: AppIconImageView!
|
||||
@IBOutlet var button: PillButton!
|
||||
@IBOutlet var buttonLabel: UILabel!
|
||||
@IBOutlet var betaBadgeView: UIView!
|
||||
|
||||
@IBOutlet var backgroundEffectView: UIVisualEffectView!
|
||||
|
||||
@IBOutlet private var vibrancyView: UIVisualEffectView!
|
||||
@IBOutlet private var accessibilityView: UIView!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
initialize()
|
||||
}
|
||||
|
||||
private func initialize() {
|
||||
accessibilityView.accessibilityTraits.formUnion(.button)
|
||||
|
||||
isAccessibilityElement = false
|
||||
accessibilityElements = [accessibilityView, button].compactMap { $0 }
|
||||
|
||||
betaBadgeView.isHidden = true
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
if tintAdjustmentMode != .dimmed {
|
||||
originalTintColor = tintColor
|
||||
}
|
||||
|
||||
update()
|
||||
}
|
||||
}
|
||||
|
||||
extension AppBannerView {
|
||||
func configure(for app: AppProtocol) {
|
||||
struct AppValues {
|
||||
var name: String
|
||||
var developerName: String?
|
||||
var isBeta: Bool = false
|
||||
|
||||
init(app: AppProtocol) {
|
||||
name = app.name
|
||||
|
||||
guard let storeApp = (app as? StoreApp) ?? (app as? InstalledApp)?.storeApp else { return }
|
||||
developerName = storeApp.developerName
|
||||
|
||||
if storeApp.isBeta {
|
||||
name = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
|
||||
isBeta = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let values = AppValues(app: app)
|
||||
titleLabel.text = app.name // Don't use values.name since that already includes "beta".
|
||||
betaBadgeView.isHidden = !values.isBeta
|
||||
|
||||
if let developerName = values.developerName {
|
||||
subtitleLabel.text = developerName
|
||||
accessibilityLabel = String(format: NSLocalizedString("%@ by %@", comment: ""), values.name, developerName)
|
||||
} else {
|
||||
subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
|
||||
accessibilityLabel = values.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppBannerView {
|
||||
func update() {
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = 22
|
||||
|
||||
subtitleLabel.textColor = originalTintColor ?? tintColor
|
||||
backgroundEffectView.backgroundColor = originalTintColor ?? tintColor
|
||||
}
|
||||
}
|
||||
156
Sources/SideStore/Components/AppBannerView.xib
Normal file
156
Sources/SideStore/Components/AppBannerView.xib
Normal file
@@ -0,0 +1,156 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17124"/>
|
||||
<capability name="Named colors" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="accessibilityView" destination="bJL-Yw-i4u" id="PWe-tw-jDA"/>
|
||||
<outlet property="backgroundEffectView" destination="rZk-be-tiI" id="fzU-VT-JeW"/>
|
||||
<outlet property="betaBadgeView" destination="qQl-Ez-zC5" id="6O1-Cx-7qz"/>
|
||||
<outlet property="button" destination="tVx-3G-dcu" id="joa-AH-syX"/>
|
||||
<outlet property="buttonLabel" destination="Yd9-jw-faD" id="o7g-Gb-CIt"/>
|
||||
<outlet property="iconImageView" destination="avS-dx-4iy" id="TQs-Ej-gin"/>
|
||||
<outlet property="subtitleLabel" destination="oN5-vu-Dnw" id="gA4-iJ-Tix"/>
|
||||
<outlet property="titleLabel" destination="mFe-zJ-eva" id="2OH-f8-cid"/>
|
||||
<outlet property="vibrancyView" destination="fC4-1V-iMn" id="PXE-2B-A7w"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="FxI-Fh-ll5">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<view userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bJL-Yw-i4u">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<accessibility key="accessibilityConfiguration">
|
||||
<bool key="isElement" value="YES"/>
|
||||
</accessibility>
|
||||
</view>
|
||||
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rZk-be-tiI">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="b8k-up-HtI">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" name="BlurTint"/>
|
||||
</view>
|
||||
<blurEffect style="systemChromeMaterial"/>
|
||||
</visualEffectView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" alignment="center" spacing="11" translatesAutoresizingMaskIntoConstraints="NO" id="d1T-UD-gWG" userLabel="App Info">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="88"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="avS-dx-4iy" customClass="AppIconImageView" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="14" y="14" width="60" height="60"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="60" id="6lU-H8-nEw"/>
|
||||
<constraint firstAttribute="width" secondItem="avS-dx-4iy" secondAttribute="height" multiplier="1:1" id="AYT-3g-wcV"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="100" insetsLayoutMarginsFromSafeArea="NO" axis="vertical" alignment="top" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="caL-vN-Svn">
|
||||
<rect key="frame" x="85" y="25.5" width="190" height="37.5"/>
|
||||
<subviews>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" horizontalCompressionResistancePriority="500" spacing="6" translatesAutoresizingMaskIntoConstraints="NO" id="1Es-pv-zwd">
|
||||
<rect key="frame" x="0.0" y="0.0" width="126" height="19.5"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="251" horizontalCompressionResistancePriority="500" text="App Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="mFe-zJ-eva">
|
||||
<rect key="frame" x="0.0" y="0.0" width="79" height="19.5"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="NameLabel"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="BetaBadge" translatesAutoresizingMaskIntoConstraints="NO" id="qQl-Ez-zC5">
|
||||
<rect key="frame" x="85" y="0.0" width="41" height="19.5"/>
|
||||
<accessibility key="accessibilityConfiguration" identifier="Beta Badge">
|
||||
<accessibilityTraits key="traits" image="YES" notEnabled="YES"/>
|
||||
<bool key="isElement" value="YES"/>
|
||||
</accessibility>
|
||||
</imageView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
|
||||
<rect key="frame" x="0.0" y="21.5" width="190" height="16"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="190" height="16"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
|
||||
<rect key="frame" x="0.0" y="0.0" width="190" height="16"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="13"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="oN5-vu-Dnw" firstAttribute="top" secondItem="LQh-pN-ePC" secondAttribute="top" id="7RH-WP-LzL"/>
|
||||
<constraint firstItem="oN5-vu-Dnw" firstAttribute="leading" secondItem="LQh-pN-ePC" secondAttribute="leading" id="By8-cR-kTu"/>
|
||||
<constraint firstAttribute="trailing" secondItem="oN5-vu-Dnw" secondAttribute="trailing" id="Hiv-6y-XrH"/>
|
||||
<constraint firstAttribute="bottom" secondItem="oN5-vu-Dnw" secondAttribute="bottom" id="yc2-Dr-Qnv"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<vibrancyEffect style="secondaryLabel">
|
||||
<blurEffect style="systemChromeMaterial"/>
|
||||
</vibrancyEffect>
|
||||
</visualEffectView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3ox-Q2-Rnd" userLabel="Button Stack View">
|
||||
<rect key="frame" x="286" y="28.5" width="77" height="31"/>
|
||||
<subviews>
|
||||
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
|
||||
<rect key="frame" x="0.0" y="0.0" width="77" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
|
||||
<color key="textColor" systemColor="secondaryLabelColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="77" height="31"/>
|
||||
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
|
||||
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="77" id="eGc-Dk-QbL"/>
|
||||
</constraints>
|
||||
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
|
||||
<state key="normal" title="FREE"/>
|
||||
</button>
|
||||
</subviews>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
|
||||
</stackView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
|
||||
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
|
||||
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
|
||||
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
|
||||
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
|
||||
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
|
||||
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" id="nJo-To-LmX"/>
|
||||
<constraint firstItem="bJL-Yw-i4u" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="oLt-2z-QoJ"/>
|
||||
<constraint firstItem="rZk-be-tiI" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="pGD-Tl-U4c"/>
|
||||
<constraint firstItem="d1T-UD-gWG" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="q2p-0S-Nv5"/>
|
||||
<constraint firstAttribute="trailing" secondItem="bJL-Yw-i4u" secondAttribute="trailing" id="vwx-P9-dlB"/>
|
||||
<constraint firstAttribute="bottom" secondItem="rZk-be-tiI" secondAttribute="bottom" id="yk0-pw-joP"/>
|
||||
</constraints>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<point key="canvasLocation" x="139.85507246376812" y="152.67857142857142"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="BetaBadge" width="41" height="17"/>
|
||||
<namedColor name="BlurTint">
|
||||
<color red="1" green="1" blue="1" alpha="0.30000001192092896" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</namedColor>
|
||||
<systemColor name="secondaryLabelColor">
|
||||
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</systemColor>
|
||||
</resources>
|
||||
</document>
|
||||
36
Sources/SideStore/Components/AppIconImageView.swift
Normal file
36
Sources/SideStore/Components/AppIconImageView.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// AppIconImageView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class AppIconImageView: UIImageView {
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentMode = .scaleAspectFill
|
||||
clipsToBounds = true
|
||||
|
||||
backgroundColor = .white
|
||||
|
||||
if #available(iOS 13, *) {
|
||||
self.layer.cornerCurve = .continuous
|
||||
} else {
|
||||
if layer.responds(to: Selector(("continuousCorners"))) {
|
||||
layer.setValue(true, forKey: "continuousCorners")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Based off of 60pt icon having 12pt radius.
|
||||
let radius = bounds.height / 5
|
||||
layer.cornerRadius = radius
|
||||
}
|
||||
}
|
||||
89
Sources/SideStore/Components/BackgroundTaskManager.swift
Normal file
89
Sources/SideStore/Components/BackgroundTaskManager.swift
Normal file
@@ -0,0 +1,89 @@
|
||||
//
|
||||
// BackgroundTaskManager.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 6/19/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
|
||||
final class BackgroundTaskManager {
|
||||
static let shared = BackgroundTaskManager()
|
||||
|
||||
private var isPlaying = false
|
||||
|
||||
private let audioEngine: AVAudioEngine
|
||||
private let player: AVAudioPlayerNode
|
||||
private let audioFile: AVAudioFile
|
||||
|
||||
private let audioEngineQueue: DispatchQueue
|
||||
|
||||
private init() {
|
||||
audioEngine = AVAudioEngine()
|
||||
audioEngine.mainMixerNode.outputVolume = 0.0
|
||||
|
||||
player = AVAudioPlayerNode()
|
||||
audioEngine.attach(player)
|
||||
|
||||
do {
|
||||
let audioFileURL = Bundle.main.url(forResource: "Silence", withExtension: "m4a")!
|
||||
|
||||
audioFile = try AVAudioFile(forReading: audioFileURL)
|
||||
audioEngine.connect(player, to: audioEngine.mainMixerNode, format: audioFile.processingFormat)
|
||||
} catch {
|
||||
fatalError("Error. \(error)")
|
||||
}
|
||||
|
||||
audioEngineQueue = DispatchQueue(label: "com.altstore.BackgroundTaskManager")
|
||||
}
|
||||
}
|
||||
|
||||
extension BackgroundTaskManager {
|
||||
func performExtendedBackgroundTask(taskHandler: @escaping ((Result<Void, Error>, @escaping () -> Void) -> Void)) {
|
||||
func finish() {
|
||||
player.stop()
|
||||
audioEngine.stop()
|
||||
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
audioEngineQueue.sync {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
|
||||
// Schedule audio file buffers.
|
||||
self.scheduleAudioFile()
|
||||
self.scheduleAudioFile()
|
||||
|
||||
let outputFormat = self.audioEngine.outputNode.outputFormat(forBus: 0)
|
||||
self.audioEngine.connect(self.audioEngine.mainMixerNode, to: self.audioEngine.outputNode, format: outputFormat)
|
||||
|
||||
try self.audioEngine.start()
|
||||
self.player.play()
|
||||
|
||||
self.isPlaying = true
|
||||
|
||||
taskHandler(.success(())) {
|
||||
finish()
|
||||
}
|
||||
} catch {
|
||||
taskHandler(.failure(error)) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension BackgroundTaskManager {
|
||||
func scheduleAudioFile() {
|
||||
player.scheduleFile(audioFile, at: nil) {
|
||||
self.audioEngineQueue.async {
|
||||
guard self.isPlaying else { return }
|
||||
self.scheduleAudioFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
Sources/SideStore/Components/BannerCollectionViewCell.swift
Normal file
51
Sources/SideStore/Components/BannerCollectionViewCell.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// BannerCollectionViewCell.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 3/23/20.
|
||||
// Copyright © 2020 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class BannerCollectionViewCell: UICollectionViewCell {
|
||||
private(set) var errorBadge: UIView?
|
||||
@IBOutlet private(set) var bannerView: AppBannerView!
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
contentView.preservesSuperviewLayoutMargins = true
|
||||
|
||||
if #available(iOS 13.0, *) {
|
||||
let errorBadge = UIView()
|
||||
errorBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||
errorBadge.isHidden = true
|
||||
self.addSubview(errorBadge)
|
||||
|
||||
// Solid background to make the X opaque white.
|
||||
let backgroundView = UIView()
|
||||
backgroundView.translatesAutoresizingMaskIntoConstraints = false
|
||||
backgroundView.backgroundColor = .white
|
||||
errorBadge.addSubview(backgroundView)
|
||||
|
||||
let badgeView = UIImageView(image: UIImage(systemName: "exclamationmark.circle.fill"))
|
||||
badgeView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(scale: .large)
|
||||
badgeView.tintColor = .systemRed
|
||||
errorBadge.addSubview(badgeView, pinningEdgesWith: .zero)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
errorBadge.centerXAnchor.constraint(equalTo: self.bannerView.trailingAnchor, constant: -5),
|
||||
errorBadge.centerYAnchor.constraint(equalTo: self.bannerView.topAnchor, constant: 5),
|
||||
|
||||
backgroundView.centerXAnchor.constraint(equalTo: badgeView.centerXAnchor),
|
||||
backgroundView.centerYAnchor.constraint(equalTo: badgeView.centerYAnchor),
|
||||
backgroundView.widthAnchor.constraint(equalTo: badgeView.widthAnchor, multiplier: 0.5),
|
||||
backgroundView.heightAnchor.constraint(equalTo: badgeView.heightAnchor, multiplier: 0.5)
|
||||
])
|
||||
|
||||
self.errorBadge = errorBadge
|
||||
}
|
||||
}
|
||||
}
|
||||
57
Sources/SideStore/Components/Button.swift
Normal file
57
Sources/SideStore/Components/Button.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// Button.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 5/9/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class Button: UIButton {
|
||||
override var intrinsicContentSize: CGSize {
|
||||
var size = super.intrinsicContentSize
|
||||
size.width += 20
|
||||
size.height += 10
|
||||
return size
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
setTitleColor(.white, for: .normal)
|
||||
|
||||
layer.masksToBounds = true
|
||||
layer.cornerRadius = 8
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override func tintColorDidChange() {
|
||||
super.tintColorDidChange()
|
||||
|
||||
update()
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
override var isEnabled: Bool {
|
||||
didSet {
|
||||
update()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Button {
|
||||
func update() {
|
||||
if isEnabled {
|
||||
backgroundColor = tintColor
|
||||
} else {
|
||||
backgroundColor = .lightGray
|
||||
}
|
||||
}
|
||||
}
|
||||
106
Sources/SideStore/Components/CollapsingTextView.swift
Normal file
106
Sources/SideStore/Components/CollapsingTextView.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
//
|
||||
// CollapsingTextView.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 7/23/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
final class CollapsingTextView: UITextView {
|
||||
var isCollapsed = true {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var maximumNumberOfLines = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
var lineSpacing: CGFloat = 2 {
|
||||
didSet {
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
let moreButton = UIButton(type: .system)
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
layoutManager.delegate = self
|
||||
|
||||
textContainerInset = .zero
|
||||
textContainer.lineFragmentPadding = 0
|
||||
textContainer.lineBreakMode = .byTruncatingTail
|
||||
textContainer.heightTracksTextView = true
|
||||
textContainer.widthTracksTextView = true
|
||||
|
||||
moreButton.setTitle(NSLocalizedString("More", comment: ""), for: .normal)
|
||||
moreButton.addTarget(self, action: #selector(CollapsingTextView.toggleCollapsed(_:)), for: .primaryActionTriggered)
|
||||
addSubview(moreButton)
|
||||
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let font = font else { return }
|
||||
|
||||
let buttonFont = UIFont.systemFont(ofSize: font.pointSize, weight: .medium)
|
||||
moreButton.titleLabel?.font = buttonFont
|
||||
|
||||
let buttonY = (font.lineHeight + lineSpacing) * CGFloat(maximumNumberOfLines - 1)
|
||||
let size = moreButton.sizeThatFits(CGSize(width: 1000, height: 1000))
|
||||
|
||||
let moreButtonFrame = CGRect(x: bounds.width - moreButton.bounds.width,
|
||||
y: buttonY,
|
||||
width: size.width,
|
||||
height: font.lineHeight)
|
||||
moreButton.frame = moreButtonFrame
|
||||
|
||||
if isCollapsed {
|
||||
textContainer.maximumNumberOfLines = maximumNumberOfLines
|
||||
|
||||
let boundingSize = attributedText.boundingRect(with: CGSize(width: textContainer.size.width, height: .infinity), options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil)
|
||||
let maximumCollapsedHeight = font.lineHeight * Double(maximumNumberOfLines)
|
||||
|
||||
if boundingSize.height.rounded() > maximumCollapsedHeight.rounded() {
|
||||
var exclusionFrame = moreButtonFrame
|
||||
exclusionFrame.origin.y += moreButton.bounds.midY
|
||||
exclusionFrame.size.width = bounds.width // Extra wide to make sure it wraps to next line.
|
||||
textContainer.exclusionPaths = [UIBezierPath(rect: exclusionFrame)]
|
||||
|
||||
moreButton.isHidden = false
|
||||
} else {
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
} else {
|
||||
textContainer.maximumNumberOfLines = 0
|
||||
textContainer.exclusionPaths = []
|
||||
|
||||
moreButton.isHidden = true
|
||||
}
|
||||
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CollapsingTextView {
|
||||
@objc func toggleCollapsed(_: UIButton) {
|
||||
isCollapsed.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
extension CollapsingTextView: NSLayoutManagerDelegate {
|
||||
func layoutManager(_: NSLayoutManager, lineSpacingAfterGlyphAt _: Int, withProposedLineFragmentRect _: CGRect) -> CGFloat {
|
||||
lineSpacing
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user