mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
Hopefully fix some weird issues
This commit is contained in:
parent
f9c1f46fad
commit
ec147679e1
49 changed files with 2458 additions and 2287 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -15,6 +15,7 @@ xcuserdata/
|
|||
## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
|
||||
build/
|
||||
DerivedData/
|
||||
.derivedData*/
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
|
|
|
|||
|
|
@ -579,7 +579,7 @@
|
|||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
|
|
@ -611,7 +611,7 @@
|
|||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.tests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
|
|
@ -634,7 +634,7 @@
|
|||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugUITests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.uitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
|
|
@ -656,7 +656,7 @@
|
|||
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugUITests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.uitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
|
|
|
|||
47
StikDebug/App/AppBootstrapper.swift
Normal file
47
StikDebug/App/AppBootstrapper.swift
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// AppBootstrapper.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ObjectiveC.runtime
|
||||
import UIKit
|
||||
|
||||
enum AppBootstrapper {
|
||||
static func configure() {
|
||||
registerDefaultSettings()
|
||||
startConfiguredKeepAliveServices()
|
||||
applyDocumentPickerCopyWorkaround()
|
||||
}
|
||||
|
||||
private static func registerDefaultSettings() {
|
||||
let os = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let enableAdvancedOptions = os.majorVersion >= 19
|
||||
|
||||
UserDefaults.standard.register(defaults: [
|
||||
"enableAdvancedOptions": enableAdvancedOptions,
|
||||
UserDefaults.Keys.txmOverride: false,
|
||||
"keepAliveAudio": true,
|
||||
"keepAliveLocation": true
|
||||
])
|
||||
}
|
||||
|
||||
private static func startConfiguredKeepAliveServices() {
|
||||
guard UserDefaults.standard.bool(forKey: "keepAliveAudio") else {
|
||||
return
|
||||
}
|
||||
BackgroundAudioManager.shared.start()
|
||||
}
|
||||
|
||||
private static func applyDocumentPickerCopyWorkaround() {
|
||||
let fixedSelector = NSSelectorFromString("fix_initForOpeningContentTypes:asCopy:")
|
||||
let originalSelector = #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:))
|
||||
|
||||
guard let fixedMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, fixedSelector),
|
||||
let originalMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, originalSelector) else {
|
||||
return
|
||||
}
|
||||
|
||||
method_exchangeImplementations(originalMethod, fixedMethod)
|
||||
}
|
||||
}
|
||||
|
|
@ -298,7 +298,7 @@ struct StikDebugShortcuts: AppShortcutsProvider {
|
|||
|
||||
func ensureTunnel() async {
|
||||
await MainActor.run {
|
||||
pubTunnelConnected = false
|
||||
markTunnelDisconnected()
|
||||
startTunnelInBackground(showErrorUI: false)
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
58
StikDebug/App/StikDebugApp.swift
Normal file
58
StikDebug/App/StikDebugApp.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// StikDebugApp.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Created by Stephen on 3/26/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct StikDebugApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var shouldAttemptTunnelReconnect = false
|
||||
|
||||
init() {
|
||||
AppBootstrapper.configure()
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainTabView()
|
||||
.task {
|
||||
await downloadMissingDeveloperDiskImageFiles()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
handleScenePhaseChange(newPhase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||
switch newPhase {
|
||||
case .background:
|
||||
shouldAttemptTunnelReconnect = true
|
||||
case .active:
|
||||
if shouldAttemptTunnelReconnect {
|
||||
shouldAttemptTunnelReconnect = false
|
||||
startTunnelInBackground(showErrorUI: false)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadMissingDeveloperDiskImageFiles() async {
|
||||
do {
|
||||
try await DeveloperDiskImageService.shared.downloadMissingFiles()
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
title: "An Error has Occurred",
|
||||
message: "[Download DDI Error]: \(error.localizedDescription)",
|
||||
showOk: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -90,13 +90,4 @@ final class LogManager: ObservableObject {
|
|||
self.errorCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
func removeOldestLogs(count: Int) {
|
||||
DispatchQueue.main.async {
|
||||
let removed = self.logs.prefix(count)
|
||||
self.logs.removeFirst(count)
|
||||
let removedErrors = removed.filter { $0.type == .error }.count
|
||||
self.errorCount = max(0, self.errorCount - removedErrors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -379,7 +379,9 @@ struct CSVDocument: FileDocument {
|
|||
static var writableContentTypes: [UTType] { [UTType.commaSeparatedText] }
|
||||
let url: URL?
|
||||
init(url: URL?) { self.url = url }
|
||||
init(configuration: ReadConfiguration) throws { fatalError("Not supported") }
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
throw CocoaError(.fileReadUnsupportedScheme)
|
||||
}
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
guard let url = url else { throw NSError(domain: "CSVDocument", code: -1) }
|
||||
return try FileWrapper(url: url, options: .immediate)
|
||||
92
StikDebug/Device/MountingProgress.swift
Normal file
92
StikDebug/Device/MountingProgress.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// MountingProgress.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import idevice
|
||||
|
||||
final class MountingProgress: ObservableObject {
|
||||
static let shared = MountingProgress()
|
||||
|
||||
@Published private(set) var mountProgress: Double = 0.0
|
||||
@Published private(set) var mountingThread: Thread?
|
||||
@Published private(set) var coolisMounted: Bool = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func checkforMounted() {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let mounted = isMounted()
|
||||
DispatchQueue.main.async {
|
||||
self.coolisMounted = mounted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) {
|
||||
let percentage = Double(progress) / Double(total) * 100.0
|
||||
DispatchQueue.main.async {
|
||||
self.mountProgress = percentage
|
||||
}
|
||||
}
|
||||
|
||||
func pubMount() {
|
||||
mount()
|
||||
}
|
||||
|
||||
private func mount() {
|
||||
let currentlyMounted = isMounted()
|
||||
DispatchQueue.main.async {
|
||||
self.coolisMounted = currentlyMounted
|
||||
}
|
||||
|
||||
guard isPairing(), !currentlyMounted else {
|
||||
return
|
||||
}
|
||||
|
||||
if let mountingThread {
|
||||
mountingThread.cancel()
|
||||
self.mountingThread = nil
|
||||
}
|
||||
|
||||
let thread = Thread { [weak self] in
|
||||
guard let self else { return }
|
||||
let mountError = mountPersonalDDI(
|
||||
imagePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg").path,
|
||||
trustcachePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path,
|
||||
manifestPath: URL.documentsDirectory.appendingPathComponent("DDI/BuildManifest.plist").path
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let mountError {
|
||||
showAlert(title: "DDI Mount Failed", message: mountError, showOk: true, showTryAgain: true) { shouldTryAgain in
|
||||
if shouldTryAgain {
|
||||
self.mount()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.coolisMounted = true
|
||||
self.checkforMounted()
|
||||
}
|
||||
self.mountingThread = nil
|
||||
}
|
||||
}
|
||||
|
||||
thread.qualityOfService = .background
|
||||
thread.name = "mounting"
|
||||
thread.start()
|
||||
mountingThread = thread
|
||||
}
|
||||
}
|
||||
|
||||
func isPairing() -> Bool {
|
||||
let pairingPath = PairingFileStore.prepareURL().path
|
||||
var pairingFile: RpPairingFileHandle?
|
||||
let error = rp_pairing_file_read(pairingPath, &pairingFile)
|
||||
if error != nil {
|
||||
return false
|
||||
}
|
||||
rp_pairing_file_free(pairingFile)
|
||||
return true
|
||||
}
|
||||
|
|
@ -14,6 +14,14 @@ final class SystemLogStream: ObservableObject {
|
|||
let timestamp: Date
|
||||
let message: String
|
||||
let raw: String
|
||||
let searchableRaw: String
|
||||
|
||||
init(timestamp: Date, message: String, raw: String) {
|
||||
self.timestamp = timestamp
|
||||
self.message = message
|
||||
self.raw = raw
|
||||
self.searchableRaw = raw.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var entries: [Entry] = []
|
||||
|
|
@ -167,12 +175,6 @@ final class SystemLogStream: ObservableObject {
|
|||
return line
|
||||
}
|
||||
|
||||
func concatenatedLog(limit: Int = 500) -> String {
|
||||
let slice = entries.suffix(limit)
|
||||
return slice.map { "[\(DateFormatter.consoleFormatter.string(from: $0.timestamp))] \($0.raw)" }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func scheduleFlushIfNeeded() {
|
||||
guard !isPaused else { return }
|
||||
guard !pendingEntries.isEmpty else { return }
|
||||
|
|
@ -215,11 +217,3 @@ final class SystemLogStream: ObservableObject {
|
|||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private extension DateFormatter {
|
||||
static let consoleFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
86
StikDebug/Features/Home/HomeExternalAction.swift
Normal file
86
StikDebug/Features/Home/HomeExternalAction.swift
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// HomeExternalAction.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct JITEnableConfiguration {
|
||||
var bundleID: String?
|
||||
var pid: Int?
|
||||
var scriptData: Data?
|
||||
var scriptName: String?
|
||||
}
|
||||
|
||||
enum HomeExternalAction: Identifiable {
|
||||
case enableJIT(JITEnableConfiguration)
|
||||
case killProcess(Int)
|
||||
case launchApp(String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .enableJIT(let configuration):
|
||||
return "enable-\(configuration.bundleID ?? "")-\(configuration.pid ?? 0)-\(configuration.scriptName ?? "")"
|
||||
case .killProcess(let pid):
|
||||
return "kill-\(pid)"
|
||||
case .launchApp(let bundleID):
|
||||
return "launch-\(bundleID)"
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .enableJIT:
|
||||
return "Enable JIT?"
|
||||
case .killProcess:
|
||||
return "Kill Process?"
|
||||
case .launchApp:
|
||||
return "Launch App?"
|
||||
}
|
||||
}
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .enableJIT(let configuration):
|
||||
let scriptText = configuration.scriptData == nil ? "" : " and run a script"
|
||||
return "An external link wants to enable JIT\(scriptText) for \(targetDescription(for: configuration))."
|
||||
case .killProcess(let pid):
|
||||
return "An external link wants to kill process \(pid)."
|
||||
case .launchApp(let bundleID):
|
||||
return "An external link wants to launch \(bundleID)."
|
||||
}
|
||||
}
|
||||
|
||||
var confirmationTitle: String {
|
||||
switch self {
|
||||
case .enableJIT(let configuration):
|
||||
return configuration.scriptData == nil ? "Enable JIT" : "Enable and Run Script"
|
||||
case .killProcess:
|
||||
return "Kill Process"
|
||||
case .launchApp:
|
||||
return "Launch App"
|
||||
}
|
||||
}
|
||||
|
||||
var role: ButtonRole? {
|
||||
switch self {
|
||||
case .enableJIT(let configuration):
|
||||
return configuration.scriptData == nil ? nil : .destructive
|
||||
case .killProcess:
|
||||
return .destructive
|
||||
case .launchApp:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func targetDescription(for configuration: JITEnableConfiguration) -> String {
|
||||
if let bundleID = configuration.bundleID {
|
||||
return bundleID
|
||||
}
|
||||
if let pid = configuration.pid {
|
||||
return "process \(pid)"
|
||||
}
|
||||
return "the requested app"
|
||||
}
|
||||
}
|
||||
252
StikDebug/Features/InstalledApps/AppIconRepository.swift
Normal file
252
StikDebug/Features/InstalledApps/AppIconRepository.swift
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
//
|
||||
// AppIconRepository.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum AppIconRepository {
|
||||
private static let memory: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 2_000
|
||||
cache.totalCostLimit = 64 * 1024 * 1024
|
||||
return cache
|
||||
}()
|
||||
|
||||
private static let diskQueue = DispatchQueue(label: "com.stik.iconcache.disk", qos: .utility)
|
||||
private static let fetchSemaphore = AsyncSemaphore(permits: 4)
|
||||
private static let registry = IconFetchRegistry()
|
||||
private static let appGroupIdentifier = "group.com.stik.sj"
|
||||
|
||||
static func cachedImage(for bundleID: String) -> UIImage? {
|
||||
memory.object(forKey: bundleID as NSString)
|
||||
}
|
||||
|
||||
static func image(for bundleID: String) async -> UIImage? {
|
||||
if let memoryImage = cachedImage(for: bundleID) {
|
||||
return memoryImage
|
||||
}
|
||||
|
||||
if let diskImage = await loadFromDisk(bundleID: bundleID) {
|
||||
storeInMemory(diskImage, for: bundleID)
|
||||
return diskImage
|
||||
}
|
||||
|
||||
return await fetchAndStore(bundleID: bundleID)
|
||||
}
|
||||
|
||||
static func prefetch(bundleIDs: [String]) {
|
||||
for bundleID in Set(bundleIDs) {
|
||||
Task.detached(priority: .utility) {
|
||||
_ = await image(for: bundleID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchAndStore(bundleID: String) async -> UIImage? {
|
||||
let task = await registry.task(for: bundleID) {
|
||||
Task.detached(priority: .utility) {
|
||||
await fetchSemaphore.acquire()
|
||||
|
||||
let result: UIImage?
|
||||
if let fetched = await fetchFromSource(bundleID: bundleID) {
|
||||
let prepared = prepareForDisplay(fetched)
|
||||
store(prepared, for: bundleID)
|
||||
result = prepared
|
||||
} else {
|
||||
result = nil
|
||||
}
|
||||
|
||||
await fetchSemaphore.release()
|
||||
await registry.clear(bundleID: bundleID)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return await task.value
|
||||
}
|
||||
|
||||
private static func fetchFromSource(bundleID: String) async -> UIImage? {
|
||||
await withCheckedContinuation { continuation in
|
||||
AppStoreIconFetcher.getIcon(for: bundleID) { image in
|
||||
continuation.resume(returning: image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadFromDisk(bundleID: String) async -> UIImage? {
|
||||
let imageScale = await MainActor.run { UIScreen.main.scale }
|
||||
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<UIImage?, Never>) in
|
||||
diskQueue.async {
|
||||
guard let url = iconURL(for: bundleID),
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = try? Data(contentsOf: url) else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = UIImage(data: data, scale: imageScale) else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
continuation.resume(returning: prepareForDisplay(image))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func store(_ image: UIImage, for bundleID: String) {
|
||||
storeInMemory(image, for: bundleID)
|
||||
storeOnDisk(image, bundleID: bundleID)
|
||||
}
|
||||
|
||||
private static func storeInMemory(_ image: UIImage, for bundleID: String) {
|
||||
memory.setObject(image, forKey: bundleID as NSString, cost: memoryCost(for: image))
|
||||
}
|
||||
|
||||
private static func storeOnDisk(_ image: UIImage, bundleID: String) {
|
||||
diskQueue.async {
|
||||
guard let url = iconURL(for: bundleID),
|
||||
let data = image.pngData() else {
|
||||
return
|
||||
}
|
||||
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
private static func iconURL(for bundleID: String) -> URL? {
|
||||
guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier),
|
||||
let fileName = cacheFileName(for: bundleID) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let directory = container.appendingPathComponent("icons", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func cacheFileName(for bundleID: String) -> String? {
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_"))
|
||||
let sanitized = String(bundleID.unicodeScalars.map { scalar in
|
||||
allowed.contains(scalar) ? Character(scalar) : "_"
|
||||
})
|
||||
|
||||
guard !sanitized.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return "\(sanitized).png"
|
||||
}
|
||||
|
||||
private static func memoryCost(for image: UIImage) -> Int {
|
||||
let width = Int(image.size.width * image.scale)
|
||||
let height = Int(image.size.height * image.scale)
|
||||
return max(width * height * 4, 1)
|
||||
}
|
||||
|
||||
private static func prepareForDisplay(_ image: UIImage) -> UIImage {
|
||||
image.preparingForDisplay() ?? image
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class IconLoader: ObservableObject {
|
||||
@Published private(set) var image: UIImage?
|
||||
|
||||
private let bundleID: String
|
||||
private var didStart = false
|
||||
|
||||
init(bundleID: String) {
|
||||
self.bundleID = bundleID
|
||||
if let cached = AppIconRepository.cachedImage(for: bundleID) {
|
||||
image = cached
|
||||
didStart = true
|
||||
}
|
||||
}
|
||||
|
||||
func beginLoading() {
|
||||
if image != nil {
|
||||
didStart = true
|
||||
return
|
||||
}
|
||||
|
||||
guard !didStart else {
|
||||
return
|
||||
}
|
||||
|
||||
didStart = true
|
||||
let targetID = bundleID
|
||||
|
||||
Task { [weak self] in
|
||||
if let resolved = await AppIconRepository.image(for: targetID) {
|
||||
guard let self else { return }
|
||||
withAnimation(.linear(duration: 0.12)) {
|
||||
self.image = resolved
|
||||
}
|
||||
} else {
|
||||
self?.didStart = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor IconFetchRegistry {
|
||||
private var tasks: [String: Task<UIImage?, Never>] = [:]
|
||||
|
||||
func task(for bundleID: String, create: () -> Task<UIImage?, Never>) -> Task<UIImage?, Never> {
|
||||
if let existing = tasks[bundleID] {
|
||||
return existing
|
||||
}
|
||||
|
||||
let task = create()
|
||||
tasks[bundleID] = task
|
||||
return task
|
||||
}
|
||||
|
||||
func clear(bundleID: String) {
|
||||
tasks[bundleID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
private actor AsyncSemaphore {
|
||||
private var permits: Int
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
init(permits: Int) {
|
||||
self.permits = permits
|
||||
}
|
||||
|
||||
func acquire() async {
|
||||
if permits > 0 {
|
||||
permits -= 1
|
||||
return
|
||||
}
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
waiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func release() {
|
||||
if let next = waiters.first {
|
||||
waiters.removeFirst()
|
||||
next.resume()
|
||||
} else {
|
||||
permits += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
46
StikDebug/Features/InstalledApps/InstalledAppListItem.swift
Normal file
46
StikDebug/Features/InstalledApps/InstalledAppListItem.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// InstalledAppListItem.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct InstalledAppListItem: Identifiable, Equatable {
|
||||
let bundleID: String
|
||||
let name: String
|
||||
|
||||
private let normalizedBundleID: String
|
||||
private let normalizedName: String
|
||||
|
||||
var id: String {
|
||||
bundleID
|
||||
}
|
||||
|
||||
init(bundleID: String, name: String) {
|
||||
self.bundleID = bundleID
|
||||
self.name = name
|
||||
self.normalizedBundleID = Self.normalized(bundleID)
|
||||
self.normalizedName = Self.normalized(name)
|
||||
}
|
||||
|
||||
func matches(_ query: String) -> Bool {
|
||||
query.isEmpty || normalizedBundleID.contains(query) || normalizedName.contains(query)
|
||||
}
|
||||
|
||||
static func sorted(from apps: [String: String]) -> [InstalledAppListItem] {
|
||||
apps.map { InstalledAppListItem(bundleID: $0.key, name: $0.value) }
|
||||
.sorted { lhs, rhs in
|
||||
let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name)
|
||||
if comparison == .orderedSame {
|
||||
return lhs.bundleID < rhs.bundleID
|
||||
}
|
||||
return comparison == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
static func normalized(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
}
|
||||
}
|
||||
361
StikDebug/Features/InstalledApps/InstalledAppRows.swift
Normal file
361
StikDebug/Features/InstalledApps/InstalledAppRows.swift
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
//
|
||||
// InstalledAppRows.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WidgetKit
|
||||
|
||||
struct AppButton: View {
|
||||
let bundleID: String
|
||||
let appName: String
|
||||
|
||||
@Binding var recentApps: [String]
|
||||
@Binding var favoriteApps: [String]
|
||||
|
||||
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
||||
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
|
||||
|
||||
var onSelectApp: (String, String) -> Void
|
||||
let sharedDefaults: UserDefaults
|
||||
|
||||
@State private var showScriptPicker = false
|
||||
@State private var assignedScriptName: String?
|
||||
@StateObject private var iconLoader: IconLoader
|
||||
|
||||
init(
|
||||
bundleID: String,
|
||||
appName: String,
|
||||
recentApps: Binding<[String]>,
|
||||
favoriteApps: Binding<[String]>,
|
||||
onSelectApp: @escaping (String, String) -> Void,
|
||||
sharedDefaults: UserDefaults
|
||||
) {
|
||||
self.bundleID = bundleID
|
||||
self.appName = appName
|
||||
self._recentApps = recentApps
|
||||
self._favoriteApps = favoriteApps
|
||||
self.onSelectApp = onSelectApp
|
||||
self.sharedDefaults = sharedDefaults
|
||||
_iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID))
|
||||
_assignedScriptName = State(initialValue: AppButton.currentAssignment(for: bundleID))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: selectApp) {
|
||||
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
|
||||
AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(appName)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(bundleID)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if favoriteApps.contains(bundleID) {
|
||||
Image(systemName: "star.fill")
|
||||
.imageScale(.medium)
|
||||
.foregroundStyle(.yellow)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
Button(action: toggleFavorite) {
|
||||
Label(
|
||||
favoriteApps.contains(bundleID) ? "Remove Favorite" : "Add to Favorites",
|
||||
systemImage: favoriteApps.contains(bundleID) ? "star.slash" : "star"
|
||||
)
|
||||
.disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4)
|
||||
}
|
||||
Button {
|
||||
copyBundleID()
|
||||
} label: {
|
||||
Label("Copy Bundle ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
if enableAdvancedOptions {
|
||||
Button {
|
||||
showScriptPicker = true
|
||||
} label: {
|
||||
Label("Assign Script", systemImage: "chevron.left.slash.chevron.right")
|
||||
}
|
||||
|
||||
if assignedScriptName != nil {
|
||||
Button(action: resetScriptAssignment) {
|
||||
Label("Reset Script", systemImage: "arrow.uturn.left")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
toggleFavorite()
|
||||
} label: {
|
||||
Label(favoriteApps.contains(bundleID) ? "Unfavorite" : "Favorite", systemImage: "star")
|
||||
}
|
||||
.tint(.yellow)
|
||||
|
||||
Button {
|
||||
copyBundleID()
|
||||
} label: {
|
||||
Label("Copy ID", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showScriptPicker) {
|
||||
ScriptListView { url in
|
||||
assignScript(url)
|
||||
showScriptPicker = false
|
||||
}
|
||||
}
|
||||
.onAppear(perform: beginIconLoadingIfNeeded)
|
||||
.onChange(of: loadAppIconsOnJIT) { _, newValue in
|
||||
if newValue {
|
||||
iconLoader.beginLoading()
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(format: "Enable JIT for %@".localized, appName))
|
||||
.accessibilityValue(accessibilityValue)
|
||||
.accessibilityHint("Double-tap to open the app and enable JIT. Use the actions rotor for favorites or bundle ID.".localized)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityRemoveTraits(.isStaticText)
|
||||
.accessibilityAction(named: Text(favoriteAccessibilityActionLabel)) {
|
||||
toggleFavorite()
|
||||
}
|
||||
.accessibilityAction(named: Text("Copy Bundle ID".localized)) {
|
||||
copyBundleID()
|
||||
}
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
var parts = [String(format: "Bundle ID %@".localized, bundleID)]
|
||||
if favoriteApps.contains(bundleID) {
|
||||
parts.append("Favorite".localized)
|
||||
}
|
||||
if let assignedScriptName {
|
||||
parts.append(String(format: "Assigned script %@".localized, assignedScriptName))
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private var favoriteAccessibilityActionLabel: String {
|
||||
favoriteApps.contains(bundleID)
|
||||
? "Remove from Favorites".localized
|
||||
: "Add to Favorites".localized
|
||||
}
|
||||
|
||||
private func beginIconLoadingIfNeeded() {
|
||||
guard loadAppIconsOnJIT else {
|
||||
return
|
||||
}
|
||||
iconLoader.beginLoading()
|
||||
}
|
||||
|
||||
private func selectApp() {
|
||||
Haptics.selection()
|
||||
recentApps.removeAll { $0 == bundleID }
|
||||
recentApps.insert(bundleID, at: 0)
|
||||
if recentApps.count > 3 {
|
||||
recentApps = Array(recentApps.prefix(3))
|
||||
}
|
||||
persistIfChanged()
|
||||
onSelectApp(bundleID, appName)
|
||||
}
|
||||
|
||||
private func toggleFavorite() {
|
||||
Haptics.light()
|
||||
let wasFavorite = favoriteApps.contains(bundleID)
|
||||
|
||||
if wasFavorite {
|
||||
favoriteApps.removeAll { $0 == bundleID }
|
||||
} else if favoriteApps.count < 4 {
|
||||
favoriteApps.insert(bundleID, at: 0)
|
||||
recentApps.removeAll { $0 == bundleID }
|
||||
} else {
|
||||
AccessibilityAnnouncer.announce("Favorites are full".localized)
|
||||
return
|
||||
}
|
||||
|
||||
persistIfChanged()
|
||||
AccessibilityAnnouncer.announce(wasFavorite ? "Removed from Favorites".localized : "Added to Favorites".localized)
|
||||
}
|
||||
|
||||
private func copyBundleID() {
|
||||
UIPasteboard.general.string = bundleID
|
||||
Haptics.light()
|
||||
AccessibilityAnnouncer.announce("Bundle ID copied".localized)
|
||||
}
|
||||
|
||||
private func assignScript(_ url: URL?) {
|
||||
if let url {
|
||||
let filename = url.lastPathComponent
|
||||
ScriptStore.updateAssignedScriptName(filename, for: bundleID)
|
||||
assignedScriptName = filename
|
||||
} else {
|
||||
ScriptStore.updateAssignedScriptName(nil, for: bundleID)
|
||||
assignedScriptName = nil
|
||||
}
|
||||
Haptics.light()
|
||||
}
|
||||
|
||||
private func resetScriptAssignment() {
|
||||
assignScript(nil)
|
||||
}
|
||||
|
||||
private static func currentAssignment(for bundleID: String) -> String? {
|
||||
ScriptStore.assignedScriptName(for: bundleID)
|
||||
}
|
||||
|
||||
private func persistIfChanged() {
|
||||
var touched = false
|
||||
let previousRecents = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? []
|
||||
let previousFavorites = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? []
|
||||
|
||||
if previousRecents != recentApps {
|
||||
sharedDefaults.set(recentApps, forKey: "recentApps")
|
||||
touched = true
|
||||
}
|
||||
if previousFavorites != favoriteApps {
|
||||
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
||||
touched = true
|
||||
}
|
||||
if touched {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LaunchAppRow: View {
|
||||
let bundleID: String
|
||||
let appName: String
|
||||
let isLaunching: Bool
|
||||
var launchAction: () -> Void
|
||||
|
||||
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
||||
@StateObject private var iconLoader: IconLoader
|
||||
|
||||
init(
|
||||
bundleID: String,
|
||||
appName: String,
|
||||
isLaunching: Bool,
|
||||
launchAction: @escaping () -> Void
|
||||
) {
|
||||
self.bundleID = bundleID
|
||||
self.appName = appName
|
||||
self.isLaunching = isLaunching
|
||||
self.launchAction = launchAction
|
||||
_iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
guard !isLaunching else { return }
|
||||
launchAction()
|
||||
} label: {
|
||||
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
|
||||
AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(appName)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(bundleID)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isLaunching {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Launch".localized)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color.accentColor.opacity(0.18)))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLaunching)
|
||||
.onAppear(perform: beginIconLoadingIfNeeded)
|
||||
.onChange(of: loadAppIconsOnJIT) { _, newValue in
|
||||
if newValue {
|
||||
iconLoader.beginLoading()
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(format: "Launch %@".localized, appName))
|
||||
.accessibilityValue(accessibilityValue)
|
||||
.accessibilityHint(isLaunching
|
||||
? "Launch request in progress.".localized
|
||||
: "Double-tap to launch this app without enabling JIT.".localized)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityRemoveTraits(.isStaticText)
|
||||
.accessibilityAction(named: Text("Launch App".localized)) {
|
||||
guard !isLaunching else { return }
|
||||
launchAction()
|
||||
}
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
let state = isLaunching ? "Launching".localized : "Ready".localized
|
||||
return "\(state), \(String(format: "Bundle ID %@".localized, bundleID))"
|
||||
}
|
||||
|
||||
private func beginIconLoadingIfNeeded() {
|
||||
guard loadAppIconsOnJIT else {
|
||||
return
|
||||
}
|
||||
iconLoader.beginLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppIconView: View {
|
||||
let image: UIImage?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 54, height: 54)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(UIColor.systemGray5))
|
||||
.frame(width: 54, height: 54)
|
||||
.overlay {
|
||||
Image(systemName: "app")
|
||||
.font(.system(size: 24, weight: .regular))
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
147
StikDebug/Features/InstalledApps/InstalledAppsViewModel.swift
Normal file
147
StikDebug/Features/InstalledApps/InstalledAppsViewModel.swift
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
//
|
||||
// InstalledAppsViewModel.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
final class InstalledAppsViewModel: ObservableObject {
|
||||
@Published private(set) var debuggableApps: [String: String] = [:]
|
||||
@Published private(set) var nonDebuggableApps: [String: String] = [:]
|
||||
@Published private(set) var systemApps: [String: String] = [:]
|
||||
@Published private(set) var debuggableItems: [InstalledAppListItem] = []
|
||||
@Published private(set) var launchItems: [InstalledAppListItem] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var lastError: String?
|
||||
|
||||
private let workQueue = DispatchQueue(label: "com.stik.installedApps", qos: .userInitiated)
|
||||
private let cache = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard
|
||||
private let cacheKeyDebuggable = "cachedDebuggableApps"
|
||||
private let cacheKeyNonDebuggable = "cachedNonDebuggableApps"
|
||||
private let cacheKeySystem = "cachedSystemApps"
|
||||
|
||||
init() {
|
||||
loadCachedApps()
|
||||
refreshAppLists()
|
||||
}
|
||||
|
||||
func refreshAppLists() {
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
|
||||
workQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
let debuggable = try JITEnableContext.shared.getAppList()
|
||||
let allApps = try JITEnableContext.shared.getAllApps()
|
||||
let hiddenSystem = (try? JITEnableContext.shared.getHiddenSystemApps()) ?? [:]
|
||||
let classifiedApps = Self.classifyApps(
|
||||
allApps: allApps,
|
||||
debuggable: debuggable,
|
||||
hiddenSystem: hiddenSystem
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.apply(
|
||||
debuggable: debuggable,
|
||||
nonDebuggable: classifiedApps.nonDebuggable,
|
||||
system: classifiedApps.system
|
||||
)
|
||||
self.isLoading = false
|
||||
self.cacheApps(
|
||||
debuggable: debuggable,
|
||||
nonDebuggable: classifiedApps.nonDebuggable,
|
||||
system: classifiedApps.system
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
self.lastError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func displayName(for bundleID: String) -> String? {
|
||||
debuggableApps[bundleID] ?? systemApps[bundleID] ?? nonDebuggableApps[bundleID]
|
||||
}
|
||||
|
||||
func launchWithoutDebug(bundleID: String, completion: @escaping (Bool) -> Void) {
|
||||
workQueue.async {
|
||||
let success = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil)
|
||||
DispatchQueue.main.async {
|
||||
completion(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCachedApps() {
|
||||
let cachedDebuggable = decode(cacheKeyDebuggable)
|
||||
let cachedNonDebuggable = decode(cacheKeyNonDebuggable)
|
||||
let cachedSystem = decode(cacheKeySystem)
|
||||
|
||||
if !cachedDebuggable.isEmpty || !cachedNonDebuggable.isEmpty || !cachedSystem.isEmpty {
|
||||
apply(debuggable: cachedDebuggable, nonDebuggable: cachedNonDebuggable, system: cachedSystem)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) {
|
||||
debuggableApps = debuggable
|
||||
nonDebuggableApps = nonDebuggable
|
||||
systemApps = system
|
||||
debuggableItems = InstalledAppListItem.sorted(from: debuggable)
|
||||
launchItems = InstalledAppListItem.sorted(from: Self.launchApps(nonDebuggable: nonDebuggable, system: system))
|
||||
}
|
||||
|
||||
private func cacheApps(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) {
|
||||
cache.set(encode(debuggable), forKey: cacheKeyDebuggable)
|
||||
cache.set(encode(nonDebuggable), forKey: cacheKeyNonDebuggable)
|
||||
cache.set(encode(system), forKey: cacheKeySystem)
|
||||
}
|
||||
|
||||
private func decode(_ key: String) -> [String: String] {
|
||||
guard let data = cache.data(forKey: key),
|
||||
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func encode(_ value: [String: String]) -> Data? {
|
||||
try? JSONEncoder().encode(value)
|
||||
}
|
||||
|
||||
private static func classifyApps(
|
||||
allApps: [String: String],
|
||||
debuggable: [String: String],
|
||||
hiddenSystem: [String: String]
|
||||
) -> (nonDebuggable: [String: String], system: [String: String]) {
|
||||
var nonDebuggable: [String: String] = [:]
|
||||
var system: [String: String] = [:]
|
||||
|
||||
for (bundleID, name) in allApps where debuggable[bundleID] == nil {
|
||||
if let hiddenName = hiddenSystem[bundleID] {
|
||||
system[bundleID] = hiddenName
|
||||
} else {
|
||||
nonDebuggable[bundleID] = name
|
||||
}
|
||||
}
|
||||
|
||||
for (bundleID, name) in hiddenSystem where system[bundleID] == nil && debuggable[bundleID] == nil {
|
||||
system[bundleID] = name
|
||||
}
|
||||
|
||||
return (nonDebuggable, system)
|
||||
}
|
||||
|
||||
private static func launchApps(nonDebuggable: [String: String], system: [String: String]) -> [String: String] {
|
||||
var combined = nonDebuggable
|
||||
for (bundleID, name) in system {
|
||||
combined[bundleID] = name
|
||||
}
|
||||
return combined
|
||||
}
|
||||
}
|
||||
|
|
@ -8,12 +8,13 @@
|
|||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.stik.StikDebug.enableJIT</string>
|
||||
<string>com.stik.stikdebug.enableJIT</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>stikdebug</string>
|
||||
</array>
|
||||
</dict>
|
||||
<array>
|
||||
<string>stikdebug</string>
|
||||
<string>stikjit</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.stik.StikDebug.stiktool</string>
|
||||
<string>com.stik.stikdebug.stiktool</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import UIKit
|
|||
|
||||
class AppStoreIconFetcher {
|
||||
private static var cache = [String: UIImage]()
|
||||
private static let queue = DispatchQueue(label: "com.stik.StikDebug.iconFetchQueue", attributes: .concurrent)
|
||||
private static let queue = DispatchQueue(label: "com.stik.stikdebug.iconFetchQueue", attributes: .concurrent)
|
||||
|
||||
static func getIcon(for bundleID: String, completion: @escaping (UIImage?) -> Void) {
|
||||
if let icon = cache[bundleID] {
|
||||
64
StikDebug/Services/DebugKeepAliveLease.swift
Normal file
64
StikDebug/Services/DebugKeepAliveLease.swift
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//
|
||||
// DebugKeepAliveLease.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
final class DebugKeepAliveLease {
|
||||
private let stateLock = NSLock()
|
||||
private var isActive = false
|
||||
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||
|
||||
init() {
|
||||
activate()
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
stateLock.lock()
|
||||
guard isActive else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
isActive = false
|
||||
stateLock.unlock()
|
||||
|
||||
runOnMain {
|
||||
BackgroundAudioManager.shared.requestStop()
|
||||
BackgroundLocationManager.shared.requestStop()
|
||||
|
||||
if self.backgroundTaskID != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
|
||||
self.backgroundTaskID = .invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
stateLock.lock()
|
||||
guard !isActive else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
isActive = true
|
||||
stateLock.unlock()
|
||||
|
||||
runOnMain {
|
||||
BackgroundAudioManager.shared.requestStart()
|
||||
BackgroundLocationManager.shared.requestStart()
|
||||
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in
|
||||
LogManager.shared.addWarningLog("Debug session background task expired")
|
||||
self?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runOnMain(_ work: @escaping () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
124
StikDebug/Services/DeveloperDiskImageService.swift
Normal file
124
StikDebug/Services/DeveloperDiskImageService.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//
|
||||
// DeveloperDiskImageService.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class DeveloperDiskImageService {
|
||||
static let shared = DeveloperDiskImageService()
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let session: URLSession
|
||||
|
||||
init(fileManager: FileManager = .default, session: URLSession = .shared) {
|
||||
self.fileManager = fileManager
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func downloadMissingFiles() async throws {
|
||||
for item in Self.downloadItems {
|
||||
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
guard !fileManager.fileExists(atPath: destinationURL.path) else {
|
||||
continue
|
||||
}
|
||||
try await downloadFile(from: item.urlString, to: destinationURL)
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFile(from urlString: String, to destinationURL: URL) async throws {
|
||||
guard let url = URL(string: urlString),
|
||||
url.scheme?.lowercased() == "https" else {
|
||||
throw DDIDownloadError.invalidURL(urlString)
|
||||
}
|
||||
|
||||
let (temporaryURL, response) = try await session.download(from: url)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw DDIDownloadError.invalidResponse
|
||||
}
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw DDIDownloadError.badStatus(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
try fileManager.createDirectory(
|
||||
at: destinationURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
try fileManager.moveItem(at: temporaryURL, to: destinationURL)
|
||||
}
|
||||
|
||||
func redownload(progressHandler: ((Double, String) -> Void)? = nil) async throws {
|
||||
let totalStages = Double(Self.downloadItems.count + 1)
|
||||
var completedStages = 0.0
|
||||
|
||||
progressHandler?(0.0, "Removing existing DDI files...")
|
||||
for item in Self.downloadItems {
|
||||
let fileURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
completedStages += 1.0
|
||||
progressHandler?(completedStages / totalStages, "Starting downloads...")
|
||||
|
||||
for item in Self.downloadItems {
|
||||
progressHandler?(completedStages / totalStages, "Downloading \(item.name)...")
|
||||
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
try await downloadFile(from: item.urlString, to: destinationURL)
|
||||
completedStages += 1.0
|
||||
progressHandler?(completedStages / totalStages, "\(item.name) ready")
|
||||
}
|
||||
|
||||
progressHandler?(1.0, "DDI download complete.")
|
||||
}
|
||||
|
||||
private static let downloadItems: [DDIDownloadItem] = [
|
||||
.init(
|
||||
name: "Build Manifest",
|
||||
relativePath: "DDI/BuildManifest.plist",
|
||||
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist"
|
||||
),
|
||||
.init(
|
||||
name: "Image",
|
||||
relativePath: "DDI/Image.dmg",
|
||||
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg"
|
||||
),
|
||||
.init(
|
||||
name: "TrustCache",
|
||||
relativePath: "DDI/Image.dmg.trustcache",
|
||||
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private struct DDIDownloadItem {
|
||||
let name: String
|
||||
let relativePath: String
|
||||
let urlString: String
|
||||
}
|
||||
|
||||
enum DDIDownloadError: LocalizedError {
|
||||
case invalidURL(String)
|
||||
case invalidResponse
|
||||
case badStatus(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let string):
|
||||
return "Invalid download URL: \(string)"
|
||||
case .invalidResponse:
|
||||
return "The DDI server returned an invalid response."
|
||||
case .badStatus(let statusCode):
|
||||
return "The DDI server returned HTTP \(statusCode)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func redownloadDDI(progressHandler: ((Double, String) -> Void)? = nil) async throws {
|
||||
try await DeveloperDiskImageService.shared.redownload(progressHandler: progressHandler)
|
||||
}
|
||||
215
StikDebug/Services/TunnelManager.swift
Normal file
215
StikDebug/Services/TunnelManager.swift
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
//
|
||||
// TunnelManager.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class TunnelManager: ObservableObject {
|
||||
static let shared = TunnelManager()
|
||||
|
||||
@Published private(set) var isConnected = false
|
||||
|
||||
private var isStarting = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func markDisconnected() {
|
||||
runOnMain {
|
||||
self.isConnected = false
|
||||
}
|
||||
}
|
||||
|
||||
func start(showErrorUI: Bool = true) {
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
self.start(showErrorUI: showErrorUI)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let pairingFileURL = PairingFileStore.prepareURL()
|
||||
guard FileManager.default.fileExists(atPath: pairingFileURL.path) else {
|
||||
isConnected = false
|
||||
return
|
||||
}
|
||||
|
||||
guard !isStarting else {
|
||||
return
|
||||
}
|
||||
|
||||
isStarting = true
|
||||
|
||||
DispatchQueue.global(qos: .userInteractive).async { [showErrorUI] in
|
||||
let result: Result<Void, NSError>
|
||||
do {
|
||||
try JITEnableContext.shared.startTunnel()
|
||||
result = .success(())
|
||||
} catch {
|
||||
result = .failure(error as NSError)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.finishStart(result, showErrorUI: showErrorUI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStart(_ result: Result<Void, NSError>, showErrorUI: Bool) {
|
||||
isStarting = false
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
isConnected = true
|
||||
LogManager.shared.addInfoLog("Tunnel connected successfully")
|
||||
mountDeveloperDiskImageIfNeeded()
|
||||
case .failure(let error):
|
||||
isConnected = false
|
||||
handleStartFailure(error, showErrorUI: showErrorUI)
|
||||
}
|
||||
}
|
||||
|
||||
private func mountDeveloperDiskImageIfNeeded() {
|
||||
let trustcachePath = URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path
|
||||
guard FileManager.default.fileExists(atPath: trustcachePath),
|
||||
!MountingProgress.shared.coolisMounted,
|
||||
MountingProgress.shared.mountingThread == nil else {
|
||||
return
|
||||
}
|
||||
MountingProgress.shared.pubMount()
|
||||
}
|
||||
|
||||
private func handleStartFailure(_ error: NSError, showErrorUI: Bool) {
|
||||
LogManager.shared.addErrorLog(tunnelConnectionLogMessage(for: error))
|
||||
guard showErrorUI else {
|
||||
return
|
||||
}
|
||||
|
||||
if error.code == -9 {
|
||||
handleInvalidPairingFile()
|
||||
return
|
||||
}
|
||||
|
||||
showAlert(
|
||||
title: "Connection Error",
|
||||
message: tunnelConnectionAlertMessage(for: error),
|
||||
showOk: false,
|
||||
showTryAgain: true
|
||||
) { shouldTryAgain in
|
||||
if shouldTryAgain {
|
||||
startTunnelInBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleInvalidPairingFile() {
|
||||
do {
|
||||
try PairingFileStore.remove()
|
||||
LogManager.shared.addInfoLog("Removed invalid pairing file")
|
||||
} catch {
|
||||
LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
showAlert(
|
||||
title: "Invalid Pairing File",
|
||||
message: "The pairing file is invalid or expired. Please select a new pairing file.",
|
||||
showOk: true,
|
||||
showTryAgain: false,
|
||||
primaryButtonText: "Select New File"
|
||||
) { _ in
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ShowPairingFilePicker"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func runOnMain(_ work: @escaping () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startTunnelInBackground(showErrorUI: Bool = true) {
|
||||
TunnelManager.shared.start(showErrorUI: showErrorUI)
|
||||
}
|
||||
|
||||
func markTunnelDisconnected() {
|
||||
TunnelManager.shared.markDisconnected()
|
||||
}
|
||||
|
||||
private func tunnelConnectionLogMessage(for error: NSError) -> String {
|
||||
let target = "\(DeviceConnectionContext.targetIPAddress):49152"
|
||||
return "Tunnel connection failed for \(target): \(error.localizedDescription) (Domain: \(error.domain), Code: \(error.code), Raw: \(String(describing: error)))"
|
||||
}
|
||||
|
||||
private func tunnelConnectionAlertMessage(for error: NSError) -> String {
|
||||
let targetIP = DeviceConnectionContext.targetIPAddress
|
||||
let rawMessage = error.localizedDescription
|
||||
let lowercasedMessage = rawMessage.lowercased()
|
||||
|
||||
let likelyCause: String
|
||||
let recoverySteps: [String]
|
||||
|
||||
if error.code == 48 || lowercasedMessage.contains("address already in use") || lowercasedMessage.contains("port already in use") {
|
||||
likelyCause = "A port needed for the tunnel is already in use."
|
||||
recoverySteps = [
|
||||
"Close other JIT, debugging, proxy, or VPN apps that may be using the tunnel.",
|
||||
"Disconnect and reconnect LocalDevVPN.",
|
||||
"Restart StikDebug, then try again.",
|
||||
"If it keeps happening, reboot the device to clear the stuck port."
|
||||
]
|
||||
} else if error.code == 54 || lowercasedMessage.contains("connection reset") {
|
||||
likelyCause = "The device or VPN closed the tunnel connection before setup finished."
|
||||
recoverySteps = [
|
||||
"Open LocalDevVPN and confirm the VPN is connected.",
|
||||
"Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.",
|
||||
"Reconnect Wi-Fi and LocalDevVPN, then try again.",
|
||||
"If this keeps happening, select a fresh pairing file."
|
||||
]
|
||||
} else if error.code == -18 || lowercasedMessage.contains("parse target ip") {
|
||||
likelyCause = "The configured target IP address is not valid."
|
||||
recoverySteps = [
|
||||
"Open Settings and check the target IP address.",
|
||||
"Use the default \(DeviceConnectionContext.defaultTargetIPAddress)."
|
||||
]
|
||||
} else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") {
|
||||
likelyCause = "The app could not reach the device before the connection timed out."
|
||||
recoverySteps = [
|
||||
"Confirm Wi-Fi and LocalDevVPN are both connected.",
|
||||
"Wake and unlock the target device.",
|
||||
"Confirm LocalDevVPN is exposing the device at \(targetIP)."
|
||||
]
|
||||
} else if lowercasedMessage.contains("network is unreachable") || lowercasedMessage.contains("no route") {
|
||||
likelyCause = "The VPN route to the device is not available."
|
||||
recoverySteps = [
|
||||
"Disconnect and reconnect LocalDevVPN.",
|
||||
"Confirm iOS shows the VPN indicator.",
|
||||
"Try switching Wi-Fi off and on."
|
||||
]
|
||||
} else {
|
||||
likelyCause = "The tunnel could not be created."
|
||||
recoverySteps = [
|
||||
"Confirm Wi-Fi and LocalDevVPN are connected.",
|
||||
"Wake and unlock the target device.",
|
||||
"Reconnect LocalDevVPN, then try again."
|
||||
]
|
||||
}
|
||||
|
||||
let steps = recoverySteps.enumerated()
|
||||
.map { "\($0.offset + 1). \($0.element)" }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return """
|
||||
\(likelyCause)
|
||||
|
||||
Target: \(targetIP):49152
|
||||
Expected LocalDevVPN IP: \(DeviceConnectionContext.defaultTargetIPAddress)
|
||||
|
||||
Try this:
|
||||
\(steps)
|
||||
|
||||
Technical details:
|
||||
Code \(error.code): \(rawMessage)
|
||||
"""
|
||||
}
|
||||
|
|
@ -1,601 +0,0 @@
|
|||
//
|
||||
// StikDebugApp.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Created by Stephen on 3/26/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Network
|
||||
import idevice
|
||||
|
||||
// Register default settings before the app starts
|
||||
private func registerAdvancedOptionsDefault() {
|
||||
let os = ProcessInfo.processInfo.operatingSystemVersion
|
||||
// Enable advanced options by default on iOS 19/26 and above
|
||||
let enabled = os.majorVersion >= 19
|
||||
UserDefaults.standard.register(defaults: ["enableAdvancedOptions": enabled])
|
||||
UserDefaults.standard.register(defaults: [UserDefaults.Keys.txmOverride: false])
|
||||
UserDefaults.standard.register(defaults: ["keepAliveAudio": true])
|
||||
UserDefaults.standard.register(defaults: ["keepAliveLocation": true])
|
||||
}
|
||||
|
||||
// MARK: - DNS Checker
|
||||
|
||||
class DNSChecker: ObservableObject {
|
||||
@Published var appleIP: String?
|
||||
@Published var controlIP: String?
|
||||
@Published var dnsError: String?
|
||||
|
||||
func checkDNS() {
|
||||
checkIfConnectedToWifi { [weak self] wifiConnected in
|
||||
guard let self = self else { return }
|
||||
if wifiConnected {
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
self.lookupIPAddress(for: "gs.apple.com") { ip in
|
||||
DispatchQueue.main.async {
|
||||
self.appleIP = ip
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.enter()
|
||||
self.lookupIPAddress(for: "google.com") { ip in
|
||||
DispatchQueue.main.async {
|
||||
self.controlIP = ip
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
if self.controlIP == nil {
|
||||
self.dnsError = "No internet connection."
|
||||
} else if self.appleIP == nil {
|
||||
self.dnsError = "Apple DNS blocked. Your network might be filtering Apple traffic."
|
||||
} else {
|
||||
self.dnsError = nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.dnsError = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkIfConnectedToWifi(completion: @escaping (Bool) -> Void) {
|
||||
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
|
||||
monitor.pathUpdateHandler = { path in
|
||||
completion(path.status == .satisfied)
|
||||
monitor.cancel()
|
||||
}
|
||||
let queue = DispatchQueue.global(qos: .background)
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
private func lookupIPAddress(for host: String, completion: @escaping (String?) -> Void) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
var hints = addrinfo(
|
||||
ai_flags: 0,
|
||||
ai_family: AF_UNSPEC,
|
||||
ai_socktype: SOCK_STREAM,
|
||||
ai_protocol: 0,
|
||||
ai_addrlen: 0,
|
||||
ai_canonname: nil,
|
||||
ai_addr: nil,
|
||||
ai_next: nil
|
||||
)
|
||||
var res: UnsafeMutablePointer<addrinfo>?
|
||||
let err = getaddrinfo(host, nil, &hints, &res)
|
||||
if err != 0 {
|
||||
DispatchQueue.main.async { completion(nil) }
|
||||
return
|
||||
}
|
||||
|
||||
var ipAddress: String?
|
||||
var ptr = res
|
||||
while ptr != nil {
|
||||
if let addr = ptr?.pointee.ai_addr {
|
||||
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
if getnameinfo(addr, ptr!.pointee.ai_addrlen,
|
||||
&hostBuffer, socklen_t(hostBuffer.count),
|
||||
nil, 0, NI_NUMERICHOST) == 0 {
|
||||
ipAddress = String(cString: hostBuffer)
|
||||
break
|
||||
}
|
||||
}
|
||||
ptr = ptr?.pointee.ai_next
|
||||
}
|
||||
freeaddrinfo(res)
|
||||
DispatchQueue.main.async { completion(ipAddress) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main App
|
||||
|
||||
// Global state variable for the tunnel connection.
|
||||
var pubTunnelConnected = false
|
||||
private var tunnelStartPending = false
|
||||
private var tunnelStartInProgress = false
|
||||
private var tunnelPendingShowUI = true
|
||||
|
||||
@main
|
||||
struct HeartbeatApp: App {
|
||||
@StateObject private var mount = MountingProgress.shared
|
||||
@Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle
|
||||
@State private var shouldAttemptTunnelReconnect = false
|
||||
|
||||
init() {
|
||||
registerAdvancedOptionsDefault()
|
||||
if UserDefaults.standard.bool(forKey: "keepAliveAudio") {
|
||||
BackgroundAudioManager.shared.start()
|
||||
}
|
||||
let fixSelector = NSSelectorFromString("fix_initForOpeningContentTypes:asCopy:")
|
||||
if let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, fixSelector),
|
||||
let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:))) {
|
||||
method_exchangeImplementations(origMethod, fixMethod)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||
switch newPhase {
|
||||
case .background:
|
||||
shouldAttemptTunnelReconnect = true
|
||||
case .active:
|
||||
if shouldAttemptTunnelReconnect {
|
||||
shouldAttemptTunnelReconnect = false
|
||||
startTunnelInBackground(showErrorUI: false)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainTabView()
|
||||
.onAppear {
|
||||
Task {
|
||||
let fileManager = FileManager.default
|
||||
for item in ddiDownloadItems {
|
||||
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
if fileManager.fileExists(atPath: destinationURL.path) { continue }
|
||||
do {
|
||||
try await downloadFile(from: item.urlString, to: destinationURL)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
showAlert(title: "An Error has Occurred",
|
||||
message: "[Download DDI Error]: \(error.localizedDescription)",
|
||||
showOk: true)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
handleScenePhaseChange(newPhase)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Additional Helpers
|
||||
|
||||
actor FunctionGuard<T> {
|
||||
private var runningTask: Task<T, Never>?
|
||||
|
||||
func execute(_ work: @escaping @Sendable () -> T) async -> T {
|
||||
if let task = runningTask {
|
||||
return await task.value
|
||||
}
|
||||
let task = Task.detached { work() }
|
||||
runningTask = task
|
||||
let result = await task.value
|
||||
runningTask = nil
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
class MountingProgress: ObservableObject {
|
||||
static var shared = MountingProgress()
|
||||
@Published var mountProgress: Double = 0.0
|
||||
@Published var mountingThread: Thread?
|
||||
@Published var coolisMounted: Bool = false
|
||||
|
||||
func checkforMounted() {
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let mounted = isMounted()
|
||||
DispatchQueue.main.async {
|
||||
self.coolisMounted = mounted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) {
|
||||
let percentage = Double(progress) / Double(total) * 100.0
|
||||
DispatchQueue.main.async {
|
||||
self.mountProgress = percentage
|
||||
}
|
||||
}
|
||||
|
||||
func pubMount() {
|
||||
mount()
|
||||
}
|
||||
|
||||
private func mount() {
|
||||
let currentlyMounted = isMounted()
|
||||
DispatchQueue.main.async {
|
||||
self.coolisMounted = currentlyMounted
|
||||
}
|
||||
|
||||
if isPairing(), !currentlyMounted {
|
||||
if let mountingThread = mountingThread {
|
||||
mountingThread.cancel()
|
||||
self.mountingThread = nil
|
||||
}
|
||||
|
||||
let thread = Thread { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let mountError = mountPersonalDDI(
|
||||
imagePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg").path,
|
||||
trustcachePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path,
|
||||
manifestPath: URL.documentsDirectory.appendingPathComponent("DDI/BuildManifest.plist").path,
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let mountError {
|
||||
showAlert(title: "DDI Mount Failed", message: mountError, showOk: true, showTryAgain: true) { shouldTryAgain in
|
||||
if shouldTryAgain { self.mount() }
|
||||
}
|
||||
} else {
|
||||
self.coolisMounted = true
|
||||
self.checkforMounted()
|
||||
}
|
||||
self.mountingThread = nil
|
||||
}
|
||||
}
|
||||
thread.qualityOfService = .background
|
||||
thread.name = "mounting"
|
||||
thread.start()
|
||||
mountingThread = thread
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isPairing() -> Bool {
|
||||
let pairingpath = PairingFileStore.prepareURL().path
|
||||
var pairingFile: RpPairingFileHandle?
|
||||
let err = rp_pairing_file_read(pairingpath, &pairingFile)
|
||||
if err != nil { return false }
|
||||
rp_pairing_file_free(pairingFile)
|
||||
return true
|
||||
}
|
||||
|
||||
func startTunnelInBackground(showErrorUI: Bool = true) {
|
||||
assert(Thread.isMainThread, "startTunnelInBackground must be called on the main thread")
|
||||
let pairingFileURL = PairingFileStore.prepareURL()
|
||||
|
||||
guard FileManager.default.fileExists(atPath: pairingFileURL.path) else {
|
||||
tunnelStartPending = false
|
||||
tunnelPendingShowUI = true
|
||||
return
|
||||
}
|
||||
|
||||
guard !tunnelStartInProgress else {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelStartPending = false
|
||||
tunnelPendingShowUI = true
|
||||
tunnelStartInProgress = true
|
||||
|
||||
DispatchQueue.global(qos: .userInteractive).async {
|
||||
defer {
|
||||
DispatchQueue.main.async {
|
||||
tunnelStartInProgress = false
|
||||
}
|
||||
}
|
||||
do {
|
||||
try JITEnableContext.shared.startTunnel()
|
||||
LogManager.shared.addInfoLog("Tunnel connected successfully")
|
||||
pubTunnelConnected = true
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let trustcachePath = URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path
|
||||
guard FileManager.default.fileExists(atPath: trustcachePath),
|
||||
!MountingProgress.shared.coolisMounted,
|
||||
MountingProgress.shared.mountingThread == nil else { return }
|
||||
MountingProgress.shared.pubMount()
|
||||
}
|
||||
} catch {
|
||||
let err2 = error as NSError
|
||||
let code = err2.code
|
||||
LogManager.shared.addErrorLog(tunnelConnectionLogMessage(for: err2))
|
||||
guard showErrorUI else { return }
|
||||
DispatchQueue.main.async {
|
||||
if code == -9 {
|
||||
do {
|
||||
try PairingFileStore.remove()
|
||||
LogManager.shared.addInfoLog("Removed invalid pairing file")
|
||||
} catch {
|
||||
LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
showAlert(
|
||||
title: "Invalid Pairing File",
|
||||
message: "The pairing file is invalid or expired. Please select a new pairing file.",
|
||||
showOk: true,
|
||||
showTryAgain: false,
|
||||
primaryButtonText: "Select New File"
|
||||
) { _ in
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ShowPairingFilePicker"), object: nil)
|
||||
}
|
||||
} else {
|
||||
showAlert(
|
||||
title: "Connection Error",
|
||||
message: tunnelConnectionAlertMessage(for: err2),
|
||||
showOk: false,
|
||||
showTryAgain: true
|
||||
) { shouldTryAgain in
|
||||
if shouldTryAgain {
|
||||
DispatchQueue.main.async {
|
||||
startTunnelInBackground()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private func tunnelConnectionLogMessage(for error: NSError) -> String {
|
||||
let target = "\(DeviceConnectionContext.targetIPAddress):49152"
|
||||
return "Tunnel connection failed for \(target): \(error.localizedDescription) (Domain: \(error.domain), Code: \(error.code), Raw: \(String(describing: error)))"
|
||||
}
|
||||
|
||||
private func tunnelConnectionAlertMessage(for error: NSError) -> String {
|
||||
let targetIP = DeviceConnectionContext.targetIPAddress
|
||||
let rawMessage = error.localizedDescription
|
||||
let lowercasedMessage = rawMessage.lowercased()
|
||||
|
||||
let likelyCause: String
|
||||
let recoverySteps: [String]
|
||||
|
||||
if error.code == 48 || lowercasedMessage.contains("address already in use") || lowercasedMessage.contains("port already in use") {
|
||||
likelyCause = "A port needed for the tunnel is already in use."
|
||||
recoverySteps = [
|
||||
"Close other JIT, debugging, proxy, or VPN apps that may be using the tunnel.",
|
||||
"Disconnect and reconnect LocalDevVPN.",
|
||||
"Restart StikDebug, then try again.",
|
||||
"If it keeps happening, reboot the device to clear the stuck port."
|
||||
]
|
||||
} else if error.code == 54 || lowercasedMessage.contains("connection reset") {
|
||||
likelyCause = "The device or VPN closed the tunnel connection before setup finished."
|
||||
recoverySteps = [
|
||||
"Open LocalDevVPN and confirm the VPN is connected.",
|
||||
"Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.",
|
||||
"Reconnect Wi-Fi and LocalDevVPN, then try again.",
|
||||
"If this keeps happening, select a fresh pairing file."
|
||||
]
|
||||
} else if error.code == -18 || lowercasedMessage.contains("parse target ip") {
|
||||
likelyCause = "The configured target IP address is not valid."
|
||||
recoverySteps = [
|
||||
"Open Settings and check the target IP address.",
|
||||
"Use the default \(DeviceConnectionContext.defaultTargetIPAddress)."
|
||||
]
|
||||
} else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") {
|
||||
likelyCause = "The app could not reach the device before the connection timed out."
|
||||
recoverySteps = [
|
||||
"Confirm Wi-Fi and LocalDevVPN are both connected.",
|
||||
"Wake and unlock the target device.",
|
||||
"Confirm LocalDevVPN is exposing the device at \(targetIP)."
|
||||
]
|
||||
} else if lowercasedMessage.contains("network is unreachable") || lowercasedMessage.contains("no route") {
|
||||
likelyCause = "The VPN route to the device is not available."
|
||||
recoverySteps = [
|
||||
"Disconnect and reconnect LocalDevVPN.",
|
||||
"Confirm iOS shows the VPN indicator.",
|
||||
"Try switching Wi-Fi off and on."
|
||||
]
|
||||
} else {
|
||||
likelyCause = "The tunnel could not be created."
|
||||
recoverySteps = [
|
||||
"Confirm Wi-Fi and LocalDevVPN are connected.",
|
||||
"Wake and unlock the target device.",
|
||||
"Reconnect LocalDevVPN, then try again."
|
||||
]
|
||||
}
|
||||
|
||||
let steps = recoverySteps.enumerated()
|
||||
.map { "\($0.offset + 1). \($0.element)" }
|
||||
.joined(separator: "\n")
|
||||
|
||||
return """
|
||||
\(likelyCause)
|
||||
|
||||
Target: \(targetIP):49152
|
||||
Expected LocalDevVPN IP: \(DeviceConnectionContext.defaultTargetIPAddress)
|
||||
|
||||
Try this:
|
||||
\(steps)
|
||||
|
||||
Technical details:
|
||||
Code \(error.code): \(rawMessage)
|
||||
"""
|
||||
}
|
||||
|
||||
func checkDeviceConnection(callback: @escaping (Bool, String?) -> Void) {
|
||||
let targetIP = DeviceConnectionContext.targetIPAddress
|
||||
let host = NWEndpoint.Host(targetIP)
|
||||
let port = NWEndpoint.Port(rawValue: 62078)!
|
||||
let connection = NWConnection(host: host, port: port, using: .tcp)
|
||||
var timeoutWorkItem: DispatchWorkItem?
|
||||
|
||||
timeoutWorkItem = DispatchWorkItem { [weak connection] in
|
||||
if connection?.state != .ready {
|
||||
connection?.cancel()
|
||||
DispatchQueue.main.async {
|
||||
if timeoutWorkItem?.isCancelled == false {
|
||||
let message = "[TIMEOUT] Could not reach the device at \(targetIP). Make sure it’s online and on the same network."
|
||||
callback(false, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { [weak connection] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
timeoutWorkItem?.cancel()
|
||||
connection?.cancel()
|
||||
DispatchQueue.main.async {
|
||||
callback(true, nil)
|
||||
}
|
||||
case .failed(let error):
|
||||
timeoutWorkItem?.cancel()
|
||||
connection?.cancel()
|
||||
DispatchQueue.main.async {
|
||||
let message = "Could not reach the device at \(targetIP): \(error.localizedDescription)"
|
||||
callback(false, message)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: .global())
|
||||
if let workItem = timeoutWorkItem {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 20, execute: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
public func showAlert(title: String, message: String, showOk: Bool, showTryAgain: Bool = false, primaryButtonText: String? = nil, completion: ((Bool) -> Void)? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootViewController = scene.windows.first?.rootViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
|
||||
if showTryAgain {
|
||||
alert.addAction(UIAlertAction(title: primaryButtonText ?? "Try Again", style: .default) { _ in
|
||||
completion?(true)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
|
||||
completion?(false)
|
||||
})
|
||||
} else if showOk {
|
||||
alert.addAction(UIAlertAction(title: primaryButtonText ?? "OK", style: .default) { _ in
|
||||
completion?(true)
|
||||
})
|
||||
} else {
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
|
||||
completion?(true)
|
||||
})
|
||||
}
|
||||
|
||||
var topController = rootViewController
|
||||
while let presented = topController.presentedViewController {
|
||||
topController = presented
|
||||
}
|
||||
topController.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DDIDownloadItem {
|
||||
let name: String
|
||||
let relativePath: String
|
||||
let urlString: String
|
||||
}
|
||||
|
||||
private let ddiDownloadItems: [DDIDownloadItem] = [
|
||||
.init(
|
||||
name: "Build Manifest",
|
||||
relativePath: "DDI/BuildManifest.plist",
|
||||
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist"
|
||||
),
|
||||
.init(
|
||||
name: "Image",
|
||||
relativePath: "DDI/Image.dmg",
|
||||
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg"
|
||||
),
|
||||
.init(
|
||||
name: "TrustCache",
|
||||
relativePath: "DDI/Image.dmg.trustcache",
|
||||
urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache"
|
||||
)
|
||||
]
|
||||
|
||||
enum DDIDownloadError: LocalizedError {
|
||||
case invalidURL(String)
|
||||
case invalidResponse
|
||||
case badStatus(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL(let string):
|
||||
return "Invalid download URL: \(string)"
|
||||
case .invalidResponse:
|
||||
return "The DDI server returned an invalid response."
|
||||
case .badStatus(let statusCode):
|
||||
return "The DDI server returned HTTP \(statusCode)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadFile(from urlString: String, to destinationURL: URL) async throws {
|
||||
guard let url = URL(string: urlString),
|
||||
url.scheme?.lowercased() == "https" else {
|
||||
throw DDIDownloadError.invalidURL(urlString)
|
||||
}
|
||||
let (tempLocalUrl, response) = try await URLSession.shared.download(from: url)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw DDIDownloadError.invalidResponse
|
||||
}
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw DDIDownloadError.badStatus(httpResponse.statusCode)
|
||||
}
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
try fileManager.moveItem(at: tempLocalUrl, to: destinationURL)
|
||||
}
|
||||
|
||||
func redownloadDDI(progressHandler: ((Double, String) -> Void)? = nil) async throws {
|
||||
let fileManager = FileManager.default
|
||||
let totalStages = Double(ddiDownloadItems.count + 1)
|
||||
var completedStages = 0.0
|
||||
|
||||
progressHandler?(0.0, "Removing existing DDI files…")
|
||||
for item in ddiDownloadItems {
|
||||
let fileURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
if fileManager.fileExists(atPath: fileURL.path) {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
completedStages += 1.0
|
||||
progressHandler?(completedStages / totalStages, "Starting downloads…")
|
||||
|
||||
for item in ddiDownloadItems {
|
||||
progressHandler?(completedStages / totalStages, "Downloading \(item.name)…")
|
||||
let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath)
|
||||
try await downloadFile(from: item.urlString, to: destinationURL)
|
||||
completedStages += 1.0
|
||||
progressHandler?(completedStages / totalStages, "\(item.name) ready")
|
||||
}
|
||||
progressHandler?(1.0, "DDI download complete.")
|
||||
}
|
||||
14
StikDebug/Support/AccessibilityAnnouncer.swift
Normal file
14
StikDebug/Support/AccessibilityAnnouncer.swift
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// AccessibilityAnnouncer.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum AccessibilityAnnouncer {
|
||||
static func announce(_ message: String) {
|
||||
DispatchQueue.main.async {
|
||||
UIAccessibility.post(notification: .announcement, argument: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
StikDebug/Support/AlertPresenter.swift
Normal file
91
StikDebug/Support/AlertPresenter.swift
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//
|
||||
// AlertPresenter.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum AlertPresenter {
|
||||
static func show(
|
||||
title: String,
|
||||
message: String,
|
||||
showOk: Bool,
|
||||
showTryAgain: Bool = false,
|
||||
primaryButtonText: String? = nil,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
guard let rootViewController = UIApplication.shared.activeRootViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
||||
|
||||
if showTryAgain {
|
||||
alert.addAction(UIAlertAction(title: primaryButtonText ?? "Try Again", style: .default) { _ in
|
||||
completion?(true)
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
|
||||
completion?(false)
|
||||
})
|
||||
} else if showOk {
|
||||
alert.addAction(UIAlertAction(title: primaryButtonText ?? "OK", style: .default) { _ in
|
||||
completion?(true)
|
||||
})
|
||||
} else {
|
||||
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in
|
||||
completion?(true)
|
||||
})
|
||||
}
|
||||
|
||||
UIApplication.shared.topViewController(from: rootViewController)?.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
static func dismissPresentedAlert() {
|
||||
DispatchQueue.main.async {
|
||||
guard let rootViewController = UIApplication.shared.activeRootViewController,
|
||||
let topController = UIApplication.shared.topViewController(from: rootViewController),
|
||||
topController is UIAlertController else {
|
||||
return
|
||||
}
|
||||
topController.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func showAlert(
|
||||
title: String,
|
||||
message: String,
|
||||
showOk: Bool,
|
||||
showTryAgain: Bool = false,
|
||||
primaryButtonText: String? = nil,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) {
|
||||
AlertPresenter.show(
|
||||
title: title,
|
||||
message: message,
|
||||
showOk: showOk,
|
||||
showTryAgain: showTryAgain,
|
||||
primaryButtonText: primaryButtonText,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
private extension UIApplication {
|
||||
var activeRootViewController: UIViewController? {
|
||||
connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.first(where: \.isKeyWindow)?
|
||||
.rootViewController
|
||||
}
|
||||
|
||||
func topViewController(from rootViewController: UIViewController) -> UIViewController? {
|
||||
var topController: UIViewController? = rootViewController
|
||||
while let presented = topController?.presentedViewController {
|
||||
topController = presented
|
||||
}
|
||||
return topController
|
||||
}
|
||||
}
|
||||
42
StikDebug/Support/AppStorageCodable.swift
Normal file
42
StikDebug/Support/AppStorageCodable.swift
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// AppStorageCodable.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Array: @retroactive RawRepresentable where Element: Codable {
|
||||
public init?(rawValue: String) {
|
||||
guard let data = rawValue.data(using: .utf8),
|
||||
let result = try? JSONDecoder().decode([Element].self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
self = result
|
||||
}
|
||||
|
||||
public var rawValue: String {
|
||||
guard let data = try? JSONEncoder().encode(self),
|
||||
let result = String(data: data, encoding: .utf8) else {
|
||||
return "[]"
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Dictionary: @retroactive RawRepresentable where Key: Codable, Value: Codable {
|
||||
public init?(rawValue: String) {
|
||||
guard let data = rawValue.data(using: .utf8),
|
||||
let result = try? JSONDecoder().decode([Key: Value].self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
self = result
|
||||
}
|
||||
|
||||
public var rawValue: String {
|
||||
guard let data = try? JSONEncoder().encode(self),
|
||||
let result = String(data: data, encoding: .utf8) else {
|
||||
return "{}"
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
16
StikDebug/Support/FileManager+Lookup.swift
Normal file
16
StikDebug/Support/FileManager+Lookup.swift
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// FileManager+Lookup.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension FileManager {
|
||||
func filePath(atPath path: String, withLength length: Int) -> String? {
|
||||
guard let file = try? contentsOfDirectory(atPath: path).first(where: { $0.count == length }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return "\(path)/\(file)"
|
||||
}
|
||||
}
|
||||
20
StikDebug/Support/Haptics.swift
Normal file
20
StikDebug/Support/Haptics.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// Haptics.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
enum Haptics {
|
||||
static func light() {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
static func medium() {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
|
||||
static func selection() {
|
||||
UISelectionFeedbackGenerator().selectionChanged()
|
||||
}
|
||||
}
|
||||
11
StikDebug/Support/NotificationName+StikDebug.swift
Normal file
11
StikDebug/Support/NotificationName+StikDebug.swift
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//
|
||||
// NotificationName+StikDebug.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let pairingFileImported = Notification.Name("PairingFileImported")
|
||||
static let intentJSScriptReady = Notification.Name("intentJSScriptReady")
|
||||
}
|
||||
109
StikDebug/Support/PairingFileStore.swift
Normal file
109
StikDebug/Support/PairingFileStore.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// PairingFileStore.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum PairingFileStore {
|
||||
static let fileName = "rp_pairing_file.plist"
|
||||
static let supportedContentTypes: [UTType] = [
|
||||
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
|
||||
UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!,
|
||||
.propertyList
|
||||
]
|
||||
|
||||
private static let legacyFileName = "pairingFile.plist"
|
||||
|
||||
static var url: URL {
|
||||
directoryURL.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func prepareURL(fileManager: FileManager = .default) -> URL {
|
||||
let destination = url
|
||||
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
|
||||
guard !fileManager.fileExists(atPath: destination.path) else {
|
||||
removeLegacyCopies(fileManager: fileManager)
|
||||
return destination
|
||||
}
|
||||
|
||||
migrateLegacyCopy(to: destination, fileManager: fileManager)
|
||||
return destination
|
||||
}
|
||||
|
||||
static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws {
|
||||
let destination = prepareURL(fileManager: fileManager)
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
|
||||
removeLegacyCopies(fileManager: fileManager)
|
||||
try fileManager.copyItem(at: sourceURL, to: destination)
|
||||
protectPairingFile(at: destination, fileManager: fileManager)
|
||||
}
|
||||
|
||||
static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws {
|
||||
let accessing = sourceURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
sourceURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
guard fileManager.fileExists(atPath: sourceURL.path) else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
|
||||
try replace(with: sourceURL, fileManager: fileManager)
|
||||
}
|
||||
|
||||
static func remove(fileManager: FileManager = .default) throws {
|
||||
let destination = prepareURL(fileManager: fileManager)
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
removeLegacyCopies(fileManager: fileManager)
|
||||
}
|
||||
|
||||
private static var directoryURL: URL {
|
||||
FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Pairing", isDirectory: true)
|
||||
}
|
||||
|
||||
private static var legacyURLs: [URL] {
|
||||
[
|
||||
URL.documentsDirectory.appendingPathComponent(fileName),
|
||||
URL.documentsDirectory.appendingPathComponent(legacyFileName)
|
||||
]
|
||||
}
|
||||
|
||||
private static func migrateLegacyCopy(to destination: URL, fileManager: FileManager) {
|
||||
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
||||
do {
|
||||
try fileManager.moveItem(at: legacyURL, to: destination)
|
||||
} catch {
|
||||
if let data = try? Data(contentsOf: legacyURL) {
|
||||
try? data.write(to: destination, options: .atomic)
|
||||
try? fileManager.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
protectPairingFile(at: destination, fileManager: fileManager)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func removeLegacyCopies(fileManager: FileManager) {
|
||||
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
||||
try? fileManager.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func protectPairingFile(at url: URL, fileManager: FileManager) {
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
44
StikDebug/Support/ProcessInfo+TXM.swift
Normal file
44
StikDebug/Support/ProcessInfo+TXM.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// ProcessInfo+TXM.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension ProcessInfo {
|
||||
var hasTXM: Bool {
|
||||
if isTXMOverridden {
|
||||
return true
|
||||
}
|
||||
|
||||
return ProcessInfo.hasTXMSupport(
|
||||
operatingSystemVersion: operatingSystemVersion,
|
||||
localTXMDetector: ProcessInfo.detectLocalTXM
|
||||
)
|
||||
}
|
||||
|
||||
var isTXMOverridden: Bool {
|
||||
UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
|
||||
}
|
||||
|
||||
static func hasTXMSupport(
|
||||
operatingSystemVersion: OperatingSystemVersion,
|
||||
localTXMDetector: () -> Bool
|
||||
) -> Bool {
|
||||
guard operatingSystemVersion.majorVersion >= 26 else {
|
||||
return false
|
||||
}
|
||||
return localTXMDetector()
|
||||
}
|
||||
|
||||
private static func detectLocalTXM() -> Bool {
|
||||
if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36),
|
||||
let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) {
|
||||
return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
|
||||
}
|
||||
|
||||
return FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map {
|
||||
access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
|
||||
} ?? false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +1,9 @@
|
|||
//
|
||||
// Extensions.swift
|
||||
// ScriptStore.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Created by s s on 2025/7/9.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
enum AccessibilityAnnouncer {
|
||||
static func announce(_ message: String) {
|
||||
DispatchQueue.main.async {
|
||||
UIAccessibility.post(notification: .announcement, argument: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum PairingFileStore {
|
||||
static let fileName = "rp_pairing_file.plist"
|
||||
private static let legacyFileName = "pairingFile.plist"
|
||||
static let supportedContentTypes: [UTType] = [
|
||||
UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
|
||||
UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!,
|
||||
.propertyList
|
||||
]
|
||||
|
||||
static var url: URL {
|
||||
directoryURL.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static var directoryURL: URL {
|
||||
FileManager.default
|
||||
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Pairing", isDirectory: true)
|
||||
}
|
||||
|
||||
private static var legacyURLs: [URL] {
|
||||
[
|
||||
URL.documentsDirectory.appendingPathComponent(fileName),
|
||||
URL.documentsDirectory.appendingPathComponent(legacyFileName)
|
||||
]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func prepareURL(fileManager: FileManager = .default) -> URL {
|
||||
let destination = url
|
||||
try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
|
||||
|
||||
guard !fileManager.fileExists(atPath: destination.path) else {
|
||||
removeLegacyCopies(fileManager: fileManager)
|
||||
return destination
|
||||
}
|
||||
|
||||
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
||||
do {
|
||||
try fileManager.moveItem(at: legacyURL, to: destination)
|
||||
} catch {
|
||||
if let data = try? Data(contentsOf: legacyURL) {
|
||||
try? data.write(to: destination, options: .atomic)
|
||||
try? fileManager.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path)
|
||||
break
|
||||
}
|
||||
|
||||
return destination
|
||||
}
|
||||
|
||||
static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws {
|
||||
let destination = prepareURL(fileManager: fileManager)
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
removeLegacyCopies(fileManager: fileManager)
|
||||
try fileManager.copyItem(at: sourceURL, to: destination)
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path)
|
||||
}
|
||||
|
||||
static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws {
|
||||
let accessing = sourceURL.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
sourceURL.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
guard fileManager.fileExists(atPath: sourceURL.path) else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
|
||||
try replace(with: sourceURL, fileManager: fileManager)
|
||||
}
|
||||
|
||||
static func remove(fileManager: FileManager = .default) throws {
|
||||
let destination = prepareURL(fileManager: fileManager)
|
||||
if fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.removeItem(at: destination)
|
||||
}
|
||||
removeLegacyCopies(fileManager: fileManager)
|
||||
}
|
||||
|
||||
private static func removeLegacyCopies(fileManager: FileManager) {
|
||||
for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) {
|
||||
try? fileManager.removeItem(at: legacyURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ScriptResource {
|
||||
let resourceName: String
|
||||
|
|
@ -137,12 +34,15 @@ enum ScriptStore {
|
|||
let directory = directoryURL
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory)
|
||||
|
||||
if exists && !isDirectory.boolValue {
|
||||
try fileManager.removeItem(at: directory)
|
||||
}
|
||||
|
||||
if !exists || !isDirectory.boolValue {
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
try ensureBundledScripts(in: directory, fileManager: fileManager)
|
||||
return directory
|
||||
}
|
||||
|
|
@ -151,6 +51,7 @@ enum ScriptStore {
|
|||
guard let scriptName = normalizedScriptFileName(scriptName) else {
|
||||
throw CocoaError(.fileReadInvalidFileName)
|
||||
}
|
||||
|
||||
let directory = try prepareDirectory(fileManager: fileManager)
|
||||
return directory.appendingPathComponent(scriptName)
|
||||
}
|
||||
|
|
@ -173,16 +74,40 @@ enum ScriptStore {
|
|||
assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager)
|
||||
}
|
||||
|
||||
static func favoriteAppName(for bundleID: String, defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName)) -> String? {
|
||||
static func favoriteAppName(
|
||||
for bundleID: String,
|
||||
defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName)
|
||||
) -> String? {
|
||||
let names = defaults?.dictionary(forKey: favoriteAppNamesKey) as? [String: String]
|
||||
return names?[bundleID]
|
||||
}
|
||||
|
||||
static func normalizedScriptFileName(_ candidate: String) -> String? {
|
||||
let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
!trimmed.contains("/"),
|
||||
!trimmed.contains("\\"),
|
||||
!trimmed.contains("\0") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileName = URL(fileURLWithPath: trimmed).lastPathComponent
|
||||
guard fileName == trimmed,
|
||||
fileName != ".",
|
||||
fileName != "..",
|
||||
URL(fileURLWithPath: fileName).pathExtension.lowercased() == "js" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
|
||||
private static func ensureBundledScripts(in directory: URL, fileManager: FileManager) throws {
|
||||
for resource in bundledResources {
|
||||
guard let bundleURL = Bundle.main.url(forResource: resource.resourceName, withExtension: "js") else {
|
||||
continue
|
||||
}
|
||||
|
||||
let destination = directory.appendingPathComponent(resource.fileName)
|
||||
if !fileManager.fileExists(atPath: destination.path) {
|
||||
try fileManager.copyItem(at: bundleURL, to: destination)
|
||||
|
|
@ -191,13 +116,12 @@ enum ScriptStore {
|
|||
}
|
||||
|
||||
private static func assignedScript(for bundleID: String, fileManager: FileManager) -> (data: Data, name: String)? {
|
||||
guard let scriptName = assignedScriptName(for: bundleID) else {
|
||||
return nil
|
||||
}
|
||||
guard let scriptURL = try? scriptURL(named: scriptName, fileManager: fileManager),
|
||||
guard let scriptName = assignedScriptName(for: bundleID),
|
||||
let scriptURL = try? scriptURL(named: scriptName, fileManager: fileManager),
|
||||
let data = try? Data(contentsOf: scriptURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (data, scriptName)
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +148,7 @@ enum ScriptStore {
|
|||
let data = try? Data(contentsOf: bundleURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (data, resource.fileName)
|
||||
}
|
||||
|
||||
|
|
@ -231,52 +156,4 @@ enum ScriptStore {
|
|||
let rawMap = defaults.dictionary(forKey: assignmentKey) as? [String: String] ?? [:]
|
||||
return rawMap.compactMapValues(normalizedScriptFileName)
|
||||
}
|
||||
|
||||
static func normalizedScriptFileName(_ candidate: String) -> String? {
|
||||
let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
!trimmed.contains("/"),
|
||||
!trimmed.contains("\\"),
|
||||
!trimmed.contains("\0") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileName = URL(fileURLWithPath: trimmed).lastPathComponent
|
||||
guard fileName == trimmed,
|
||||
fileName != ".",
|
||||
fileName != "..",
|
||||
URL(fileURLWithPath: fileName).pathExtension.lowercased() == "js" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fileName
|
||||
}
|
||||
}
|
||||
|
||||
extension FileManager {
|
||||
func filePath(atPath path: String, withLength length: Int) -> String? {
|
||||
guard let file = try? contentsOfDirectory(atPath: path).first(where: { $0.count == length }) else { return nil }
|
||||
return "\(path)/\(file)"
|
||||
}
|
||||
}
|
||||
|
||||
extension UIDocumentPickerViewController {
|
||||
@objc func fix_init(forOpeningContentTypes contentTypes: [UTType], asCopy: Bool) -> UIDocumentPickerViewController {
|
||||
return fix_init(forOpeningContentTypes: contentTypes, asCopy: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let pairingFileImported = Notification.Name("PairingFileImported")
|
||||
static let intentJSScriptReady = Notification.Name("intentJSScriptReady")
|
||||
}
|
||||
|
||||
extension UserDefaults {
|
||||
enum Keys {
|
||||
/// Forces the app to treat the current device as TXM-capable so scripts always run.
|
||||
static let txmOverride = "overrideTXMForScripts"
|
||||
static let bundleScriptMap = "BundleScriptMap"
|
||||
static let defaultScriptName = "DefaultScriptName"
|
||||
static let defaultScriptNameValue = "attachDetach.js"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// UIDocumentPickerViewController+CopyWorkaround.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension UIDocumentPickerViewController {
|
||||
@objc func fix_init(
|
||||
forOpeningContentTypes contentTypes: [UTType],
|
||||
asCopy: Bool
|
||||
) -> UIDocumentPickerViewController {
|
||||
fix_init(forOpeningContentTypes: contentTypes, asCopy: true)
|
||||
}
|
||||
}
|
||||
16
StikDebug/Support/UserDefaults+Keys.swift
Normal file
16
StikDebug/Support/UserDefaults+Keys.swift
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// UserDefaults+Keys.swift
|
||||
// StikDebug
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension UserDefaults {
|
||||
enum Keys {
|
||||
/// Forces the app to treat the current device as TXM-capable so scripts always run.
|
||||
static let txmOverride = "overrideTXMForScripts"
|
||||
static let bundleScriptMap = "BundleScriptMap"
|
||||
static let defaultScriptName = "DefaultScriptName"
|
||||
static let defaultScriptNameValue = "attachDetach.js"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
//
|
||||
// HapticFeedbackHelper.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Created by Stephen on 3/27/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HapticFeedbackHelper {
|
||||
static func trigger() {
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
//
|
||||
// LogManagerBridge.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Created by neoarz on 3/29/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
//objc bridge for logs
|
||||
@objc class LogManagerBridge: NSObject {
|
||||
@objc static let shared = LogManagerBridge()
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
@objc func addInfoLog(_ message: String) {
|
||||
LogManager.shared.addInfoLog(message)
|
||||
}
|
||||
|
||||
@objc func addErrorLog(_ message: String) {
|
||||
LogManager.shared.addErrorLog(message)
|
||||
}
|
||||
|
||||
@objc func addDebugLog(_ message: String) {
|
||||
LogManager.shared.addDebugLog(message)
|
||||
}
|
||||
|
||||
@objc func addWarningLog(_ message: String) {
|
||||
LogManager.shared.addWarningLog(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
//
|
||||
// Security.swift
|
||||
// StikDebug
|
||||
// from MeloNX
|
||||
// Created by s s on 2025/4/6.
|
||||
//
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
typealias SecTaskRef = OpaquePointer
|
||||
|
||||
@_silgen_name("SecTaskCopyValueForEntitlement")
|
||||
func SecTaskCopyValueForEntitlement(
|
||||
_ task: SecTaskRef,
|
||||
_ entitlement: CFString,
|
||||
_ error: UnsafeMutablePointer<Unmanaged<CFError>?>?
|
||||
) -> CFTypeRef?
|
||||
|
||||
@_silgen_name("SecTaskCreateFromSelf")
|
||||
func SecTaskCreateFromSelf(
|
||||
_ allocator: CFAllocator?
|
||||
) -> SecTaskRef?
|
||||
|
||||
func checkAppEntitlement(_ ent: String) -> Bool {
|
||||
guard let task = SecTaskCreateFromSelf(nil) else { return false }
|
||||
guard let value = SecTaskCopyValueForEntitlement(task, ent as CFString, nil) else { return false }
|
||||
return value.boolValue != nil && value.boolValue
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ struct ConsoleLogsView: View {
|
|||
return systemLogStream.entries
|
||||
}
|
||||
let query = syslogSearchText.lowercased()
|
||||
return systemLogStream.entries.filter { $0.raw.lowercased().contains(query) }
|
||||
return systemLogStream.entries.filter { $0.searchableRaw.contains(query) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -165,7 +165,7 @@ struct ConsoleLogsView: View {
|
|||
}
|
||||
.coordinateSpace(name: "jitScroll")
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
||||
jitIsAtBottom = offset > -20
|
||||
updateJITBottomState(offset > -20)
|
||||
}
|
||||
.onChange(of: logManager.logs.count) { _, _ in
|
||||
guard jitIsAtBottom, let lastLog = logManager.logs.last else { return }
|
||||
|
|
@ -229,7 +229,7 @@ struct ConsoleLogsView: View {
|
|||
}
|
||||
.coordinateSpace(name: "syslogScroll")
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
|
||||
syslogIsAtBottom = offset > -20
|
||||
updateSyslogBottomState(offset > -20)
|
||||
}
|
||||
.onChange(of: systemLogStream.entries.count) { _, _ in
|
||||
guard syslogIsAtBottom, syslogSearchText.isEmpty,
|
||||
|
|
@ -460,6 +460,16 @@ struct ConsoleLogsView: View {
|
|||
showingCustomAlert = true
|
||||
}
|
||||
|
||||
private func updateJITBottomState(_ isAtBottom: Bool) {
|
||||
guard jitIsAtBottom != isAtBottom else { return }
|
||||
jitIsAtBottom = isAtBottom
|
||||
}
|
||||
|
||||
private func updateSyslogBottomState(_ isAtBottom: Bool) {
|
||||
guard syslogIsAtBottom != isAtBottom else { return }
|
||||
syslogIsAtBottom = isAtBottom
|
||||
}
|
||||
|
||||
nonisolated private static func parseAppLogEntries<S: Sequence>(from lines: S, skipPrefixes: [String] = []) -> [LogManager.LogEntry] where S.Element == String {
|
||||
var parsed: [LogManager.LogEntry] = []
|
||||
parsed.reserveCapacity(lines.underestimatedCount)
|
||||
|
|
|
|||
|
|
@ -1,167 +1,28 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// HomeView.swift
|
||||
// StikDebug
|
||||
//
|
||||
// Created by Stephen on 3/26/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct JITEnableConfiguration {
|
||||
var bundleID: String? = nil
|
||||
var pid : Int? = nil
|
||||
var scriptData: Data? = nil
|
||||
var scriptName : String? = nil
|
||||
}
|
||||
|
||||
private enum ExternalURLAction: Identifiable {
|
||||
case enableJIT(JITEnableConfiguration)
|
||||
case killProcess(Int)
|
||||
case launchApp(String)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .enableJIT(let config):
|
||||
return "enable-\(config.bundleID ?? "")-\(config.pid ?? 0)-\(config.scriptName ?? "")"
|
||||
case .killProcess(let pid):
|
||||
return "kill-\(pid)"
|
||||
case .launchApp(let bundleID):
|
||||
return "launch-\(bundleID)"
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .enableJIT:
|
||||
return "Enable JIT?"
|
||||
case .killProcess:
|
||||
return "Kill Process?"
|
||||
case .launchApp:
|
||||
return "Launch App?"
|
||||
}
|
||||
}
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .enableJIT(let config):
|
||||
let scriptText = config.scriptData == nil ? "" : " and run a script"
|
||||
return "An external link wants to enable JIT\(scriptText) for \(targetDescription(for: config))."
|
||||
case .killProcess(let pid):
|
||||
return "An external link wants to kill process \(pid)."
|
||||
case .launchApp(let bundleID):
|
||||
return "An external link wants to launch \(bundleID)."
|
||||
}
|
||||
}
|
||||
|
||||
var confirmationTitle: String {
|
||||
switch self {
|
||||
case .enableJIT(let config):
|
||||
return config.scriptData == nil ? "Enable JIT" : "Enable and Run Script"
|
||||
case .killProcess:
|
||||
return "Kill Process"
|
||||
case .launchApp:
|
||||
return "Launch App"
|
||||
}
|
||||
}
|
||||
|
||||
var role: ButtonRole? {
|
||||
switch self {
|
||||
case .enableJIT(let config):
|
||||
return config.scriptData == nil ? nil : .destructive
|
||||
case .killProcess:
|
||||
return .destructive
|
||||
case .launchApp:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func targetDescription(for config: JITEnableConfiguration) -> String {
|
||||
if let bundleID = config.bundleID {
|
||||
return bundleID
|
||||
}
|
||||
if let pid = config.pid {
|
||||
return "process \(pid)"
|
||||
}
|
||||
return "the requested app"
|
||||
}
|
||||
}
|
||||
|
||||
private final class DebugKeepAliveLease {
|
||||
private let stateLock = NSLock()
|
||||
private var isActive = false
|
||||
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||
|
||||
init() {
|
||||
activate()
|
||||
}
|
||||
|
||||
func invalidate() {
|
||||
stateLock.lock()
|
||||
guard isActive else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
isActive = false
|
||||
stateLock.unlock()
|
||||
|
||||
runOnMain {
|
||||
BackgroundAudioManager.shared.requestStop()
|
||||
BackgroundLocationManager.shared.requestStop()
|
||||
|
||||
if self.backgroundTaskID != .invalid {
|
||||
UIApplication.shared.endBackgroundTask(self.backgroundTaskID)
|
||||
self.backgroundTaskID = .invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
stateLock.lock()
|
||||
guard !isActive else {
|
||||
stateLock.unlock()
|
||||
return
|
||||
}
|
||||
isActive = true
|
||||
stateLock.unlock()
|
||||
|
||||
runOnMain {
|
||||
BackgroundAudioManager.shared.requestStart()
|
||||
BackgroundLocationManager.shared.requestStart()
|
||||
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in
|
||||
LogManager.shared.addWarningLog("Debug session background task expired")
|
||||
self?.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runOnMain(_ work: @escaping () -> Void) {
|
||||
if Thread.isMainThread {
|
||||
work()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HomeView: View {
|
||||
|
||||
@AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false
|
||||
@AppStorage("bundleID") private var bundleID: String = ""
|
||||
@State private var isProcessing = false
|
||||
@State private var viewDidAppeared = false
|
||||
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
|
||||
@State private var isShowingPairingFilePicker = false
|
||||
@State private var debugFeedback: DebugFeedback?
|
||||
@State private var pendingExternalURLAction: ExternalURLAction?
|
||||
@AppStorage(UserDefaults.Keys.defaultScriptName) private var selectedScript = UserDefaults.Keys.defaultScriptNameValue
|
||||
|
||||
@State var scriptViewShow = false
|
||||
@State private var isShowingConsole = false
|
||||
@AppStorage(UserDefaults.Keys.defaultScriptName) var selectedScript = UserDefaults.Keys.defaultScriptNameValue
|
||||
@State var jsModel: RunJSViewModel?
|
||||
@ObservedObject private var mounting = MountingProgress.shared
|
||||
|
||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
@State private var hasAppeared = false
|
||||
@State private var pendingJITEnableConfiguration: JITEnableConfiguration?
|
||||
@State private var isShowingPairingFilePicker = false
|
||||
@State private var debugFeedback: DebugFeedback?
|
||||
@State private var pendingExternalURLAction: HomeExternalAction?
|
||||
@State private var isShowingScriptRunner = false
|
||||
@State private var scriptRunModel: RunJSViewModel?
|
||||
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
private struct DebugFeedback: Identifiable {
|
||||
let id = UUID()
|
||||
|
|
@ -173,7 +34,7 @@ struct HomeView: View {
|
|||
var body: some View {
|
||||
InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in
|
||||
bundleID = selectedBundle
|
||||
HapticFeedbackHelper.trigger()
|
||||
Haptics.medium()
|
||||
startJITInBackground(bundleID: selectedBundle, displayName: selectedName)
|
||||
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
|
||||
.overlay(alignment: .bottom) {
|
||||
|
|
@ -183,27 +44,10 @@ struct HomeView: View {
|
|||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
startTunnelInBackground()
|
||||
MountingProgress.shared.checkforMounted()
|
||||
viewDidAppeared = true
|
||||
if let config = pendingJITEnableConfiguration {
|
||||
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
|
||||
pendingJITEnableConfiguration = nil
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady)) { notification in
|
||||
guard let model = notification.userInfo?["model"] as? RunJSViewModel else { return }
|
||||
jsModel = model
|
||||
if let name = notification.userInfo?["scriptName"] as? String {
|
||||
selectedScript = name
|
||||
}
|
||||
scriptViewShow = true
|
||||
}
|
||||
.onAppear(perform: handleAppear)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification)
|
||||
.onReceive(timer) { _ in
|
||||
if mounting.mountingThread == nil && !mounting.coolisMounted {
|
||||
MountingProgress.shared.checkforMounted()
|
||||
}
|
||||
refreshMountStatusIfNeeded()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleExternalURL(url)
|
||||
|
|
@ -231,48 +75,18 @@ struct HomeView: View {
|
|||
} message: { action in
|
||||
Text(action.message)
|
||||
}
|
||||
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: PairingFileStore.supportedContentTypes) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
let fileManager = FileManager.default
|
||||
do {
|
||||
try PairingFileStore.importFromPicker(url, fileManager: fileManager)
|
||||
pubTunnelConnected = false
|
||||
startTunnelInBackground()
|
||||
NotificationCenter.default.post(name: .pairingFileImported, object: nil)
|
||||
// Dismiss any existing connection error alert
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController {
|
||||
var top = root
|
||||
while let presented = top.presentedViewController { top = presented }
|
||||
if top is UIAlertController { top.dismiss(animated: true) }
|
||||
}
|
||||
} catch {
|
||||
print("Error copying pairing file: \(error)")
|
||||
}
|
||||
case .failure(let error):
|
||||
print("Failed to import pairing file: \(error)")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingConsole) {
|
||||
.fileImporter(
|
||||
isPresented: $isShowingPairingFilePicker,
|
||||
allowedContentTypes: PairingFileStore.supportedContentTypes,
|
||||
onCompletion: importPairingFile
|
||||
)
|
||||
.sheet(isPresented: $isShowingScriptRunner) {
|
||||
NavigationStack {
|
||||
ConsoleLogsView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
isShowingConsole = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $scriptViewShow) {
|
||||
NavigationStack {
|
||||
if let jsModel {
|
||||
RunJSView(model: jsModel)
|
||||
if let scriptRunModel {
|
||||
RunJSView(model: scriptRunModel)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { scriptViewShow = false }
|
||||
Button("Done") { isShowingScriptRunner = false }
|
||||
}
|
||||
}
|
||||
.navigationTitle(selectedScript)
|
||||
|
|
@ -282,6 +96,59 @@ struct HomeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func handleAppear() {
|
||||
startTunnelInBackground()
|
||||
MountingProgress.shared.checkforMounted()
|
||||
hasAppeared = true
|
||||
|
||||
if let config = pendingJITEnableConfiguration {
|
||||
startJITInBackground(
|
||||
bundleID: config.bundleID,
|
||||
pid: config.pid,
|
||||
scriptData: config.scriptData,
|
||||
scriptName: config.scriptName,
|
||||
triggeredByURLScheme: true
|
||||
)
|
||||
pendingJITEnableConfiguration = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScriptReadyNotification(_ notification: Notification) {
|
||||
guard let model = notification.userInfo?["model"] as? RunJSViewModel else {
|
||||
return
|
||||
}
|
||||
|
||||
scriptRunModel = model
|
||||
if let name = notification.userInfo?["scriptName"] as? String {
|
||||
selectedScript = name
|
||||
}
|
||||
isShowingScriptRunner = true
|
||||
}
|
||||
|
||||
private func refreshMountStatusIfNeeded() {
|
||||
guard mounting.mountingThread == nil, !mounting.coolisMounted else {
|
||||
return
|
||||
}
|
||||
MountingProgress.shared.checkforMounted()
|
||||
}
|
||||
|
||||
private func importPairingFile(_ result: Result<URL, Error>) {
|
||||
switch result {
|
||||
case .success(let url):
|
||||
do {
|
||||
try PairingFileStore.importFromPicker(url)
|
||||
markTunnelDisconnected()
|
||||
startTunnelInBackground()
|
||||
NotificationCenter.default.post(name: .pairingFileImported, object: nil)
|
||||
AlertPresenter.dismissPresentedAlert()
|
||||
} catch {
|
||||
LogManager.shared.addErrorLog("Failed to import pairing file: \(error.localizedDescription)")
|
||||
}
|
||||
case .failure(let error):
|
||||
LogManager.shared.addErrorLog("Pairing file picker failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func queryValue(_ names: [String], in components: URLComponents?) -> String? {
|
||||
guard let queryItems = components?.queryItems else { return nil }
|
||||
for name in names {
|
||||
|
|
@ -336,10 +203,10 @@ struct HomeView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func performExternalURLAction(_ action: ExternalURLAction) {
|
||||
private func performExternalURLAction(_ action: HomeExternalAction) {
|
||||
switch action {
|
||||
case .enableJIT(let config):
|
||||
if viewDidAppeared {
|
||||
if hasAppeared {
|
||||
startJITInBackground(
|
||||
bundleID: config.bundleID,
|
||||
pid: config.pid,
|
||||
|
|
@ -351,7 +218,7 @@ struct HomeView: View {
|
|||
pendingJITEnableConfiguration = config
|
||||
}
|
||||
case .killProcess(let pid):
|
||||
pubTunnelConnected = false
|
||||
markTunnelDisconnected()
|
||||
startTunnelInBackground(showErrorUI: false)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
sleep(1)
|
||||
|
|
@ -367,7 +234,7 @@ struct HomeView: View {
|
|||
}
|
||||
}
|
||||
case .launchApp(let bundleID):
|
||||
HapticFeedbackHelper.trigger()
|
||||
Haptics.medium()
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let _ = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil)
|
||||
}
|
||||
|
|
@ -403,8 +270,8 @@ struct HomeView: View {
|
|||
semaphore: semaphore)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
jsModel = model
|
||||
scriptViewShow = true
|
||||
scriptRunModel = model
|
||||
isShowingScriptRunner = true
|
||||
}
|
||||
|
||||
do {
|
||||
|
|
@ -419,7 +286,6 @@ struct HomeView: View {
|
|||
}
|
||||
|
||||
private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false, displayName: String? = nil) {
|
||||
isProcessing = true
|
||||
let targetName = displayName ?? bundleID ?? pid.map { String(format: "process %d".localized, $0) } ?? "app".localized
|
||||
let startingMessage = String(format: "Starting JIT for %@".localized, targetName)
|
||||
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
|
||||
|
|
@ -429,7 +295,7 @@ struct HomeView: View {
|
|||
AccessibilityAnnouncer.announce(startingMessage)
|
||||
|
||||
if triggeredByURLScheme {
|
||||
pubTunnelConnected = false
|
||||
markTunnelDisconnected()
|
||||
startTunnelInBackground(showErrorUI: false)
|
||||
}
|
||||
|
||||
|
|
@ -443,7 +309,6 @@ struct HomeView: View {
|
|||
|
||||
let finishProcessing: (Bool, String?) -> Void = { success, detail in
|
||||
DispatchQueue.main.async {
|
||||
isProcessing = false
|
||||
let message = success
|
||||
? String(format: "JIT request completed for %@".localized, targetName)
|
||||
: String(format: "JIT failed for %@".localized, targetName)
|
||||
|
|
@ -523,40 +388,3 @@ struct HomeView: View {
|
|||
#Preview {
|
||||
HomeView()
|
||||
}
|
||||
|
||||
public extension ProcessInfo {
|
||||
var hasTXM: Bool {
|
||||
if isTXMOverridden {
|
||||
return true
|
||||
}
|
||||
return ProcessInfo.hasTXMSupport(
|
||||
operatingSystemVersion: operatingSystemVersion,
|
||||
localTXMDetector: ProcessInfo.detectLocalTXM
|
||||
)
|
||||
}
|
||||
|
||||
var isTXMOverridden: Bool {
|
||||
UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
|
||||
}
|
||||
|
||||
static func hasTXMSupport(
|
||||
operatingSystemVersion: OperatingSystemVersion,
|
||||
localTXMDetector: () -> Bool
|
||||
) -> Bool {
|
||||
guard operatingSystemVersion.majorVersion >= 26 else {
|
||||
return false
|
||||
}
|
||||
return localTXMDetector()
|
||||
}
|
||||
|
||||
private static func detectLocalTXM() -> Bool {
|
||||
if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36),
|
||||
let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) {
|
||||
return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
|
||||
} else {
|
||||
return (FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map {
|
||||
access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0
|
||||
}) ?? false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -8,10 +8,6 @@
|
|||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let switchToTab = Notification.Name("MainTabSwitchNotification")
|
||||
}
|
||||
|
||||
private enum ExternalLocationAction: Identifiable {
|
||||
case simulate(URL, Double, Double)
|
||||
case clear
|
||||
|
|
@ -55,7 +51,6 @@ private enum ExternalLocationAction: Identifiable {
|
|||
|
||||
struct MainTabView: View {
|
||||
@AppStorage("primaryTabSelection") private var selection: String = AppFeature.home.id
|
||||
@State private var switchObserver: Any?
|
||||
@State private var detachedFeature: AppFeature?
|
||||
@State private var didSetInitialHome = false
|
||||
@State private var pendingLocationAction: ExternalLocationAction?
|
||||
|
|
@ -77,17 +72,6 @@ struct MainTabView: View {
|
|||
selection = AppFeature.home.id
|
||||
didSetInitialHome = true
|
||||
}
|
||||
guard switchObserver == nil else { return }
|
||||
switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in
|
||||
guard let id = note.object as? String else { return }
|
||||
openFeature(id: id)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer = switchObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
switchObserver = nil
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
|
|
|
|||
|
|
@ -1651,7 +1651,7 @@ private struct RouteSearchSheet: View {
|
|||
} else if !activeResults.isEmpty {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(activeResults.enumerated()), id: \.offset) { index, result in
|
||||
ForEach(Array(activeResults.enumerated()), id: \.element) { index, result in
|
||||
Button {
|
||||
resolve(result)
|
||||
} label: {
|
||||
|
|
|
|||
|
|
@ -265,9 +265,9 @@ struct ProfileView: View {
|
|||
) { result in
|
||||
switch result {
|
||||
case .success(let url):
|
||||
print("Saved at \(url)")
|
||||
LogManager.shared.addInfoLog("Provisioning profile exported to \(url.lastPathComponent)")
|
||||
case .failure(let error):
|
||||
print("Error: \(error.localizedDescription)")
|
||||
LogManager.shared.addErrorLog("Provisioning profile export failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue