StikDebug/StikJIT/Views/DeviceInfoManager.swift
Huge_Black dca36d5c00
Heartbeat Rework + Mini Tool (#299)
* Heartbeat rework

* remove some debug loggings

* stik mini tool poc

* Saperate JITEnableContext into extensions and put each feature into their own files.
Fix local pairing file is removed after switched to external device by directly loading the remote device's pairing file in place.

* custom origin & xmlhttprequest

* Fix some memory leak

* allow background script access idevice (codes are from the old JSSupport branch)

Tests are not carried out but most of the js api should work.

Do we need to obfuscate some functions' name?

* Fix JITEnableContext singleton initializer race condition

* Fix instprox callback

---------

Co-authored-by: Stephen B <158498287+StephenDev0@users.noreply.github.com>
2025-12-14 11:35:32 -05:00

445 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// DeviceInfoManager.swift
// StikDebug
//
// Created by Stephen on 8/2/25.
//
import SwiftUI
import UIKit
import UniformTypeIdentifiers
// MARK: - Device Info Manager
struct LockdownClientSendable: @unchecked Sendable {
let raw: OpaquePointer
}
@MainActor
final class DeviceInfoManager: ObservableObject {
@Published var entries: [(key: String, value: String)] = []
@Published var busy = false
@Published var error: (title: String, message: String)?
private var initialized = false
private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
private var lockdownHandle: LockdownClientSendable? = nil
func initAndLoad() {
guard !initialized else { loadInfo(); return }
busy = true
Task.detached {
do {
try JITEnableContext.shared.ensureHeartbeat()
} catch {
await MainActor.run {
self.error = ("Initialization Failed", self.initErrorMessage(Int32((error as NSError).code)))
self.busy = false
}
}
do {
let lockdownHandle = LockdownClientSendable(raw: try JITEnableContext.shared.ideviceInfoInit())
await MainActor.run {
self.lockdownHandle = lockdownHandle
self.initialized = true
self.loadInfo()
}
} catch {
await MainActor.run {
self.error = ("Initialization Failed", self.initErrorMessage(Int32((error as NSError).code)))
self.busy = false
}
}
}
}
private func loadInfo() {
busy = true
Task.detached {
var cXml : UnsafeMutablePointer<CChar>? = nil;
do {
cXml = try await JITEnableContext.shared.ideviceInfoGetXML(withLockdownClient: self.lockdownHandle?.raw)
} catch {
await MainActor.run {
self.error = ("Fetch Error", "Failed to fetch device info \(error)")
self.busy = false
}
return
}
guard let cXml else { return }
defer { free(UnsafeMutableRawPointer(mutating: cXml)) }
guard let xml = String(validatingUTF8: cXml) else {
await MainActor.run {
self.error = ("Parse Error", "Invalid XML data")
self.busy = false
}
return
}
do {
let data = Data(xml.utf8)
guard let dict = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] else {
throw NSError(domain: "DeviceInfo", code: 0,
userInfo: [NSLocalizedDescriptionKey: "Expected dictionary"])
}
let formatted = dict.keys.sorted().map { ($0, Self.convertToString(dict[$0]!)) }
await MainActor.run {
self.entries = formatted
self.busy = false
}
} catch {
await MainActor.run {
self.error = ("Parse Error", error.localizedDescription)
self.busy = false
}
}
}
}
func cleanup() {
if let lockdownHandle {
lockdownd_client_free(lockdownHandle.raw)
self.lockdownHandle = nil
}
initialized = false
}
private func initErrorMessage(_ code: Int32) -> String {
switch code {
case 1: return "Couldn’t read pairingFile.plist"
case 2: return "Unable to create device provider"
case 3: return "Cannot connect to lockdown service"
case 4: return "Unable to start lockdown session"
default: return "Unknown init error (\(code))"
}
}
nonisolated private static func convertToString(_ raw: Any) -> String {
switch raw {
case let s as String: return s
case let n as NSNumber: return n.stringValue
default: return String(describing: raw)
}
}
func exportToCSV() throws -> URL {
var csv = "Key,Value\n"
for (k, v) in entries {
csv += "\"\(k.replacingOccurrences(of: "\"", with: "\"\""))\","
csv += "\"\(v.replacingOccurrences(of: "\"", with: "\"\""))\"\n"
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent("DeviceInfo.csv")
try csv.data(using: .utf8)?.write(to: url)
return url
}
}
// MARK: - Device Info UI
struct DeviceInfoView: View {
@StateObject private var mgr = DeviceInfoManager()
@State private var importer = false
@State private var exportURL: URL?
@State private var isShowingExporter = false
@State private var shareItems: [Any] = []
@State private var showShareSheet = false
@State private var justCopied = false
private let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
private var pairingURL: URL { docs.appendingPathComponent("pairingFile.plist") }
private var isPaired: Bool { FileManager.default.fileExists(atPath: pairingURL.path) }
@State private var searchText = ""
@State private var alert = false
@State private var alertTitle = ""
@State private var alertMsg = ""
@State private var alertSuccess = false
var filteredEntries: [(key: String, value: String)] {
guard !searchText.isEmpty else { return mgr.entries }
return mgr.entries.filter {
$0.key.localizedCaseInsensitiveContains(searchText)
|| $0.value.localizedCaseInsensitiveContains(searchText)
}
}
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
@Environment(\.themeExpansionManager) private var themeExpansion
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
var body: some View {
NavigationStack {
ZStack {
ThemedBackground(style: backgroundStyle)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 20) {
infoCard
}
.padding(.horizontal, 20)
.padding(.vertical, 30)
}
if mgr.busy {
Color.black.opacity(0.35).ignoresSafeArea()
ProgressView("Fetching device info…")
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
if alert {
CustomErrorView(title: alertTitle,
message: alertMsg,
onDismiss: { alert = false },
messageType: alertSuccess ? .success : .error)
}
if justCopied {
VStack {
Spacer()
Text("Copied")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: Capsule())
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
.transition(.move(edge: .bottom).combined(with: .opacity))
.padding(.bottom, 30)
}
.animation(.easeInOut(duration: 0.25), value: justCopied)
}
}
.navigationTitle("Device Info")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
if isPaired {
Button { mgr.initAndLoad() } label: {
Label("Reload", systemImage: "arrow.clockwise")
}
Button {
do {
exportURL = try mgr.exportToCSV()
isShowingExporter = true
} catch {
fail("Export Failed", error.localizedDescription)
}
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.disabled(mgr.entries.isEmpty)
Menu {
Button { copyAllText() } label: {
Label("Copy All (Text)", systemImage: "doc.on.doc")
}
Button { copyAllCSV() } label: {
Label("Copy All (CSV)", systemImage: "tablecells")
}
Button { shareAll() } label: {
Label("Share…", systemImage: "square.and.arrow.up.on.square")
}
} label: {
Image(systemName: "ellipsis.circle")
}
.disabled(mgr.entries.isEmpty)
}
}
ToolbarItem(placement: .navigationBarLeading) {
if !isPaired {
Button { importer = true } label: {
Label("Import Pairing File", systemImage: "doc.badge.plus")
}
}
}
}
.fileImporter(isPresented: $importer, allowedContentTypes: [.propertyList]) { result in
if case .success(let url) = result { importPairing(from: url) }
}
.fileExporter(
isPresented: $isShowingExporter,
document: CSVDocument(url: exportURL),
contentType: .commaSeparatedText,
defaultFilename: "DeviceInfo"
) { _ in notify("Export Complete", "Device info exported to CSV") }
.sheet(isPresented: $showShareSheet) {
ActivityViewController(items: shareItems)
}
.onAppear { if isPaired { mgr.initAndLoad() } }
.onDisappear { mgr.cleanup() }
}
.preferredColorScheme(preferredScheme)
}
// MARK: - UI Sections
private var infoCard: some View {
VStack(alignment: .leading, spacing: 14) {
TextField("Search device info…", text: $searchText)
.padding(12)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
)
.padding(.bottom, 10)
if !isPaired {
VStack(alignment: .leading, spacing: 6) {
Label("No pairing file detected", systemImage: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Import your device’s pairing file to get started.")
.font(.footnote)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
}
if mgr.entries.isEmpty {
Text(mgr.busy ? "Loading…" : (isPaired ? "No info available" : ""))
.foregroundColor(.secondary)
} else {
ForEach(filteredEntries, id: \.key) { entry in
VStack(alignment: .leading, spacing: 6) {
Text(entry.key).bold()
Text(entry.value)
.font(.caption.monospaced())
.foregroundColor(.secondary)
.textSelection(.enabled)
}
.padding(.vertical, 4)
Divider()
.background(Color.white.opacity(0.12))
}
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
// MARK: - Copy / Share helpers
private func allAsText() -> String {
filteredEntries.map { "\($0.key): \($0.value)" }.joined(separator: "\n")
}
private func allAsCSV() -> String {
var csv = "Key,Value\n"
for (k, v) in filteredEntries {
let kq = k.replacingOccurrences(of: "\"", with: "\"\"")
let vq = v.replacingOccurrences(of: "\"", with: "\"\"")
csv += "\"\(kq)\",\"\(vq)\"\n"
}
return csv
}
private func copyAllText() {
UIPasteboard.general.string = allAsText()
hapticCopySuccess()
showCopiedToast()
}
private func copyAllCSV() {
UIPasteboard.general.string = allAsCSV()
hapticCopySuccess()
showCopiedToast()
}
private func copyToPasteboard(_ str: String) {
UIPasteboard.general.string = str
hapticCopySuccess()
showCopiedToast()
}
private func shareAll() {
let text = allAsText()
shareItems = [text]
showShareSheet = true
}
private func hapticCopySuccess() {
let gen = UINotificationFeedbackGenerator()
gen.notificationOccurred(.success)
}
private func showCopiedToast() {
withAnimation { justCopied = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
withAnimation { justCopied = false }
}
}
// MARK: - Pairing Import
private func importPairing(from src: URL) {
guard src.startAccessingSecurityScopedResource() else { return }
defer { src.stopAccessingSecurityScopedResource() }
do {
if FileManager.default.fileExists(atPath: pairingURL.path) {
try FileManager.default.removeItem(at: pairingURL)
}
try FileManager.default.copyItem(at: src, to: pairingURL)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: pairingURL.path)
notify("Pairing File Added", "Your device is ready. Tap Reload to fetch info.")
mgr.initAndLoad()
} catch {
fail("Import Failed", error.localizedDescription)
}
}
// MARK: - Alerts
private func fail(_ title: String, _ msg: String) {
alertTitle = title; alertMsg = msg; alertSuccess = false; alert = true
}
private func notify(_ title: String, _ msg: String) {
alertTitle = title; alertMsg = msg; alertSuccess = true; alert = true
}
}
// MARK: - CSV Document
struct CSVDocument: FileDocument {
static var readableContentTypes: [UTType] { [] }
static var writableContentTypes: [UTType] { [UTType.commaSeparatedText] }
let url: URL?
init(url: URL?) { self.url = url }
init(configuration: ReadConfiguration) throws { fatalError("Not supported") }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
guard let url = url else { throw NSError(domain: "CSVDocument", code: -1) }
return try FileWrapper(url: url, options: .immediate)
}
}
// MARK: - UIKit Share Sheet wrapper
private struct ActivityViewController: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) { }
}