The app is FTStore Admin. Its own AGENTS.md already sets the vocabulary — "the FTStore Admin server" for web/, "the FTStore Admin app" for this one — and then most of the comments in it said "the console" anyway. Three user-visible strings went with it: the lock screen, the menu item that locks it, and the biometric prompt. The storage-notification footer named the console too. Their catalog keys move with them, in every locale the app ships. Not "the app": inside these files that already means FTStore on somebody's phone, so a blind swap produced lines like "Dashboard and app only, never the app". AdminConsoleView is AdminHomeView. The Console/ directory keeps its name: the history of Console/*.swift runs back through manager/FTStore/Admin/ and is documented as doing so, which is worth more than a path nobody reads as a name. Also fills a translation gap this turned up — the status-change footer in the feedback detail was untranslated.
123 lines
4.0 KiB
Swift
123 lines
4.0 KiB
Swift
//
|
||
// AdminUIKit.swift
|
||
// FTStore Admin
|
||
//
|
||
// The three UIKit helpers FTStore Admin borrows from FTStore's engine, inlined
|
||
// so this app needs no Swift package at all: it links nothing but the system
|
||
// frameworks, which is what keeps a decrypted IPA down to a login form.
|
||
//
|
||
// Adapted from NimbleKit (`manager/NimbleKit`, MIT — Copyright 2025 CLARATION):
|
||
// `UIImage+resize.swift` and `UlActivityViewController+Present.swift`. The
|
||
// presenter lookup is rewritten against `UIWindowScene`, since the original
|
||
// went through the long-deprecated `UIApplication.keyWindow`.
|
||
//
|
||
|
||
import UIKit
|
||
import AVFoundation
|
||
|
||
// MARK: - Presentation
|
||
|
||
extension UIApplication {
|
||
/// The view controller a modal should be presented from: the foreground
|
||
/// scene's key window, walked down through navigation/tab/presented
|
||
/// controllers.
|
||
///
|
||
/// The starting point is resolved in the body rather than as a default
|
||
/// argument — a default argument is evaluated at the call site, which is not
|
||
/// main-actor isolated, and reading `foregroundKeyWindow` there doesn't
|
||
/// compile.
|
||
@MainActor
|
||
static func topViewController() -> UIViewController? {
|
||
_topViewController(from: shared.foregroundKeyWindow?.rootViewController)
|
||
}
|
||
|
||
@MainActor
|
||
private static func _topViewController(from controller: UIViewController?) -> UIViewController? {
|
||
if let navigation = controller as? UINavigationController {
|
||
return _topViewController(from: navigation.visibleViewController)
|
||
}
|
||
if let tab = controller as? UITabBarController, let selected = tab.selectedViewController {
|
||
return _topViewController(from: selected)
|
||
}
|
||
if let presented = controller?.presentedViewController {
|
||
return _topViewController(from: presented)
|
||
}
|
||
return controller
|
||
}
|
||
|
||
@MainActor
|
||
var foregroundKeyWindow: UIWindow? {
|
||
connectedScenes
|
||
.compactMap { $0 as? UIWindowScene }
|
||
.first { $0.activationState == .foregroundActive }?
|
||
.windows
|
||
.first { $0.isKeyWindow }
|
||
?? connectedScenes
|
||
.compactMap { $0 as? UIWindowScene }
|
||
.flatMap(\.windows)
|
||
.first { $0.isKeyWindow }
|
||
}
|
||
}
|
||
|
||
extension UIActivityViewController {
|
||
/// Present a share sheet from the top-most controller. Used for the settings
|
||
/// backup export, which is a file the operator hands off to somewhere else.
|
||
@MainActor
|
||
static func show(activityItems: [Any], applicationActivities: [UIActivity]? = nil) {
|
||
guard let presenter = UIApplication.topViewController() else { return }
|
||
|
||
let controller = Self(
|
||
activityItems: activityItems,
|
||
applicationActivities: applicationActivities
|
||
)
|
||
|
||
// iPad presents this as a popover and crashes without an anchor.
|
||
if let popover = controller.popoverPresentationController {
|
||
popover.sourceView = presenter.view
|
||
popover.sourceRect = CGRect(
|
||
x: presenter.view.bounds.midX,
|
||
y: presenter.view.bounds.midY,
|
||
width: 0,
|
||
height: 0
|
||
)
|
||
popover.permittedArrowDirections = []
|
||
}
|
||
|
||
presenter.present(controller, animated: true)
|
||
}
|
||
}
|
||
|
||
// MARK: - Images
|
||
|
||
extension UIImage {
|
||
/// Centre-crop to a square, so a tweak icon picked from Photos or the camera
|
||
/// isn't letterboxed when the server scales it.
|
||
func resizeToSquare() -> UIImage? {
|
||
let side = min(size.width, size.height)
|
||
let origin = CGPoint(x: (size.width - side) / 2, y: (size.height - side) / 2)
|
||
|
||
UIGraphicsBeginImageContextWithOptions(CGSize(width: side, height: side), false, scale)
|
||
draw(at: CGPoint(x: -origin.x, y: -origin.y))
|
||
let square = UIGraphicsGetImageFromCurrentImageContext()
|
||
UIGraphicsEndImageContext()
|
||
|
||
return square
|
||
}
|
||
|
||
/// Aspect-fit into `width` × `height` at scale 1, so the uploaded icon is the
|
||
/// pixel size the caller asked for rather than a Retina multiple of it.
|
||
func resize(_ width: Int, _ height: Int) -> UIImage {
|
||
let target = AVFoundation.AVMakeRect(
|
||
aspectRatio: size,
|
||
insideRect: CGRect(origin: .zero, size: CGSize(width: width, height: height))
|
||
).size
|
||
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.scale = 1
|
||
|
||
return UIGraphicsImageRenderer(size: target, format: format).image { _ in
|
||
draw(in: CGRect(origin: .zero, size: target))
|
||
}
|
||
}
|
||
}
|