mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
373 lines
14 KiB
Swift
373 lines
14 KiB
Swift
//
|
|
// StikJITApp.swift
|
|
// StikJIT
|
|
//
|
|
// Created by Stephen on 3/26/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
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: - 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()
|
|
.environment(LanguageManager.shared)
|
|
.id(LanguageManager.shared.language)
|
|
.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("\(error.localizedDescription) (Code: \(code))")
|
|
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: "\(error.localizedDescription)\n\nCheck that the embedded VPN is connected and that the pairing file is current.",
|
|
showOk: false,
|
|
showTryAgain: true
|
|
) { shouldTryAgain in
|
|
if shouldTryAgain {
|
|
DispatchQueue.main.async {
|
|
startTunnelInBackground()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
|
|
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)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL(let string):
|
|
return "Invalid download URL: \(string)"
|
|
}
|
|
}
|
|
}
|
|
|
|
func downloadFile(from urlString: String, to destinationURL: URL) async throws {
|
|
guard let url = URL(string: urlString) else {
|
|
throw DDIDownloadError.invalidURL(urlString)
|
|
}
|
|
let (tempLocalUrl, _) = try await URLSession.shared.download(from: url)
|
|
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.")
|
|
}
|