Files
LocalConsole/Sources/LocalConsole/Console.swift
T
Duraid Abdul 16a2df3694 Adjust naming
2021-05-10 22:30:54 -07:00

450 lines
17 KiB
Swift

//
// LocalConsole.swift
//
// Created by Duraid Abdul.
// Copyright © 2021 Duraid Abdul. All rights reserved.
//
#if canImport(UIKit)
import UIKit
var GLOBAL_DEBUG_BORDERS = false
var GLOBAL_BORDER_TRACKERS: [BorderManager] = []
public class Console: NSObject, UIGestureRecognizerDelegate {
public static let shared = Console()
let consoleSize = CGSize(width: 208, height: 116)
// Strong reference needed to keep the window alive.
var consoleWindow: ConsoleWindow?
// The console needs a view controller to display context menus.
let viewController = UIViewController()
lazy var consoleView = viewController.view!
let consoleTextView = UITextView()
var menuButton: UIButton!
var scrollLocked = true
let feedbackGenerator = UISelectionFeedbackGenerator()
lazy var possibleEndpoints = [CGPoint(x: consoleSize.width / 2 + 12,
y: UIApplication.shared.statusBarHeight + consoleSize.height / 2 + 5),
CGPoint(x: UIScreen.size.width - consoleSize.width / 2 - 12,
y: UIApplication.shared.statusBarHeight + consoleSize.height / 2 + 5),
CGPoint(x: consoleSize.width / 2 + 12,
y: UIScreen.size.height - consoleSize.height / 2 - 56),
CGPoint(x: UIScreen.size.width - consoleSize.width / 2 - 12,
y: UIScreen.size.height - consoleSize.height / 2 - 56)]
lazy var initialViewLocation: CGPoint = .zero
override init() {
super.init()
// Configure console window.
let windowScene = UIApplication.shared
.connectedScenes
.filter { $0.activationState == .foregroundActive }
.first
if let windowScene = windowScene as? UIWindowScene {
consoleWindow = ConsoleWindow(windowScene: windowScene)
consoleWindow?.frame = UIScreen.main.bounds
consoleWindow?.windowLevel = UIWindow.Level.normal
consoleWindow?.isHidden = false
consoleWindow?.addSubview(consoleView)
UIWindow.swizzleStatusBarAppearanceOverride
}
// Configure console view.
consoleView.frame.size = consoleSize
consoleView.backgroundColor = .black
consoleView.layer.shadowRadius = 16
consoleView.layer.shadowOpacity = 0.5
consoleView.layer.shadowOffset = CGSize(width: 0, height: 2)
consoleView.center = possibleEndpoints.first!
consoleView.alpha = 0
consoleView.layer.borderWidth = 1
consoleView.layer.borderColor = UIColor(white: 1, alpha: 0.08).cgColor
consoleView.layer.cornerRadius = 19
consoleView.layer.cornerCurve = .continuous
// Configure text view.
consoleTextView.frame = CGRect(x: 0, y: 2, width: consoleSize.width, height: consoleSize.height - 4)
consoleTextView.isEditable = false
consoleTextView.backgroundColor = .clear
consoleTextView.textContainerInset = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10)
consoleTextView.isSelectable = false
consoleTextView.showsVerticalScrollIndicator = false
consoleTextView.contentInsetAdjustmentBehavior = .never
consoleView.addSubview(consoleTextView)
// Configure gesture recognizers.
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(consolePiPPanner(recognizer:)))
panRecognizer.maximumNumberOfTouches = 1
panRecognizer.delegate = self
let tapRecognizer = UITapStartEndGestureRecognizer(target: self, action: #selector(consolePiPTapStartEnd(recognizer:)))
tapRecognizer.delegate = self
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressAction(recognizer:)))
longPressRecognizer.minimumPressDuration = 0.1
consoleView.addGestureRecognizer(panRecognizer)
consoleView.addGestureRecognizer(tapRecognizer)
consoleView.addGestureRecognizer(longPressRecognizer)
// Prepare menu button.
let diameter = CGFloat(25)
menuButton = UIButton(frame: CGRect(x: consoleView.bounds.width - diameter - (consoleView.layer.cornerRadius - diameter / 2),
y: consoleView.bounds.height - diameter - (consoleView.layer.cornerRadius - diameter / 2),
width: diameter, height: diameter))
menuButton.layer.cornerRadius = diameter / 2
menuButton.backgroundColor = UIColor(white: 1, alpha: 0.20)
let ellipsisImage = UIImageView(image: UIImage(systemName: "ellipsis", withConfiguration: UIImage.SymbolConfiguration(pointSize: 16)))
ellipsisImage.frame.size = menuButton!.bounds.size
ellipsisImage.contentMode = .center
menuButton.addSubview(ellipsisImage)
menuButton.tintColor = UIColor(white: 1, alpha: 0.75)
menuButton.menu = makeMenu()
menuButton.showsMenuAsPrimaryAction = true
consoleView.addSubview(menuButton!)
UIView.swizzleDebugBehaviour
_print(consoleView)
_print(menuButton)
_print("Hello, world!")
}
public var isVisible = false {
didSet {
guard oldValue != isVisible else { return }
if isVisible {
consoleView.transform = .init(scaleX: 0.9, y: 0.9)
UIViewPropertyAnimator(duration: 0.5, dampingRatio: 0.6) { [self] in
consoleView.transform = .init(scaleX: 1, y: 1)
}.startAnimation()
UIViewPropertyAnimator(duration: 0.3, dampingRatio: 1) { [self] in
consoleView.alpha = 1
}.startAnimation()
} else {
UIViewPropertyAnimator(duration: 0.25, dampingRatio: 1) { [self] in
consoleView.transform = .init(scaleX: 0.9, y: 0.9)
consoleView.alpha = 0
}.startAnimation()
}
}
}
private var debugBordersEnabled = false {
didSet {
GLOBAL_DEBUG_BORDERS = debugBordersEnabled
guard debugBordersEnabled else {
GLOBAL_BORDER_TRACKERS.forEach {
$0.deactivate()
}
GLOBAL_BORDER_TRACKERS = []
return
}
func subviewsRecursive(in _view: UIView) -> [UIView] {
return _view.subviews + _view.subviews.flatMap { subviewsRecursive(in: $0) }
}
var allViews: [UIView] = []
for window in UIApplication.shared.windows {
allViews.append(contentsOf: subviewsRecursive(in: window))
}
allViews.forEach {
let tracker = BorderManager(view: $0)
GLOBAL_BORDER_TRACKERS.append(tracker)
tracker.activate()
}
}
}
@objc func toggleLock() {
scrollLocked.toggle()
}
func toggleVisibility() {
if isVisible {
UIViewPropertyAnimator(duration: 0.25, dampingRatio: 1) { [self] in
consoleView.transform = .init(scaleX: 0.9, y: 0.9)
consoleView.alpha = 0
}.startAnimation()
isVisible = false
} else {
UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in
consoleView.transform = .init(scaleX: 1, y: 1)
consoleView.alpha = 1
}.startAnimation()
isVisible = true
}
// Renders color properly (for dark appearance).
consoleView.backgroundColor = .black
}
public func _print(_ items: Any) {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.headIndent = 7
let attributes: [NSAttributedString.Key: Any] = [
.paragraphStyle: paragraphStyle,
.foregroundColor: UIColor.white,
.font: UIFont.systemFont(ofSize: 7, weight: .semibold, design: .monospaced)
]
let string: String = {
if consoleTextView.attributedText.string == "" {
return "\(items)"
} else {
return "\(items)\n" + consoleTextView.text
}
}()
consoleTextView.attributedText = NSAttributedString(string: string, attributes: attributes)
}
public func clear() {
consoleTextView.text = ""
}
func makeMenu() -> UIMenu {
let viewFrames = UIAction(title: debugBordersEnabled ? "Hide View Frames" : "Show View Frames",
image: UIImage(systemName: "rectangle.3.offgrid"), handler: { _ in
self.debugBordersEnabled.toggle()
self.menuButton?.menu = self.makeMenu()
})
let respring = UIAction(title: "Restart SpringBoard",
image: UIImage(systemName: "apps.iphone"), handler: { _ in
guard let window = UIApplication.shared.windows.first else { return }
window.layer.cornerRadius = UIScreen.main.value(forKey: "_displayCornerRadius") as! CGFloat
window.layer.masksToBounds = true
let animator = UIViewPropertyAnimator(duration: 0.5, dampingRatio: 1) {
window.transform = .init(scaleX: 0.96, y: 0.96)
window.alpha = 0
}
animator.addCompletion { _ in
while true {
window.snapshotView(afterScreenUpdates: false)
}
}
animator.startAnimation()
})
return UIMenu(title: "", children: [viewFrames, respring])
}
@objc func longPressAction(recognizer: UILongPressGestureRecognizer) {
switch recognizer.state {
case .began:
feedbackGenerator.selectionChanged()
scrollLocked = false
UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in
consoleView.transform = .init(scaleX: 1.04, y: 1.04)
consoleTextView.alpha = 0.5
}.startAnimation()
case .cancelled, .ended:
scrollLocked = true
UIViewPropertyAnimator(duration: 0.8, dampingRatio: 0.5) { [self] in
consoleView.transform = .init(scaleX: 1, y: 1)
}.startAnimation()
UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in
consoleTextView.alpha = 1
}.startAnimation()
default: break
}
}
@objc func consolePiPPanner(recognizer: UIPanGestureRecognizer) {
if recognizer.state == .began {
initialViewLocation = consoleView.center
}
guard !scrollLocked else { return }
let translation = recognizer.translation(in: consoleView.superview)
let velocity = recognizer.velocity(in: consoleView.superview)
switch recognizer.state {
case .changed:
consoleView.center.x = initialViewLocation.x + translation.x
consoleView.center.y = initialViewLocation.y + translation.y
case .ended, .cancelled:
// After the PiP is thrown, determine the best corner and re-target it there.
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let projectedPosition = CGPoint(
x: consoleView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),
y: consoleView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate)
)
let nearestTargetPosition = nearestTargetTo(projectedPosition, possibleTargets: possibleEndpoints)
let relativeInitialVelocity = CGVector(
dx: relativeVelocity(forVelocity: velocity.x, from: consoleView.center.x, to: nearestTargetPosition.x),
dy: relativeVelocity(forVelocity: velocity.y, from: consoleView.center.y, to: nearestTargetPosition.y)
)
let timingParameters = UISpringTimingParameters(damping: 1, response: 0.4, initialVelocity: relativeInitialVelocity)
let positionAnimator = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters)
positionAnimator.addAnimations { [self] in
consoleView.center = nearestTargetPosition
}
positionAnimator.startAnimation()
default: break
}
}
// Animate touch down.
func consolePiPTouchDown() {
UIViewPropertyAnimator(duration: 1, dampingRatio: 0.5) { [self] in
consoleView.transform = .init(scaleX: 0.96, y: 0.96)
}.startAnimation()
UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in
if !scrollLocked {
consoleView.backgroundColor = #colorLiteral(red: 0.1331297589, green: 0.1331297589, blue: 0.1331297589, alpha: 1)
}
}.startAnimation()
}
// Animate touch up.
func consolePiPTouchUp() {
UIViewPropertyAnimator(duration: 0.8, dampingRatio: 0.4) { [self] in
consoleView.transform = .init(scaleX: 1, y: 1)
}.startAnimation()
UIViewPropertyAnimator(duration: 0.4, dampingRatio: 1) { [self] in
consoleTextView.alpha = 1
}.startAnimation()
UIViewPropertyAnimator(duration: 0.75, dampingRatio: 1) { [self] in
consoleView.backgroundColor = .black
}.startAnimation()
}
// Simulataneously listen to all gesture recognizers.
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
@objc func consolePiPTapStartEnd(recognizer: UITapStartEndGestureRecognizer) {
switch recognizer.state {
case .began:
consolePiPTouchDown()
case .cancelled:
consolePiPTouchUp()
case .changed:
break
case .ended:
consolePiPTouchUp()
case .failed:
consolePiPTouchUp()
case .possible:
consolePiPTouchUp()
@unknown default:
break
}
}
}
// Custom window for the console to appear above other windows while passing touches down.
class ConsoleWindow: UIWindow {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hitView = super.hitTest(point, with: event)!
return hitView.isKind(of: ConsoleWindow.self) ? nil : hitView
}
}
import UIKit.UIGestureRecognizerSubclass
public class UITapStartEndGestureRecognizer: UITapGestureRecognizer {
override public func touchesBegan(_ touches: Set<UITouch>, with: UIEvent) {
self.state = .began
}
override public func touchesMoved(_ touches: Set<UITouch>, with: UIEvent) {
self.state = .changed
}
override public func touchesEnded(_ touches: Set<UITouch>, with: UIEvent) {
self.state = .ended
}
}
// MARK: Fun hacks!
extension UIView {
/// Swizzle UIView to use custom frame system when needed.
static let swizzleDebugBehaviour: Void = {
guard let originalMethod = class_getInstanceMethod(UIView.self, #selector(layoutSubviews)),
let swizzledMethod = class_getInstanceMethod(UIView.self, #selector(swizzled_layoutSubviews)) else { return }
method_exchangeImplementations(originalMethod, swizzledMethod)
}()
@objc func swizzled_layoutSubviews() {
swizzled_layoutSubviews()
if GLOBAL_DEBUG_BORDERS {
let tracker = BorderManager(view: self)
GLOBAL_BORDER_TRACKERS.append(tracker)
tracker.activate()
}
}
}
extension UIWindow {
/// Make sure this window does not have control over the status bar appearance.
static let swizzleStatusBarAppearanceOverride: Void = {
guard let originalMethod = class_getInstanceMethod(UIWindow.self, NSSelectorFromString("_can" + "Affect" + "Status" + "Bar" + "Appearance")),
let swizzledMethod = class_getInstanceMethod(UIWindow.self, #selector(swizzled_statusBarAppearance))
else { return }
method_exchangeImplementations(originalMethod, swizzledMethod)
}()
@objc func swizzled_statusBarAppearance() -> Bool {
return isKeyWindow
}
}
#endif