diff --git a/StikJIT/Info.plist b/StikJIT/Info.plist
index 27dbccf..aabccb5 100644
--- a/StikJIT/Info.plist
+++ b/StikJIT/Info.plist
@@ -28,5 +28,12 @@
UIFileSharingEnabled
+ NSLocalNetworkUsageDescription
+ StikDebug needs access to devices on your local network so it can connect to the targets you add to the Device Library.
+ NSBonjourServices
+
+ _stikdebug._tcp
+ _stikdebug._udp
+
diff --git a/StikJIT/StikJIT-Bridging-Header.h b/StikJIT/StikJIT-Bridging-Header.h
index f0317ad..c4b0c21 100644
--- a/StikJIT/StikJIT-Bridging-Header.h
+++ b/StikJIT/StikJIT-Bridging-Header.h
@@ -10,3 +10,4 @@
#include "idevice/ideviceinfo.h"
#include "idevice/ls.h"
#include "idevice/profiles.h"
+#include "idevice/something.h"
diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift
index a0fda6c..2323148 100644
--- a/StikJIT/StikJITApp.swift
+++ b/StikJIT/StikJITApp.swift
@@ -567,7 +567,7 @@ struct HeartbeatApp: App {
}
private func triggerAutoVPNStartIfNeeded() {
- guard autoStartVPN else { return }
+ guard autoStartVPN, DeviceConnectionContext.requiresLoopbackVPN else { return }
let manager = TunnelManager.shared
if manager.tunnelStatus == .disconnected || manager.tunnelStatus == .error {
manager.startVPN()
@@ -701,12 +701,14 @@ class MountingProgress: ObservableObject {
}
private func mount() {
- guard TunnelManager.shared.tunnelStatus == .connected else {
- DispatchQueue.main.async {
- self.coolisMounted = false
- self.mountingThread = nil
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ guard TunnelManager.shared.tunnelStatus == .connected else {
+ DispatchQueue.main.async {
+ self.coolisMounted = false
+ self.mountingThread = nil
+ }
+ return
}
- return
}
let currentlyMounted = isMounted()
@@ -724,6 +726,7 @@ class MountingProgress: ObservableObject {
mountingThread = Thread { [weak self] in
guard let self = self else { return }
let mountResult = mountPersonalDDI(
+ deviceIP: DeviceConnectionContext.targetIPAddress,
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,
@@ -766,7 +769,7 @@ func isPairing() -> Bool {
return true
}
-func startHeartbeatInBackground(requireVPNConnection: Bool = true) {
+func startHeartbeatInBackground(requireVPNConnection: Bool? = nil) {
assert(Thread.isMainThread, "startHeartbeatInBackground must be called on the main thread")
let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
@@ -775,8 +778,9 @@ func startHeartbeatInBackground(requireVPNConnection: Bool = true) {
return
}
+ let shouldRequireVPN = requireVPNConnection ?? DeviceConnectionContext.requiresLoopbackVPN
let vpnConnected = TunnelManager.shared.tunnelStatus == .connected
- if requireVPNConnection && !vpnConnected {
+ if shouldRequireVPN && !vpnConnected {
if !heartbeatStartPending {
print("Heartbeat start deferred until VPN connects")
}
@@ -851,7 +855,8 @@ func startHeartbeatInBackground(requireVPNConnection: Bool = true) {
}
func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) {
- let host = NWEndpoint.Host("10.7.0.1")
+ 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?
@@ -861,7 +866,13 @@ func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) {
connection?.cancel()
DispatchQueue.main.async {
if timeoutWorkItem?.isCancelled == false {
- callback(false, "[TIMEOUT] The loopback VPN is not connected. Try closing this app, turn it off and back on.")
+ let message: String
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ message = "[TIMEOUT] The loopback VPN is not connected. Try closing this app, turn it off and back on."
+ } else {
+ message = "[TIMEOUT] Could not reach the device at \(targetIP). Make sure it’s online and on the same network."
+ }
+ callback(false, message)
}
}
}
@@ -879,13 +890,19 @@ func checkVPNConnection(callback: @escaping (Bool, String?) -> Void) {
timeoutWorkItem?.cancel()
connection?.cancel()
DispatchQueue.main.async {
- if error == NWError.posix(.ETIMEDOUT) {
- callback(false, "The loopback VPN is not connected. Try closing the app, turn it off and back on.")
- } else if error == NWError.posix(.ECONNREFUSED) {
- callback(false, "Wifi is not connected. StikJIT won't work on cellular data.")
+ let message: String
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ if error == NWError.posix(.ETIMEDOUT) {
+ message = "The loopback VPN is not connected. Try closing the app, turn it off and back on."
+ } else if error == NWError.posix(.ECONNREFUSED) {
+ message = "Wi-Fi is not connected. StikDebug can't connect over cellular data while in loopback mode."
+ } else {
+ message = "VPN check error: \(error.localizedDescription)"
+ }
} else {
- callback(false, "em proxy check error: \(error.localizedDescription)")
+ message = "Could not reach the device at \(targetIP): \(error.localizedDescription)"
}
+ callback(false, message)
}
default:
break
diff --git a/StikJIT/Utilities/DeviceConnectionContext.swift b/StikJIT/Utilities/DeviceConnectionContext.swift
new file mode 100644
index 0000000..3397e2f
--- /dev/null
+++ b/StikJIT/Utilities/DeviceConnectionContext.swift
@@ -0,0 +1,25 @@
+//
+// DeviceConnectionContext.swift
+// StikJIT
+//
+// Created by Stephen.
+//
+
+import Foundation
+
+enum DeviceConnectionContext {
+ static var isUsingExternalDevice: Bool {
+ DeviceLibraryStore.shared.isUsingExternalDevice
+ }
+
+ static var requiresLoopbackVPN: Bool {
+ !isUsingExternalDevice
+ }
+
+ static var targetIPAddress: String {
+ if let device = DeviceLibraryStore.shared.activeDevice {
+ return device.ipAddress
+ }
+ return UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.2"
+ }
+}
diff --git a/StikJIT/Utilities/DeviceLibraryStore.swift b/StikJIT/Utilities/DeviceLibraryStore.swift
new file mode 100644
index 0000000..b4284ca
--- /dev/null
+++ b/StikJIT/Utilities/DeviceLibraryStore.swift
@@ -0,0 +1,265 @@
+//
+// DeviceLibraryStore.swift
+// StikJIT
+//
+// Created by Stephen.
+//
+
+import Foundation
+
+struct DeviceProfileEntry: Identifiable, Codable, Equatable {
+ var id: UUID
+ var name: String
+ var ipAddress: String
+ var pairingRelativePath: String
+ var pairingFilename: String
+ var dateAdded: Date
+ var lastUpdated: Date
+}
+
+enum DeviceLibraryError: LocalizedError {
+ case missingPairingData
+ case deviceNotFound
+ case pairingFileUnavailable
+ case fileOperationFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingPairingData:
+ return "Select a pairing file before saving."
+ case .deviceNotFound:
+ return "The selected device could not be found."
+ case .pairingFileUnavailable:
+ return "The pairing file for this device is missing. Re-import it and try again."
+ case .fileOperationFailed(let reason):
+ return reason
+ }
+ }
+}
+
+final class DeviceLibraryStore: ObservableObject {
+ static let shared = DeviceLibraryStore()
+
+ @Published private(set) var devices: [DeviceProfileEntry] = []
+ @Published private(set) var activeDeviceID: UUID?
+
+ var activeDevice: DeviceProfileEntry? {
+ guard let activeDeviceID else { return nil }
+ return devices.first(where: { $0.id == activeDeviceID })
+ }
+
+ var isUsingExternalDevice: Bool {
+ activeDeviceID != nil
+ }
+
+ var defaultLocalDevice: DeviceProfileEntry {
+ DeviceProfileEntry(
+ id: localLoopbackID,
+ name: "This Device",
+ ipAddress: "10.7.0.2",
+ pairingRelativePath: "",
+ pairingFilename: "pairingFile.plist",
+ dateAdded: Date.distantPast,
+ lastUpdated: Date.distantPast
+ )
+ }
+
+ private let fileManager = FileManager.default
+ private let storageURL: URL
+ private let pairingsDirectory: URL
+ private let baseDirectory: URL
+ private let activeDeviceKey = "DeviceLibraryActiveDeviceID"
+ private let localLoopbackID = UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID()
+
+ private init() {
+ baseDirectory = URL.documentsDirectory.appendingPathComponent("DeviceLibrary", isDirectory: true)
+ pairingsDirectory = baseDirectory.appendingPathComponent("Pairings", isDirectory: true)
+ storageURL = baseDirectory.appendingPathComponent("devices.json")
+ createDirectoriesIfNeeded()
+ loadFromDisk()
+ if let rawValue = UserDefaults.standard.string(forKey: activeDeviceKey),
+ let uuid = UUID(uuidString: rawValue),
+ devices.contains(where: { $0.id == uuid }) {
+ activeDeviceID = uuid
+ } else {
+ UserDefaults.standard.removeObject(forKey: activeDeviceKey)
+ UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice)
+ activeDeviceID = nil
+ }
+ updateExternalDeviceFlag()
+ }
+
+ // MARK: - Public API
+
+ func refresh() {
+ loadFromDisk()
+ }
+
+ func addDevice(name: String, ipAddress: String, pairingData: Data?, originalFilename: String?) throws {
+ guard let pairingData else {
+ throw DeviceLibraryError.missingPairingData
+ }
+
+ let id = UUID()
+ let now = Date()
+ let relativePath = try persistPairingData(pairingData, for: id)
+ let entry = DeviceProfileEntry(
+ id: id,
+ name: name.trimmingCharacters(in: .whitespacesAndNewlines),
+ ipAddress: ipAddress.trimmingCharacters(in: .whitespacesAndNewlines),
+ pairingRelativePath: relativePath,
+ pairingFilename: originalFilename ?? "pairingFile.plist",
+ dateAdded: now,
+ lastUpdated: now
+ )
+ devices.append(entry)
+ persistDevices()
+ }
+
+ func update(device: DeviceProfileEntry,
+ name: String,
+ ipAddress: String,
+ pairingData: Data?,
+ originalFilename: String?) throws {
+ guard !isDefaultDevice(device) else { return }
+ guard let index = devices.firstIndex(where: { $0.id == device.id }) else {
+ throw DeviceLibraryError.deviceNotFound
+ }
+
+ devices[index].name = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ devices[index].ipAddress = ipAddress.trimmingCharacters(in: .whitespacesAndNewlines)
+ devices[index].lastUpdated = Date()
+ if activeDeviceID == device.id {
+ UserDefaults.standard.set(devices[index].ipAddress, forKey: "TunnelDeviceIP")
+ }
+
+ if let pairingData {
+ let relativePath = try persistPairingData(pairingData, for: device.id)
+ devices[index].pairingRelativePath = relativePath
+ if let originalFilename {
+ devices[index].pairingFilename = originalFilename
+ }
+ }
+
+ persistDevices()
+ }
+
+ func remove(device: DeviceProfileEntry) throws {
+ guard !isDefaultDevice(device) else { return }
+ guard let index = devices.firstIndex(where: { $0.id == device.id }) else {
+ throw DeviceLibraryError.deviceNotFound
+ }
+
+ let relativePath = devices[index].pairingRelativePath
+ let storedURL = pairingsDirectory.appendingPathComponent(relativePath)
+ if fileManager.fileExists(atPath: storedURL.path) {
+ try? fileManager.removeItem(at: storedURL)
+ }
+
+ devices.remove(at: index)
+ if activeDeviceID == device.id {
+ clearActiveDevice()
+ }
+ persistDevices()
+ }
+
+ func activate(device: DeviceProfileEntry) throws {
+ if isDefaultDevice(device) {
+ clearActiveDevice()
+ return
+ }
+ let storedURL = pairingsDirectory.appendingPathComponent(device.pairingRelativePath)
+ guard fileManager.fileExists(atPath: storedURL.path) else {
+ throw DeviceLibraryError.pairingFileUnavailable
+ }
+ let destinationURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
+ if fileManager.fileExists(atPath: destinationURL.path) {
+ try? fileManager.removeItem(at: destinationURL)
+ }
+ try fileManager.copyItem(at: storedURL, to: destinationURL)
+ try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destinationURL.path)
+
+ UserDefaults.standard.set(device.ipAddress, forKey: "TunnelDeviceIP")
+ activeDeviceID = device.id
+ UserDefaults.standard.set(device.id.uuidString, forKey: activeDeviceKey)
+ UserDefaults.standard.set(true, forKey: UserDefaults.Keys.usingExternalDevice)
+ persistDevices()
+ updateExternalDeviceFlag()
+ }
+
+ func clearActiveDevice() {
+ activeDeviceID = nil
+ UserDefaults.standard.removeObject(forKey: activeDeviceKey)
+ UserDefaults.standard.set(false, forKey: UserDefaults.Keys.usingExternalDevice)
+ UserDefaults.standard.removeObject(forKey: "TunnelDeviceIP")
+ updateExternalDeviceFlag()
+ }
+
+ // MARK: - Persistence
+
+ private func createDirectoriesIfNeeded() {
+ do {
+ if !fileManager.fileExists(atPath: baseDirectory.path) {
+ try fileManager.createDirectory(at: baseDirectory, withIntermediateDirectories: true)
+ }
+ if !fileManager.fileExists(atPath: pairingsDirectory.path) {
+ try fileManager.createDirectory(at: pairingsDirectory, withIntermediateDirectories: true)
+ }
+ } catch {
+ print("Failed to create DeviceLibrary directories: \(error)")
+ }
+ }
+
+ private func persistPairingData(_ data: Data, for id: UUID) throws -> String {
+ createDirectoriesIfNeeded()
+ let filename = "\(id.uuidString).mobiledevicepairing"
+ let destination = pairingsDirectory.appendingPathComponent(filename)
+ if fileManager.fileExists(atPath: destination.path) {
+ try fileManager.removeItem(at: destination)
+ }
+ do {
+ try data.write(to: destination, options: .atomic)
+ try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path)
+ } catch {
+ throw DeviceLibraryError.fileOperationFailed("Unable to store pairing file. \(error.localizedDescription)")
+ }
+ return filename
+ }
+
+ private func loadFromDisk() {
+ guard fileManager.fileExists(atPath: storageURL.path) else {
+ devices = []
+ return
+ }
+ do {
+ let data = try Data(contentsOf: storageURL)
+ let decoded = try JSONDecoder().decode([DeviceProfileEntry].self, from: data)
+ devices = decoded
+ } catch {
+ print("Failed to load device library: \(error)")
+ devices = []
+ }
+ if let activeDeviceID, !devices.contains(where: { $0.id == activeDeviceID }) {
+ clearActiveDevice()
+ }
+ updateExternalDeviceFlag()
+ }
+
+ private func persistDevices() {
+ do {
+ createDirectoriesIfNeeded()
+ let data = try JSONEncoder().encode(devices)
+ try data.write(to: storageURL, options: .atomic)
+ } catch {
+ print("Failed to save device library: \(error)")
+ }
+ }
+
+ private func updateExternalDeviceFlag() {
+ UserDefaults.standard.set(isUsingExternalDevice, forKey: UserDefaults.Keys.usingExternalDevice)
+ }
+
+ func isDefaultDevice(_ device: DeviceProfileEntry) -> Bool {
+ device.id == localLoopbackID
+ }
+}
diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift
index 55e50ff..4104bc8 100644
--- a/StikJIT/Utilities/Extensions.swift
+++ b/StikJIT/Utilities/Extensions.swift
@@ -25,5 +25,7 @@ extension UserDefaults {
static let txmOverride = "overrideTXMForScripts"
/// Controls whether BGContinuedProcessingTask should be used to keep scripts alive in the background.
static let enableContinuedProcessing = "enableContinuedProcessing"
+ /// Tracks whether an external device profile is currently active.
+ static let usingExternalDevice = "UsingExternalDevice"
}
}
diff --git a/StikJIT/Utilities/FeatureFlags.swift b/StikJIT/Utilities/FeatureFlags.swift
index 547b761..3ceec3b 100644
--- a/StikJIT/Utilities/FeatureFlags.swift
+++ b/StikJIT/Utilities/FeatureFlags.swift
@@ -3,4 +3,8 @@ import Foundation
enum FeatureFlags {
/// Global toggle for exposing location spoofing UI/logic.
static let isLocationSpoofingEnabled = false
+ /// Controls visibility of beta-quality tabs and UI.
+ static let showBetaTabs = true
+ /// Forces the Theme Expansion to be unlocked and visible everywhere.
+ static let alwaysUnlockThemeExpansion = true
}
diff --git a/StikJIT/Utilities/TabConfiguration.swift b/StikJIT/Utilities/TabConfiguration.swift
index ca2449f..3865377 100644
--- a/StikJIT/Utilities/TabConfiguration.swift
+++ b/StikJIT/Utilities/TabConfiguration.swift
@@ -3,11 +3,15 @@ import Foundation
enum TabConfiguration {
static let storageKey = "enabledTabIdentifiers"
static let maxSelectableTabs = 4
- private static let baseAllowedIDs: [String] = ["home", "console", "scripts", "profiles", "processes", "deviceinfo"]
+ private static let coreIDs: [String] = ["home", "console", "scripts", "profiles", "deviceinfo"]
static var allowedIDs: [String] {
- var ids = baseAllowedIDs
- if FeatureFlags.isLocationSpoofingEnabled {
- ids.append("location")
+ var ids = coreIDs
+ if FeatureFlags.showBetaTabs {
+ ids.append("processes")
+ ids.append("devicelibrary")
+ if FeatureFlags.isLocationSpoofingEnabled {
+ ids.append("location")
+ }
}
return ids
}
diff --git a/StikJIT/Utilities/ThemeExpansionManager.swift b/StikJIT/Utilities/ThemeExpansionManager.swift
index 197fce6..1259653 100644
--- a/StikJIT/Utilities/ThemeExpansionManager.swift
+++ b/StikJIT/Utilities/ThemeExpansionManager.swift
@@ -87,6 +87,7 @@ final class ThemeExpansionManager: ObservableObject {
@Published private(set) var distributor: DistributorType
var isAppStoreBuild: Bool { distributor == .appStore }
var shouldShowThemeExpansionUpsell: Bool {
+ guard !isForcedUnlocked else { return false }
guard isAppStoreBuild else { return false }
if let lastError, lastError == Self.unavailableMessage {
return false
@@ -96,15 +97,17 @@ final class ThemeExpansionManager: ObservableObject {
private var updatesTask: Task?
private let isPreviewInstance: Bool
+ private let isForcedUnlocked: Bool
private let customThemesKey = "ThemeExpansion.CustomThemes"
init(previewUnlocked: Bool = false) {
self.isPreviewInstance = previewUnlocked
+ self.isForcedUnlocked = FeatureFlags.alwaysUnlockThemeExpansion
self.distributor = ThemeExpansionManager.detectDistributor()
- self.hasThemeExpansion = previewUnlocked
+ self.hasThemeExpansion = previewUnlocked || isForcedUnlocked
loadCustomThemes()
- if previewUnlocked && customThemes.isEmpty {
+ if (previewUnlocked || isForcedUnlocked) && customThemes.isEmpty {
customThemes = [
CustomTheme(name: "Vapor Trail",
style: .animatedGradient,
@@ -113,7 +116,7 @@ final class ThemeExpansionManager: ObservableObject {
]
}
- guard !previewUnlocked else { return }
+ guard !(previewUnlocked || isForcedUnlocked) else { return }
// Only wire StoreKit listeners if this is an App Store build
if isAppStoreBuild {
@@ -141,6 +144,12 @@ final class ThemeExpansionManager: ObservableObject {
func refreshEntitlements() async {
guard !isPreviewInstance else { return }
+ guard !isForcedUnlocked else {
+ hasThemeExpansion = true
+ themeExpansionProduct = nil
+ lastError = nil
+ return
+ }
guard isAppStoreBuild else { return } // No-op outside App Store
isProcessing = true
@@ -171,6 +180,10 @@ final class ThemeExpansionManager: ObservableObject {
func restorePurchases() async {
guard !isPreviewInstance else { return }
+ guard !isForcedUnlocked else {
+ lastError = nil
+ return
+ }
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
@@ -188,6 +201,11 @@ final class ThemeExpansionManager: ObservableObject {
func purchaseThemeExpansion() async {
guard !isPreviewInstance else { return }
+ guard !isForcedUnlocked else {
+ lastError = nil
+ hasThemeExpansion = true
+ return
+ }
guard isAppStoreBuild else {
lastError = Self.comingSoonMessage
return
diff --git a/StikJIT/Utilities/mountDDI.swift b/StikJIT/Utilities/mountDDI.swift
index 17a76d5..f488347 100644
--- a/StikJIT/Utilities/mountDDI.swift
+++ b/StikJIT/Utilities/mountDDI.swift
@@ -47,8 +47,10 @@ func htons(_ value: UInt16) -> UInt16 {
}
func isMounted() -> Bool {
- guard TunnelManager.shared.tunnelStatus == .connected else {
- return false
+ if DeviceConnectionContext.requiresLoopbackVPN {
+ guard TunnelManager.shared.tunnelStatus == .connected else {
+ return false
+ }
}
var addr = sockaddr_in()
@@ -59,7 +61,7 @@ func isMounted() -> Bool {
let pairingFilePath = URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path
- guard inet_pton(AF_INET, "10.7.0.1", &addr.sin_addr) == 1 else {
+ guard inet_pton(AF_INET, DeviceConnectionContext.targetIPAddress, &addr.sin_addr) == 1 else {
print("Invalid IP address")
return false
}
diff --git a/StikJIT/Views/DeviceLibraryView.swift b/StikJIT/Views/DeviceLibraryView.swift
new file mode 100644
index 0000000..f9d0c0d
--- /dev/null
+++ b/StikJIT/Views/DeviceLibraryView.swift
@@ -0,0 +1,503 @@
+//
+// DeviceLibraryView.swift
+// StikJIT
+//
+// Created by Stephen.
+//
+
+import SwiftUI
+import UniformTypeIdentifiers
+
+private struct DeviceAlert: Identifiable {
+ let id = UUID()
+ let title: String
+ let message: String
+ let isError: Bool
+}
+
+private enum DeviceEditorMode: Identifiable {
+ case add
+ case edit(DeviceProfileEntry)
+
+ var id: String {
+ switch self {
+ case .add: return "add"
+ case .edit(let device): return device.id.uuidString
+ }
+ }
+}
+
+struct DeviceLibraryView: View {
+ @StateObject private var store = DeviceLibraryStore.shared
+ @State private var editorMode: DeviceEditorMode?
+ @State private var alert: DeviceAlert?
+ @State private var isActivatingDevice = false
+
+ @AppStorage("customAccentColor") private var customAccentColorHex: String = ""
+ @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) }
+ private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
+
+ private var savedDevices: [DeviceProfileEntry] {
+ store.devices.sorted { lhs, rhs in
+ lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ }
+
+ private var displayedDevices: [DeviceProfileEntry] {
+ [store.defaultLocalDevice] + savedDevices
+ }
+
+ private var activeSubtitle: String {
+ store.activeDevice != nil
+ ? "Currently using an external device."
+ : "No external device selected."
+ }
+
+ var body: some View {
+ NavigationStack {
+ ZStack {
+ ThemedBackground(style: backgroundStyle)
+ .ignoresSafeArea()
+
+ ScrollView {
+ VStack(spacing: 20) {
+ fullWidthCard {
+ VStack(alignment: .leading, spacing: 16) {
+ headerRow
+ activeSummaryCard
+
+ ForEach(displayedDevices) { device in
+ let isDefault = store.isDefaultDevice(device)
+ DeviceRow(device: device,
+ isActive: isDefault ? !store.isUsingExternalDevice : store.activeDeviceID == device.id,
+ isDefault: isDefault,
+ accentColor: accentColor,
+ onActivate: { activate(device: device) },
+ onEdit: isDefault ? nil : { editorMode = .edit(device) },
+ onDelete: isDefault ? nil : { delete(device: device) })
+ if device.id != displayedDevices.last?.id {
+ Divider()
+ }
+ }
+
+ footerText
+ }
+ }
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 30)
+ }
+ }
+ .navigationTitle("Devices")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ editorMode = .add
+ } label: {
+ Label("Add Device", systemImage: "plus")
+ }
+ }
+ }
+ }
+ .tint(accentColor)
+ .preferredColorScheme(preferredScheme)
+ .disabled(isActivatingDevice)
+ .sheet(item: $editorMode) { mode in
+ DeviceEditorSheet(mode: mode) { input in
+ try handleSave(mode: mode, input: input)
+ if case .edit(let originalDevice) = mode,
+ store.activeDeviceID == originalDevice.id,
+ input.pairingData != nil {
+ if let refreshedDevice = store.devices.first(where: { $0.id == originalDevice.id }) {
+ do {
+ try store.activate(device: refreshedDevice)
+ startHeartbeatInBackground(requireVPNConnection: false)
+ alert = DeviceAlert(title: "Pairing Updated",
+ message: "\(refreshedDevice.name)'s pairing file was refreshed.",
+ isError: false)
+ } catch {
+ alert = DeviceAlert(title: "Activation Failed",
+ message: error.localizedDescription,
+ isError: true)
+ }
+ }
+ }
+ }
+ }
+ .alert(item: $alert) { alert in
+ Alert(
+ title: Text(alert.title),
+ message: Text(alert.message),
+ dismissButton: .default(Text("OK"))
+ )
+ }
+ }
+
+ private func handleSave(mode: DeviceEditorMode, input: DeviceEditorInput) throws {
+ switch mode {
+ case .add:
+ try store.addDevice(name: input.name, ipAddress: input.ipAddress, pairingData: input.pairingData, originalFilename: input.pairingFilename)
+ alert = DeviceAlert(title: "Device Saved", message: "\(input.name) was added to your library.", isError: false)
+ case .edit(let device):
+ try store.update(device: device,
+ name: input.name,
+ ipAddress: input.ipAddress,
+ pairingData: input.pairingData,
+ originalFilename: input.pairingFilename ?? device.pairingFilename)
+ alert = DeviceAlert(title: "Device Updated", message: "\(input.name) has been updated.", isError: false)
+ }
+ }
+
+ private func activate(device: DeviceProfileEntry) {
+ guard !isActivatingDevice else { return }
+ isActivatingDevice = true
+ defer { isActivatingDevice = false }
+ let activatingDefault = store.isDefaultDevice(device)
+ do {
+ try store.activate(device: device)
+ startHeartbeatInBackground(requireVPNConnection: false)
+ let title = activatingDefault ? "Loopback Active" : "Device Activated"
+ let message = activatingDefault
+ ? "Switched back to debugging this device over the loopback VPN."
+ : "\(device.name) is now active. The heartbeat will refresh automatically."
+ alert = DeviceAlert(title: title, message: message, isError: false)
+ } catch {
+ alert = DeviceAlert(title: "Activation Failed",
+ message: error.localizedDescription,
+ isError: true)
+ }
+ }
+
+ private func delete(device: DeviceProfileEntry) {
+ do {
+ try store.remove(device: device)
+ } catch {
+ alert = DeviceAlert(title: "Delete Failed", message: error.localizedDescription, isError: true)
+ }
+ }
+
+ @ViewBuilder
+ private func fullWidthCard(@ViewBuilder _ content: () -> Content) -> some View {
+ appGlassCard {
+ content()
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private var headerRow: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .firstTextBaseline) {
+ VStack(alignment: .leading, spacing: 4) {
+
+ Text("Device Library")
+ .font(.system(.title, design: .rounded).weight(.bold))
+ }
+ Spacer()
+ }
+
+ Text("Manage saved targets and switch between local or remote hardware.")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ private var activeSummaryCard: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label {
+ Text(activeSubtitle)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ } icon: {
+ Image(systemName: store.isUsingExternalDevice ? "antenna.radiowaves.left.and.right" : "iphone")
+ .foregroundColor(accentColor)
+ }
+
+ if let active = store.activeDevice {
+ let relative = relativeDateFormatter.localizedString(for: active.lastUpdated, relativeTo: Date())
+ VStack(alignment: .leading, spacing: 4) {
+ Text(active.name)
+ .font(.headline)
+ Text("IP: \(active.ipAddress)")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ Text("Synced \(relative).")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ Button {
+ activate(device: store.defaultLocalDevice)
+ } label: {
+ Label("Switch to This Device", systemImage: "arrow.uturn.backward")
+ .font(.footnote.weight(.semibold))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .buttonStyle(.borderedProminent)
+ .tint(accentColor)
+ } else {
+ Text("Using the on-device pairing file. Saved devices appear below for quick switching.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ .padding(16)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(Color.primary.opacity(0.05))
+ )
+ }
+
+ private var footerText: some View {
+ Text("Saved devices keep their own pairing files so you can connect without copying them manually.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var relativeDateFormatter: RelativeDateTimeFormatter {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .full
+ return formatter
+ }
+}
+
+private struct DeviceRow: View {
+ let device: DeviceProfileEntry
+ let isActive: Bool
+ let isDefault: Bool
+ let accentColor: Color
+ let onActivate: () -> Void
+ let onEdit: (() -> Void)?
+ let onDelete: (() -> Void)?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(device.name)
+ .font(.headline)
+ if isActive {
+ Label("Active", systemImage: "checkmark.circle.fill")
+ .font(.caption.weight(.semibold))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(accentColor.opacity(0.15), in: Capsule())
+ .foregroundColor(accentColor)
+ }
+ }
+ if isDefault {
+ Text("Loopback IP: \(device.ipAddress)")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ Text("Uses the pairing file already stored on this device.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ } else {
+ Text("IP: \(device.ipAddress)")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ Text("Pairing: \(device.pairingFilename)")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ Spacer()
+ if onEdit != nil || onDelete != nil {
+ Menu {
+ Button("Use Device", action: onActivate)
+ if let onEdit {
+ Button("Edit Details", action: onEdit)
+ }
+ if let onDelete {
+ Button(role: .destructive, action: onDelete) {
+ Text("Delete Device")
+ }
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .font(.title3)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ Button(action: onActivate) {
+ let title = isActive ? "Active" : (isDefault ? "Use Loopback" : "Use This Device")
+ HStack {
+ Image(systemName: isDefault ? "iphone" : "bolt.fill")
+ Text(title).fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(isActive ? Color.gray.opacity(0.2) : accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .foregroundColor(isActive ? .secondary : accentColor)
+ }
+ .disabled(isActive)
+ }
+ }
+}
+
+// MARK: - Editor Sheet
+
+private struct DeviceEditorInput {
+ var name: String
+ var ipAddress: String
+ var pairingData: Data?
+ var pairingFilename: String?
+}
+
+private struct DeviceEditorSheet: View {
+ let mode: DeviceEditorMode
+ let onSave: (DeviceEditorInput) throws -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @AppStorage("customAccentColor") private var customAccentColorHex: String = ""
+ @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
+ @Environment(\.themeExpansionManager) private var themeExpansion
+
+ @State private var name: String
+ @State private var ipAddress: String
+ @State private var pairingData: Data?
+ @State private var selectedFilename: String?
+ @State private var errorMessage: String?
+ @State private var showImporter = false
+
+ private var accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
+ private var requiresPairing: Bool {
+ if case .add = mode { return true }
+ return false
+ }
+ private var existingFilename: String? {
+ if case .edit(let device) = mode {
+ return device.pairingFilename
+ }
+ return nil
+ }
+ private var title: String {
+ switch mode {
+ case .add: return "New Device"
+ case .edit: return "Edit Device"
+ }
+ }
+ private var canSave: Bool {
+ !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !ipAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ (!requiresPairing || pairingData != nil)
+ }
+
+ init(mode: DeviceEditorMode, onSave: @escaping (DeviceEditorInput) throws -> Void) {
+ self.mode = mode
+ self.onSave = onSave
+ switch mode {
+ case .add:
+ _name = State(initialValue: "")
+ _ipAddress = State(initialValue: "10.7.0.1")
+ case .edit(let device):
+ _name = State(initialValue: device.name)
+ _ipAddress = State(initialValue: device.ipAddress)
+ }
+ _selectedFilename = State(initialValue: nil)
+ _pairingData = State(initialValue: nil)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section(header: Text("Details")) {
+ TextField("Display Name", text: $name)
+ .textInputAutocapitalization(.words)
+ TextField("Device IP", text: $ipAddress)
+ .keyboardType(.numbersAndPunctuation)
+ }
+
+ Section(header: Text("Pairing File")) {
+ Button {
+ showImporter = true
+ } label: {
+ Label("Select Pairing File", systemImage: "doc.badge")
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(accentColor)
+
+ if let filename = selectedFilename {
+ Text("Selected: \(filename)")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ } else if let existing = existingFilename {
+ Text("Current: \(existing)")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ } else {
+ Text("No file selected")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ if let errorMessage {
+ Section {
+ Text(errorMessage)
+ .foregroundColor(.red)
+ }
+ }
+ }
+ .navigationTitle(title)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save", action: save)
+ .disabled(!canSave)
+ }
+ }
+ .fileImporter(
+ isPresented: $showImporter,
+ allowedContentTypes: [
+ UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!,
+ .propertyList
+ ],
+ allowsMultipleSelection: false
+ ) { result in
+ switch result {
+ case .success(let urls):
+ guard let url = urls.first else { return }
+ let accessing = url.startAccessingSecurityScopedResource()
+ defer {
+ if accessing { url.stopAccessingSecurityScopedResource() }
+ }
+ do {
+ pairingData = try Data(contentsOf: url)
+ selectedFilename = url.lastPathComponent
+ errorMessage = nil
+ } catch {
+ errorMessage = "Failed to read file: \(error.localizedDescription)"
+ }
+ case .failure(let error):
+ errorMessage = error.localizedDescription
+ }
+ }
+ }
+ .tint(accentColor)
+ }
+
+ private func save() {
+ guard canSave else { return }
+ do {
+ try onSave(DeviceEditorInput(
+ name: name,
+ ipAddress: ipAddress,
+ pairingData: pairingData,
+ pairingFilename: selectedFilename ?? existingFilename
+ ))
+ dismiss()
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+}
diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift
index e29ab5d..b38056a 100644
--- a/StikJIT/Views/HomeView.swift
+++ b/StikJIT/Views/HomeView.swift
@@ -20,7 +20,7 @@ struct JITEnableConfiguration {
}
struct HomeView: View {
-
+
@AppStorage("username") private var username = "User"
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
@Environment(\.colorScheme) private var colorScheme
@@ -48,13 +48,14 @@ struct HomeView: View {
@State private var viewDidAppeared = false
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
-
+
@State var scriptViewShow = false
@AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js"
@State var jsModel: RunJSViewModel?
@StateObject private var tunnel = TunnelManager.shared
@ObservedObject private var mounting = MountingProgress.shared
+ @ObservedObject private var deviceStore = DeviceLibraryStore.shared
@State private var heartbeatOK = false
@State private var cachedAppNames: [String: String] = [:]
@AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = []
@@ -72,18 +73,19 @@ struct HomeView: View {
@State private var isSchedulingInitialSetup = false
@AppStorage("cachedAppNamesData") private var cachedAppNamesData: Data?
@AppStorage("autoStartVPN") private var autoStartVPN = true
-
+
@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) }
-
+
private var accentColor: Color {
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
}
-
+
private var ddiMounted: Bool { mounting.coolisMounted }
private var canConnectByApp: Bool { pairingFileExists && ddiMounted }
+ private var requiresLoopbackVPN: Bool { !deviceStore.isUsingExternalDevice }
private var pairingFileLikelyInvalid: Bool {
(pairingFileExists || pairingFilePresentOnDisk) &&
!isValidatingPairingFile &&
@@ -119,9 +121,9 @@ struct HomeView: View {
private var shouldPromptForWiFi: Bool {
pairingFileLikelyInvalid && !wifiConnected && isCellularActive
}
-
+
private let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
-
+
var body: some View {
NavigationStack {
ZStack {
@@ -133,9 +135,9 @@ struct HomeView: View {
welcomeCard
setupCard
connectCard
- // if pairingFileExists {
- // quickConnectCard
- // }
+ // if pairingFileExists {
+ // quickConnectCard
+ // }
if !pinnedLaunchItems.isEmpty {
launchShortcutsCard
}
@@ -145,7 +147,7 @@ struct HomeView: View {
.padding(.vertical, 30)
}
.scrollIndicators(.hidden)
-
+
if isImportingFile {
Color.black.opacity(0.35).ignoresSafeArea()
ProgressView("Processing pairing file…")
@@ -160,7 +162,7 @@ struct HomeView: View {
)
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
-
+
if showPairingFileMessage && pairingFileIsValid && !isImportingFile {
toast("✓ Pairing file successfully imported")
}
@@ -178,7 +180,7 @@ struct HomeView: View {
scheduleInitialSetupWork()
startWiFiMonitoring()
startCellularMonitoring()
- if autoStartVPN && tunnel.tunnelStatus == .disconnected {
+ if requiresLoopbackVPN && autoStartVPN && tunnel.tunnelStatus == .disconnected {
TunnelManager.shared.startVPN()
}
if !hasAutoStartedConnectionCheck {
@@ -212,6 +214,7 @@ struct HomeView: View {
}
}
.onChange(of: tunnel.tunnelStatus) { _, newStatus in
+ guard requiresLoopbackVPN else { return }
if newStatus == .connected {
loadAppListIfNeeded(force: cachedAppNames.isEmpty)
runConnectionDiagnostics()
@@ -359,8 +362,8 @@ struct HomeView: View {
let nameRaw = pinnedSystemAppNames[bundleId] ?? friendlyName(for: bundleId)
let name = shortDisplayName(from: nameRaw)
systemLaunchMessage = success
- ? String(format: "Launch requested: %@".localized, name)
- : String(format: "Failed to launch %@".localized, name)
+ ? String(format: "Launch requested: %@".localized, name)
+ : String(format: "Failed to launch %@".localized, name)
scheduleSystemToastDismiss()
}
}
@@ -393,7 +396,7 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
}
-
+
private var setupCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 16) {
@@ -403,22 +406,22 @@ struct HomeView: View {
Spacer()
connectionStatusBadge
}
-
+
statusLightsRow
-
+
vpnControls
-
+
}
}
}
-
+
private var connectCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 16) {
Text("Connect")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
-
+
if shouldPromptForWiFi {
statusBadge(
icon: "wifi.slash",
@@ -432,15 +435,15 @@ struct HomeView: View {
color: .red
)
}
-
+
primaryActionControls
-
+
if let info = connectionInfoMessage, !info.isEmpty {
Text(info)
.font(.caption)
.foregroundStyle(.secondary)
}
-
+
if isImportingFile {
pairingImportProgressView
} else if showPairingFileMessage && pairingFileIsValid {
@@ -449,9 +452,9 @@ struct HomeView: View {
}
}
}
-
+
// MARK: - Connection Setup Helpers
-
+
@ViewBuilder
private var connectionStatusBadge: some View {
if isConnectionCheckRunning {
@@ -464,7 +467,7 @@ struct HomeView: View {
statusBadge(icon: "circle.lefthalf.filled", text: "Not ready", color: .yellow)
}
}
-
+
private func statusBadge(icon: String, text: String, color: Color) -> some View {
Label(text, systemImage: icon)
.font(.footnote.weight(.semibold))
@@ -476,27 +479,35 @@ struct HomeView: View {
)
.foregroundStyle(color)
}
-
+
private var connectionHasError: Bool {
if case .failure = connectionCheckState { return true }
if case .timeout = connectionCheckState { return true }
return false
}
-
+
private var allStatusIndicatorsGreen: Bool {
ddiIndicatorStatus == .success &&
wifiIndicatorStatus == .success &&
heartbeatIndicatorStatus == .success
}
-
+
private var statusLightsRow: some View {
HStack(spacing: 12) {
ForEach(statusLights) { light in
- StatusLightView(light: light)
+ if let action = light.action {
+ Button(action: action) {
+ StatusLightView(light: light)
+ }
+ .buttonStyle(.plain)
+ .disabled(!light.isEnabled)
+ } else {
+ StatusLightView(light: light)
+ }
}
}
}
-
+
private var statusLights: [StatusLightData] {
[
StatusLightData(
@@ -519,40 +530,69 @@ struct HomeView: View {
icon: "waveform.path.ecg",
status: heartbeatIndicatorStatus,
detail: heartbeatDetailText
+ ),
+ StatusLightData(
+ type: .refresh,
+ title: "Refresh",
+ icon: "arrow.clockwise",
+ status: refreshIndicatorStatus,
+ detail: "",
+ action: refreshStatusTapped,
+ isEnabled: !isConnectionCheckRunning,
+ indicatorIconName: "arrow.clockwise",
+ indicatorColor: .blue,
+ tintOverride: .blue
)
]
}
-
+
private var ddiDetailText: String {
if ddiMounted { return "Mounted" }
if pairingFileLikelyInvalid { return "Error" }
return pairingFileExists ? "Mount required" : "Not ready"
}
-
+
private var wifiDetailText: String {
if isConnectionCheckRunning { return "Checking…" }
return wifiConnected ? "Connected" : "Offline"
}
-
+
private var heartbeatDetailText: String {
if heartbeatOK { return "Active" }
if pairingFileExists {
- return tunnel.tunnelStatus == .connected ? "Waiting" : "VPN required"
+ if requiresLoopbackVPN {
+ return tunnel.tunnelStatus == .connected ? "Waiting" : "VPN required"
+ } else {
+ return "Waiting"
+ }
}
return "Pair first"
}
+ private var refreshIndicatorStatus: StartupIndicatorStatus {
+ switch connectionCheckState {
+ case .running:
+ return .running
+ case .success:
+ return .success
+ case .failure, .timeout:
+ return .warning
+ case .idle:
+ return .idle
+ }
+ }
+
private var ddiIndicatorStatus: StartupIndicatorStatus {
if ddiMounted { return .success }
if pairingFileLikelyInvalid { return .warning }
if pairingFileExists { return .warning }
return .idle
}
-
+
private func color(for indicator: StartupIndicatorStatus) -> Color {
indicator.tint
}
-
+
private func startWiFiMonitoring() {
guard wifiMonitor == nil else { return }
let monitor = NWPathMonitor(requiredInterfaceType: .wifi)
@@ -564,12 +604,12 @@ struct HomeView: View {
}
monitor.start(queue: DispatchQueue.global(qos: .utility))
}
-
+
private func stopWiFiMonitoring() {
wifiMonitor?.cancel()
wifiMonitor = nil
}
-
+
private func startCellularMonitoring() {
guard cellularMonitor == nil else { return }
let monitor = NWPathMonitor(requiredInterfaceType: .cellular)
@@ -581,13 +621,13 @@ struct HomeView: View {
}
monitor.start(queue: DispatchQueue.global(qos: .utility))
}
-
+
private func stopCellularMonitoring() {
cellularMonitor?.cancel()
cellularMonitor = nil
isCellularActive = false
}
-
+
private var pairingStatusDescription: String {
if isValidatingPairingFile { return "Validating pairing file…" }
if pairingFileExists {
@@ -598,18 +638,21 @@ struct HomeView: View {
}
return "Import the pairing file generated from your trusted computer."
}
-
+
private var wifiStatusDescription: String {
if isConnectionCheckRunning { return "Checking Wi-Fi status…" }
return wifiConnected ? "Wi-Fi connected and ready." : "Connect to Wi-Fi."
}
-
+
private var isConnectionCheckRunning: Bool {
if case .running = connectionCheckState { return true }
return false
}
-
+
private var vpnStatusSubtitle: String {
+ if !requiresLoopbackVPN {
+ return "External device selected. VPN tunnel not required."
+ }
if isConnectionCheckRunning {
return "Checking the VPN/loopback tunnel…"
}
@@ -626,8 +669,9 @@ struct HomeView: View {
return "VPN configuration error. Try reconnecting."
}
}
-
+
private var vpnIndicatorStatus: StartupIndicatorStatus {
+ if !requiresLoopbackVPN { return .success }
if isConnectionCheckRunning { return .running }
switch tunnel.tunnelStatus {
case .connected:
@@ -640,16 +684,19 @@ struct HomeView: View {
return .error
}
}
-
+
private var wifiIndicatorStatus: StartupIndicatorStatus {
if isConnectionCheckRunning { return .running }
return wifiConnected ? .success : .warning
}
-
+
private var heartbeatSubtitle: String {
if heartbeatOK {
return "Heartbeat is responding."
}
+ if !requiresLoopbackVPN && pairingFileExists {
+ return "Waiting for the remote device to respond."
+ }
if pairingFileLikelyInvalid {
return "Heartbeat is blocked because the pairing file looks invalid."
}
@@ -664,7 +711,7 @@ struct HomeView: View {
}
return "Heartbeat runs after the connection check completes."
}
-
+
private var heartbeatIndicatorStatus: StartupIndicatorStatus {
if heartbeatOK { return .success }
if pairingFileLikelyInvalid { return .warning }
@@ -673,7 +720,7 @@ struct HomeView: View {
if case .success = connectionCheckState { return .warning }
return .idle
}
-
+
private var connectionCheckButtonLabel: some View {
compactControlButton(
icon: "waveform.path.ecg",
@@ -681,7 +728,7 @@ struct HomeView: View {
showSpinner: isConnectionCheckRunning
)
}
-
+
private var primaryActionControls: some View {
VStack(spacing: 8) {
Button(action: primaryActionTapped) {
@@ -692,7 +739,7 @@ struct HomeView: View {
)
}
.disabled(isProcessing || isValidatingPairingFile)
-
+
if pairingFileExists && enableAdvancedOptions && !pairingFileLikelyInvalid && primaryActionTitle == "Connect by App" {
Button(action: { showPIDSheet = true }) {
secondaryButtonLabel(icon: "number.circle", title: "Connect by PID")
@@ -701,1344 +748,1402 @@ struct HomeView: View {
}
}
}
-
+
+ @ViewBuilder
private var vpnControls: some View {
- VStack(alignment: .leading, spacing: 10) {
- HStack {
- Text("VPN Tunnel")
- .font(.subheadline.weight(.semibold))
- .foregroundStyle(.primary)
- Spacer()
- statusBadge(icon: "shield.lefthalf.filled", text: tunnel.tunnelStatus.rawValue, color: vpnStatusColor)
- }
-
- Text(vpnStatusSubtitle)
- .font(.footnote)
- .foregroundStyle(.secondary)
-
- HStack(spacing: 8) {
- Button(action: { TunnelManager.shared.startVPN() }) {
- compactControlButton(icon: "lock.open", title: "Connect")
- }
- .buttonStyle(.plain)
- .disabled(!canStartVPN)
-
- Button(action: { TunnelManager.shared.stopVPN() }) {
- compactControlButton(icon: "lock.fill", title: "Disconnect")
- }
- .buttonStyle(.plain)
- .disabled(!canStopVPN)
- }
-
- Button {
- autoStartVPN.toggle()
- if autoStartVPN && tunnel.tunnelStatus == .disconnected {
- TunnelManager.shared.startVPN()
- }
- } label: {
- compactControlButton(
- icon: autoStartVPN ? "lock.circle" : "lock.slash",
- title: autoStartVPN ? "Disable Auto VPN" : "Enable Auto VPN"
- )
- }
- .buttonStyle(.plain)
- }
- }
-
- private var vpnStatusColor: Color {
- switch tunnel.tunnelStatus {
- case .connected: return .green
- case .connecting, .disconnecting: return .orange
- case .error: return .red
- case .disconnected: return .yellow
- }
- }
-
- private var canStartVPN: Bool {
- switch tunnel.tunnelStatus {
- case .disconnected, .error:
- return true
- default:
- return false
- }
- }
-
- private var canStopVPN: Bool {
- switch tunnel.tunnelStatus {
- case .connected, .connecting, .disconnecting:
- return true
- default:
- return false
- }
- }
-
- private func runConnectionDiagnostics(autoStart: Bool = false) {
- guard !isConnectionCheckRunning else { return }
- connectionTimeoutTask?.cancel()
- connectionTimeoutTask = nil
- connectionInfoMessage = autoStart ? "Checking connection…" : nil
- connectionCheckState = .running
- let timeout = DispatchWorkItem {
- DispatchQueue.main.async {
- if case .running = connectionCheckState {
- connectionCheckState = .timeout
- connectionInfoMessage = "Connection timed out. Check the VPN and pairing file, then try again."
- connectionTimeoutTask = nil
- }
- }
- }
- connectionTimeoutTask = timeout
- DispatchQueue.main.asyncAfter(deadline: .now() + 7, execute: timeout)
-
- checkVPNConnection { success, error in
- connectionTimeoutTask?.cancel()
- connectionTimeoutTask = nil
- if success {
- connectionCheckState = .success
- connectionInfoMessage = nil
- if pairingFileExists && !heartbeatOK {
- startHeartbeatInBackground()
- }
- } else {
- connectionCheckState = .failure(error ?? "VPN tunnel is not connected.")
- connectionInfoMessage = error ?? "VPN tunnel is not connected."
- }
- }
- }
-
- private var primaryActionTitle: String {
- if isValidatingPairingFile { return "Validating…" }
- if !pairingFileExists { return pairingFilePresentOnDisk ? "Import New Pairing File" : "Import Pairing File" }
- if shouldPromptForWiFi { return "Connect to Wi-Fi" }
- if pairingFileLikelyInvalid { return "New Pairing File Needed" }
- if !ddiMounted { return "Mount Developer Disk Image" }
- return "Connect by App"
- }
-
- private var primaryActionIcon: String {
- if isValidatingPairingFile { return "hourglass" }
- if !pairingFileExists { return pairingFilePresentOnDisk ? "arrow.clockwise" : "doc.badge.plus" }
- if shouldPromptForWiFi { return "wifi.slash" }
- if pairingFileLikelyInvalid { return "arrow.clockwise" }
- if !ddiMounted { return "externaldrive" }
- return "cable.connector.horizontal"
- }
-
- private var pairingImportProgressView: some View {
- VStack(spacing: 8) {
- HStack {
- Text("Processing pairing file…")
- .font(.system(.caption, design: .rounded))
- .foregroundStyle(.secondary)
- Spacer()
- Text("\(Int(importProgress * 100))%")
- .font(.system(.caption, design: .rounded))
- .foregroundStyle(.secondary)
- }
-
- GeometryReader { geo in
- ZStack(alignment: .leading) {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(Color(UIColor.tertiarySystemFill))
- .frame(height: 8)
-
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .fill(accentColor)
- .frame(width: geo.size.width * CGFloat(importProgress), height: 8)
- .animation(.linear(duration: 0.25), value: importProgress)
- }
- }
- .frame(height: 8)
- }
- .accessibilityElement(children: .combine)
- }
-
- private var pairingSuccessMessage: some View {
- HStack(spacing: 10) {
- StatusDot(color: .green)
- Text("Pairing file successfully imported")
- .font(.system(.callout, design: .rounded))
- .foregroundStyle(.green)
- Spacer(minLength: 0)
- }
- .padding(.top, 4)
- .transition(.opacity)
- }
-
- private func whiteCardButtonLabel(icon: String, title: String, isLoading: Bool = false) -> some View {
- HStack(spacing: 10) {
- if isLoading {
- ProgressView()
- .progressViewStyle(.circular)
- .tint(accentColor.contrastText())
- .frame(width: 20, height: 20)
- } else {
- Image(systemName: icon)
- .font(.system(size: 20, weight: .semibold, design: .rounded))
- }
-
- Text(title)
- .font(.system(.title3, design: .rounded).weight(.semibold))
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 14)
- .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
- .foregroundColor(accentColor.contrastText())
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
- )
- .animation(.easeInOut(duration: 0.2), value: isLoading)
- }
-
- private func secondaryButtonLabel(icon: String, title: String) -> some View {
- HStack {
- Image(systemName: icon)
- .font(.system(size: 18, weight: .semibold, design: .rounded))
- Text(title)
- .font(.system(.title3, design: .rounded).weight(.semibold))
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 12)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.12), lineWidth: 1)
- )
- .foregroundStyle(.primary)
- }
-
- private var quickConnectCard: some View {
- homeCard {
- VStack(alignment: .leading, spacing: 14) {
- HStack(spacing: 8) {
- Text("Quick Connect")
- .font(.headline.weight(.semibold))
+ if !requiresLoopbackVPN {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("VPN Tunnel")
+ .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
-
+ Spacer()
+ statusBadge(icon: "checkmark.circle.fill", text: "Not needed", color: .green)
}
-
- Text("Favorites and recents stay within reach so you can enable debug with ease.")
+ Text("You’re targeting an external device. StikDebug connects directly to \(DeviceConnectionContext.targetIPAddress).")
.font(.footnote)
.foregroundStyle(.secondary)
-
- if quickConnectItems.isEmpty {
- VStack(alignment: .leading, spacing: 10) {
- Text("Pin apps from the Installed Apps list to see them here.")
- .font(.caption)
- .foregroundStyle(.secondary)
-
- Button {
- isShowingInstalledApps = true
- } label: {
- secondaryButtonLabel(icon: "star", title: "Choose Favorites")
- }
- .buttonStyle(.plain)
+ }
+ } else {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("VPN Tunnel")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ statusBadge(icon: "shield.lefthalf.filled", text: tunnel.tunnelStatus.rawValue, color: vpnStatusColor)
+ }
+
+ Text(vpnStatusSubtitle)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 8) {
+ Button(action: { TunnelManager.shared.startVPN() }) {
+ compactControlButton(icon: "lock.open", title: "Connect")
+ }
+ .buttonStyle(.plain)
+ .disabled(!canStartVPN)
+
+ Button(action: { TunnelManager.shared.stopVPN() }) {
+ compactControlButton(icon: "lock.fill", title: "Disconnect")
+ }
+ .buttonStyle(.plain)
+ .disabled(!canStopVPN)
+ }
+
+ Button {
+ autoStartVPN.toggle()
+ if requiresLoopbackVPN && autoStartVPN && tunnel.tunnelStatus == .disconnected {
+ TunnelManager.shared.startVPN()
+ }
+ } label: {
+ compactControlButton(
+ icon: autoStartVPN ? "lock.circle" : "lock.slash",
+ title: autoStartVPN ? "Disable Auto VPN" : "Enable Auto VPN"
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ private var vpnStatusColor: Color {
+ if !requiresLoopbackVPN { return .green }
+ switch tunnel.tunnelStatus {
+ case .connected: return .green
+ case .connecting, .disconnecting: return .orange
+ case .error: return .red
+ case .disconnected: return .yellow
+ }
+ }
+
+ private var canStartVPN: Bool {
+ guard requiresLoopbackVPN else { return false }
+ switch tunnel.tunnelStatus {
+ case .disconnected, .error:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private var canStopVPN: Bool {
+ guard requiresLoopbackVPN else { return false }
+ switch tunnel.tunnelStatus {
+ case .connected, .connecting, .disconnecting:
+ return true
+ default:
+ return false
+ }
+ }
+
+ private func refreshStatusTapped() {
+ runConnectionDiagnostics()
+ if pairingFileExists {
+ startHeartbeatInBackground(requireVPNConnection: requiresLoopbackVPN)
+ }
+ }
+
+ private func runConnectionDiagnostics(autoStart: Bool = false) {
+ guard !isConnectionCheckRunning else { return }
+ connectionTimeoutTask?.cancel()
+ connectionTimeoutTask = nil
+ connectionInfoMessage = autoStart ? "Checking connection…" : nil
+ connectionCheckState = .running
+ let timeout = DispatchWorkItem {
+ DispatchQueue.main.async {
+ if case .running = connectionCheckState {
+ connectionCheckState = .timeout
+ connectionInfoMessage = requiresLoopbackVPN
+ ? "Connection timed out. Check the VPN and pairing file, then try again."
+ : "Connection timed out. Make sure the device at \(DeviceConnectionContext.targetIPAddress) is reachable."
+ connectionTimeoutTask = nil
+ }
+ }
+ }
+ connectionTimeoutTask = timeout
+ DispatchQueue.main.asyncAfter(deadline: .now() + 7, execute: timeout)
+
+ checkVPNConnection { success, error in
+ connectionTimeoutTask?.cancel()
+ connectionTimeoutTask = nil
+ if success {
+ connectionCheckState = .success
+ connectionInfoMessage = nil
+ if pairingFileExists && !heartbeatOK {
+ startHeartbeatInBackground()
}
} else {
+ let fallback = requiresLoopbackVPN ? "VPN tunnel is not connected." : "Unable to reach the device."
+ connectionCheckState = .failure(error ?? fallback)
+ connectionInfoMessage = error ?? fallback
+ }
+ }
+ }
+
+ private var primaryActionTitle: String {
+ if isValidatingPairingFile { return "Validating…" }
+ if !pairingFileExists { return pairingFilePresentOnDisk ? "Import New Pairing File" : "Import Pairing File" }
+ if shouldPromptForWiFi { return "Connect to Wi-Fi" }
+ if pairingFileLikelyInvalid { return "New Pairing File Needed" }
+ if !ddiMounted { return "Mount Developer Disk Image" }
+ return "Connect by App"
+ }
+
+ private var primaryActionIcon: String {
+ if isValidatingPairingFile { return "hourglass" }
+ if !pairingFileExists { return pairingFilePresentOnDisk ? "arrow.clockwise" : "doc.badge.plus" }
+ if shouldPromptForWiFi { return "wifi.slash" }
+ if pairingFileLikelyInvalid { return "arrow.clockwise" }
+ if !ddiMounted { return "externaldrive" }
+ return "cable.connector.horizontal"
+ }
+
+ private var pairingImportProgressView: some View {
+ VStack(spacing: 8) {
+ HStack {
+ Text("Processing pairing file…")
+ .font(.system(.caption, design: .rounded))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(Int(importProgress * 100))%")
+ .font(.system(.caption, design: .rounded))
+ .foregroundStyle(.secondary)
+ }
+
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(Color(UIColor.tertiarySystemFill))
+ .frame(height: 8)
+
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(accentColor)
+ .frame(width: geo.size.width * CGFloat(importProgress), height: 8)
+ .animation(.linear(duration: 0.25), value: importProgress)
+ }
+ }
+ .frame(height: 8)
+ }
+ .accessibilityElement(children: .combine)
+ }
+
+ private var pairingSuccessMessage: some View {
+ HStack(spacing: 10) {
+ StatusDot(color: .green)
+ Text("Pairing file successfully imported")
+ .font(.system(.callout, design: .rounded))
+ .foregroundStyle(.green)
+ Spacer(minLength: 0)
+ }
+ .padding(.top, 4)
+ .transition(.opacity)
+ }
+
+ private func whiteCardButtonLabel(icon: String, title: String, isLoading: Bool = false) -> some View {
+ HStack(spacing: 10) {
+ if isLoading {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .tint(accentColor.contrastText())
+ .frame(width: 20, height: 20)
+ } else {
+ Image(systemName: icon)
+ .font(.system(size: 20, weight: .semibold, design: .rounded))
+ }
+
+ Text(title)
+ .font(.system(.title3, design: .rounded).weight(.semibold))
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .foregroundColor(accentColor.contrastText())
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
+ )
+ .animation(.easeInOut(duration: 0.2), value: isLoading)
+ }
+
+ private func secondaryButtonLabel(icon: String, title: String) -> some View {
+ HStack {
+ Image(systemName: icon)
+ .font(.system(size: 18, weight: .semibold, design: .rounded))
+ Text(title)
+ .font(.system(.title3, design: .rounded).weight(.semibold))
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.12), lineWidth: 1)
+ )
+ .foregroundStyle(.primary)
+ }
+
+ private var quickConnectCard: some View {
+ homeCard {
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(spacing: 8) {
+ Text("Quick Connect")
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.primary)
+
+ }
+
+ Text("Favorites and recents stay within reach so you can enable debug with ease.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
+ if quickConnectItems.isEmpty {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Pin apps from the Installed Apps list to see them here.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Button {
+ isShowingInstalledApps = true
+ } label: {
+ secondaryButtonLabel(icon: "star", title: "Choose Favorites")
+ }
+ .buttonStyle(.plain)
+ }
+ } else {
+ VStack(spacing: 10) {
+ ForEach(quickConnectItems) { item in
+ QuickConnectRow(
+ item: item,
+ accentColor: accentColor,
+ isEnabled: canConnectByApp && !isProcessing,
+ action: {
+ HapticFeedbackHelper.trigger()
+ let scriptInfo = preferredScript(for: item.bundleID)
+ startJITInBackground(bundleID: item.bundleID,
+ pid: nil,
+ scriptData: scriptInfo?.data,
+ scriptName: scriptInfo?.name,
+ triggeredByURLScheme: false)
+ }
+ )
+ }
+ }
+ }
+
+ if !canConnectByApp {
+ Text("Finish the pairing and mounting steps above to enable quick launches.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+
+ private var launchShortcutsCard: some View {
+ homeCard {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Launch Shortcuts".localized)
+ .font(.headline.weight(.semibold))
+ .foregroundStyle(.primary)
+
+ Text("Pin any app from Installed Apps and launch it here with ease.".localized)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+
VStack(spacing: 10) {
- ForEach(quickConnectItems) { item in
- QuickConnectRow(
+ ForEach(pinnedLaunchItems) { item in
+ SystemPinnedRow(
item: item,
accentColor: accentColor,
- isEnabled: canConnectByApp && !isProcessing,
- action: {
- HapticFeedbackHelper.trigger()
- let scriptInfo = preferredScript(for: item.bundleID)
- startJITInBackground(bundleID: item.bundleID,
- pid: nil,
- scriptData: scriptInfo?.data,
- scriptName: scriptInfo?.name,
- triggeredByURLScheme: false)
- }
+ isLaunching: launchingSystemApps.contains(item.bundleID),
+ action: { launchSystemApp(item: item) },
+ onRemove: { removePinnedSystemApp(bundleID: item.bundleID) }
)
}
}
}
-
- if !canConnectByApp {
- Text("Finish the pairing and mounting steps above to enable quick launches.")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
}
}
- }
-
- private var launchShortcutsCard: some View {
- homeCard {
- VStack(alignment: .leading, spacing: 14) {
- Text("Launch Shortcuts".localized)
- .font(.headline.weight(.semibold))
- .foregroundStyle(.primary)
-
- Text("Pin any app from Installed Apps and launch it here with ease.".localized)
- .font(.footnote)
- .foregroundStyle(.secondary)
-
- VStack(spacing: 10) {
- ForEach(pinnedLaunchItems) { item in
- SystemPinnedRow(
- item: item,
- accentColor: accentColor,
- isLaunching: launchingSystemApps.contains(item.bundleID),
- action: { launchSystemApp(item: item) },
- onRemove: { removePinnedSystemApp(bundleID: item.bundleID) }
- )
+
+ private var quickConnectItems: [QuickConnectItem] {
+ var seen = Set()
+ var ordered: [QuickConnectItem] = []
+ for bundle in favoriteApps + recentApps {
+ guard seen.insert(bundle).inserted else { continue }
+ ordered.append(QuickConnectItem(bundleID: bundle, displayName: friendlyName(for: bundle)))
+ if ordered.count >= 4 { break }
+ }
+ return ordered
+ }
+
+ private var pinnedLaunchItems: [SystemPinnedItem] {
+ pinnedSystemApps.compactMap { bundleID in
+ let raw = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID)
+ let displayName = shortDisplayName(from: raw)
+ return SystemPinnedItem(bundleID: bundleID, displayName: displayName)
+ }
+ }
+
+ // Prefer CoreDevice-reported app name, trimmed to a Home Screen–style label; else fall back to bundle ID last component.
+ private func friendlyName(for bundleID: String) -> String {
+ if let cached = cachedAppNames[bundleID], !cached.isEmpty {
+ return shortDisplayName(from: cached)
+ }
+ let components = bundleID.split(separator: ".")
+ if let last = components.last {
+ let cleaned = last.replacingOccurrences(of: "_", with: " ")
+ let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty { return trimmed.capitalized }
+ }
+ return bundleID
+ }
+
+ // Heuristic “Home Screen” shortener for long marketing names.
+ private func shortDisplayName(from name: String) -> String {
+ var s = name
+
+ // Keep only the part before common separators/subtitles.
+ let separators = [" — ", " – ", " - ", ":", "|", "·", "•"]
+ for sep in separators {
+ if let r = s.range(of: sep) {
+ s = String(s[..()
- var ordered: [QuickConnectItem] = []
- for bundle in favoriteApps + recentApps {
- guard seen.insert(bundle).inserted else { continue }
- ordered.append(QuickConnectItem(bundleID: bundle, displayName: friendlyName(for: bundle)))
- if ordered.count >= 4 { break }
- }
- return ordered
- }
-
- private var pinnedLaunchItems: [SystemPinnedItem] {
- pinnedSystemApps.compactMap { bundleID in
- let raw = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID)
- let displayName = shortDisplayName(from: raw)
- return SystemPinnedItem(bundleID: bundleID, displayName: displayName)
- }
- }
-
- // Prefer CoreDevice-reported app name, trimmed to a Home Screen–style label; else fall back to bundle ID last component.
- private func friendlyName(for bundleID: String) -> String {
- if let cached = cachedAppNames[bundleID], !cached.isEmpty {
- return shortDisplayName(from: cached)
- }
- let components = bundleID.split(separator: ".")
- if let last = components.last {
- let cleaned = last.replacingOccurrences(of: "_", with: " ")
- let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmed.isEmpty { return trimmed.capitalized }
- }
- return bundleID
- }
-
- // Heuristic “Home Screen” shortener for long marketing names.
- private func shortDisplayName(from name: String) -> String {
- var s = name
-
- // Keep only the part before common separators/subtitles.
- let separators = [" — ", " – ", " - ", ":", "|", "·", "•"]
- for sep in separators {
- if let r = s.range(of: sep) {
- s = String(s[..(@ViewBuilder content: () -> Content) -> some View {
+ content()
+ .padding(20)
+ .background(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .fill(.ultraThinMaterial)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
+ )
+ )
+ .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
}
-
- guard tunnel.tunnelStatus == .connected else { return }
-
- if !force && !cachedAppNames.isEmpty { return }
-
- DispatchQueue.global(qos: .userInitiated).async {
- let result = (try? JITEnableContext.shared.getAppList()) ?? [:]
- let encoded = try? JSONEncoder().encode(result)
- DispatchQueue.main.async {
- cachedAppNames = result
- syncFavoriteAppNamesWithCache()
- cachedAppNamesData = encoded
+
+ private func compactControlButton(icon: String, title: String, showSpinner: Bool = false) -> some View {
+ HStack(spacing: 6) {
+ if showSpinner {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .controlSize(.small)
+ } else {
+ Image(systemName: icon)
+ .font(.system(size: 14, weight: .semibold, design: .rounded))
+ }
+ Text(title)
+ .font(.caption.weight(.semibold))
}
- }
- }
-
- private func homeCard(@ViewBuilder content: () -> Content) -> some View {
- content()
- .padding(20)
+ .padding(.vertical, 8)
+ .padding(.horizontal, 12)
+ .frame(maxWidth: .infinity)
.background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
- )
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(0.8))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
)
- .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
- }
-
- private func compactControlButton(icon: String, title: String, showSpinner: Bool = false) -> some View {
- HStack(spacing: 6) {
- if showSpinner {
- ProgressView()
- .progressViewStyle(.circular)
- .controlSize(.small)
- } else {
- Image(systemName: icon)
- .font(.system(size: 14, weight: .semibold, design: .rounded))
- }
- Text(title)
- .font(.caption.weight(.semibold))
}
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
- .frame(maxWidth: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(0.8))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.1), lineWidth: 1)
- )
- }
-
- private var tipsCard: some View {
- homeCard {
- VStack(alignment: .leading, spacing: 12) {
- Text("Tips")
- .font(.headline)
- .foregroundStyle(.secondary)
-
- if !pairingFileExists {
- tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.")
- }
- if pairingFileExists && !ddiMounted {
- tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Ensure your pairing is imported and valid, connect to Wi-Fi and force-restart StikDebug.")
- }
- tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.")
-
- Divider().background(Color.white.opacity(0.1))
-
- Button {
- if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") {
- UIApplication.shared.open(url)
+
+ private var tipsCard: some View {
+ homeCard {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Tips")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+
+ if !pairingFileExists {
+ tipRow(systemImage: "doc.badge.plus", title: "Pairing file required", message: "Import your device’s pairing file to begin.")
}
- } label: {
- HStack(alignment: .center, spacing: 12) {
- Image(systemName: "questionmark.circle")
- .foregroundStyle(accentColor)
- .font(.system(size: 18, weight: .semibold))
-
- VStack(alignment: .leading, spacing: 2) {
- Text("Pairing File Guide")
- .font(.subheadline.weight(.semibold))
- Text("Step-by-step instructions from the community wiki.")
- .font(.footnote)
+ if pairingFileExists && !ddiMounted {
+ tipRow(systemImage: "externaldrive.badge.exclamationmark", title: "Developer Disk Image not mounted", message: "Ensure your pairing is imported and valid, connect to Wi-Fi and force-restart StikDebug.")
+ }
+ tipRow(systemImage: "lock.shield", title: "Local only", message: "StikDebug runs entirely on-device. No data leaves your device.")
+
+ Divider().background(Color.white.opacity(0.1))
+
+ Button {
+ if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") {
+ UIApplication.shared.open(url)
+ }
+ } label: {
+ HStack(alignment: .center, spacing: 12) {
+ Image(systemName: "questionmark.circle")
+ .foregroundStyle(accentColor)
+ .font(.system(size: 18, weight: .semibold))
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Pairing File Guide")
+ .font(.subheadline.weight(.semibold))
+ Text("Step-by-step instructions from the community wiki.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
}
-
- Spacer()
- Image(systemName: "chevron.right")
- .font(.system(size: 14, weight: .semibold))
- .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
}
- .padding(.vertical, 4)
+ .buttonStyle(.plain)
}
- .buttonStyle(.plain)
}
}
- }
-
- private func tipRow(systemImage: String, title: String, message: String) -> some View {
- HStack(alignment: .top, spacing: 12) {
- Image(systemName: systemImage)
- .foregroundStyle(accentColor)
- .font(.system(size: 18, weight: .semibold))
- VStack(alignment: .leading, spacing: 2) {
- Text(title)
- .font(.subheadline.weight(.semibold))
- Text(message)
- .font(.footnote)
- .foregroundStyle(.secondary)
+
+ private func tipRow(systemImage: String, title: String, message: String) -> some View {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: systemImage)
+ .foregroundStyle(accentColor)
+ .font(.system(size: 18, weight: .semibold))
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ .font(.subheadline.weight(.semibold))
+ Text(message)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ Spacer(minLength: 0)
}
- Spacer(minLength: 0)
+ .padding(.vertical, 4)
}
- .padding(.vertical, 4)
- }
-
- private func primaryActionTapped() {
- guard !isValidatingPairingFile else { return }
- if pairingFileLikelyInvalid {
- if shouldPromptForWiFi {
- showAlert(
- title: "Wi-Fi Required",
- message: "Connect to Wi-Fi.",
- showOk: true
- ) { _ in }
+
+ private func primaryActionTapped() {
+ guard !isValidatingPairingFile else { return }
+ if pairingFileLikelyInvalid {
+ if shouldPromptForWiFi {
+ showAlert(
+ title: "Wi-Fi Required",
+ message: "Connect to Wi-Fi.",
+ showOk: true
+ ) { _ in }
+ } else {
+ isShowingPairingFilePicker = true
+ }
+ return
+ }
+ if pairingFileExists {
+ if !ddiMounted {
+ showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in }
+ return
+ }
+ isShowingInstalledApps = true
} else {
isShowingPairingFilePicker = true
}
- return
}
- if pairingFileExists {
- if !ddiMounted {
- showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in }
+
+ private func showCopiedToast() {
+ withAnimation { justCopied = true }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
+ withAnimation { justCopied = false }
+ }
+ }
+
+ @ViewBuilder private func toast(_ text: String) -> some View {
+ VStack {
+ Spacer()
+ Text(text)
+ .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: text)
+ }
+
+ private func checkPairingFileExists() {
+ let fileExists = FileManager.default.fileExists(atPath: pairingFileURL.path)
+ pairingFilePresentOnDisk = fileExists
+
+ guard fileExists else {
+ pairingFileExists = false
+ lastValidatedPairingSignature = nil
+ isValidatingPairingFile = false
return
}
- isShowingInstalledApps = true
- } else {
- isShowingPairingFilePicker = true
- }
- }
-
- private func showCopiedToast() {
- withAnimation { justCopied = true }
- DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
- withAnimation { justCopied = false }
- }
- }
-
- @ViewBuilder private func toast(_ text: String) -> some View {
- VStack {
- Spacer()
- Text(text)
- .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: text)
- }
-
- private func checkPairingFileExists() {
- let fileExists = FileManager.default.fileExists(atPath: pairingFileURL.path)
- pairingFilePresentOnDisk = fileExists
-
- guard fileExists else {
- pairingFileExists = false
- lastValidatedPairingSignature = nil
- isValidatingPairingFile = false
- return
- }
-
- let signature = pairingFileSignature(for: pairingFileURL)
-
- guard needsValidation(for: signature) else { return }
- guard !isValidatingPairingFile else { return }
-
- isValidatingPairingFile = true
-
- DispatchQueue.global(qos: .utility).async {
- let valid = isPairing()
- DispatchQueue.main.async {
- pairingFileExists = valid
- lastValidatedPairingSignature = signature
- isValidatingPairingFile = false
+
+ let signature = pairingFileSignature(for: pairingFileURL)
+
+ guard needsValidation(for: signature) else { return }
+ guard !isValidatingPairingFile else { return }
+
+ isValidatingPairingFile = true
+
+ DispatchQueue.global(qos: .utility).async {
+ let valid = isPairing()
+ DispatchQueue.main.async {
+ pairingFileExists = valid
+ lastValidatedPairingSignature = signature
+ isValidatingPairingFile = false
+ }
}
}
- }
-
- private func needsValidation(for signature: PairingFileSignature) -> Bool {
- guard let lastSignature = lastValidatedPairingSignature else { return true }
- return lastSignature != signature
- }
-
-
- private func pairingFileSignature(for url: URL) -> PairingFileSignature {
- let attributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
- let modificationDate = attributes[.modificationDate] as? Date
- let sizeValue = (attributes[.size] as? NSNumber)?.uint64Value ?? 0
- return PairingFileSignature(modificationDate: modificationDate, fileSize: sizeValue)
- }
- private func refreshBackground() { }
-
- private func autoScript(for bundleID: String) -> (data: Data, name: String)? {
- guard ProcessInfo.processInfo.hasTXM else { return nil }
- guard #available(iOS 26, *) else { return nil }
- let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID)
- guard let appName,
- let resource = autoScriptResource(for: appName) else {
- return nil
+
+ private func needsValidation(for signature: PairingFileSignature) -> Bool {
+ guard let lastSignature = lastValidatedPairingSignature else { return true }
+ return lastSignature != signature
}
- let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
- .appendingPathComponent("scripts")
- let documentsURL = scriptsDir.appendingPathComponent(resource.fileName)
- if let data = try? Data(contentsOf: documentsURL) {
+
+
+ private func pairingFileSignature(for url: URL) -> PairingFileSignature {
+ let attributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:]
+ let modificationDate = attributes[.modificationDate] as? Date
+ let sizeValue = (attributes[.size] as? NSNumber)?.uint64Value ?? 0
+ return PairingFileSignature(modificationDate: modificationDate, fileSize: sizeValue)
+ }
+ private func refreshBackground() { }
+
+ private func autoScript(for bundleID: String) -> (data: Data, name: String)? {
+ guard ProcessInfo.processInfo.hasTXM else { return nil }
+ guard #available(iOS 26, *) else { return nil }
+ let appName = (try? JITEnableContext.shared.getAppList()[bundleID]) ?? storedFavoriteName(for: bundleID)
+ guard let appName,
+ let resource = autoScriptResource(for: appName) else {
+ return nil
+ }
+ let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("scripts")
+ let documentsURL = scriptsDir.appendingPathComponent(resource.fileName)
+ if let data = try? Data(contentsOf: documentsURL) {
+ return (data, resource.fileName)
+ }
+ guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"),
+ let data = try? Data(contentsOf: bundleURL) else {
+ return nil
+ }
return (data, resource.fileName)
}
- guard let bundleURL = Bundle.main.url(forResource: resource.resource, withExtension: "js"),
- let data = try? Data(contentsOf: bundleURL) else {
- return nil
- }
- return (data, resource.fileName)
- }
-
- private func assignedScript(for bundleID: String) -> (data: Data, name: String)? {
- guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String],
- let scriptName = mapping[bundleID] else { return nil }
- let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
- .appendingPathComponent("scripts")
- let scriptURL = scriptsDir.appendingPathComponent(scriptName)
- guard FileManager.default.fileExists(atPath: scriptURL.path),
- let data = try? Data(contentsOf: scriptURL) else { return nil }
- return (data, scriptName)
- }
-
- private func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
- if let assigned = assignedScript(for: bundleID) {
- return assigned
- }
- return autoScript(for: bundleID)
- }
-
- private func storedFavoriteName(for bundleID: String) -> String? {
- let defaults = UserDefaults(suiteName: "group.com.stik.sj")
- let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String]
- return names?[bundleID]
- }
-
- private func syncFavoriteAppNamesWithCache() {
- guard let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") else { return }
- let favorites = sharedDefaults.stringArray(forKey: "favoriteApps") ?? []
- guard !favorites.isEmpty else { return }
-
- var storedNames = (sharedDefaults.dictionary(forKey: "favoriteAppNames") as? [String: String]) ?? [:]
- var changed = false
-
- for bundle in favorites {
- guard let rawName = cachedAppNames[bundle], !rawName.isEmpty else { continue }
- let display = shortDisplayName(from: rawName)
- if storedNames[bundle] != display {
- storedNames[bundle] = display
- changed = true
- }
- }
-
- if changed {
- sharedDefaults.set(storedNames, forKey: "favoriteAppNames")
- WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
- }
- }
-
- private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
- switch appName {
- case "maciOS":
- return ("maciOS", "maciOS.js")
- case "Amethyst":
- return ("Amethyst", "Amethyst.js")
- case "Geode":
- return ("Geode", "Geode.js")
- case "MeloNX":
- return ("MeloNX", "MeloNX.js")
- case "UTM", "DolphiniOS":
- return ("UTM-Dolphin", "UTM-Dolphin.js")
- default:
- return nil
- }
- }
-
- private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
- return { pid, debugProxyHandle, remoteServerHandle, semaphore in
- jsModel = RunJSViewModel(pid: Int(pid),
- debugProxy: debugProxyHandle,
- remoteServer: remoteServerHandle,
- semaphore: semaphore)
- scriptViewShow = true
- DispatchQueue.global(qos: .background).async {
- do { try jsModel?.runScript(data: script, name: name) }
- catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) }
- }
- }
- }
-
- private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) {
- isProcessing = true
- LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
- DispatchQueue.global(qos: .background).async {
- var scriptData = scriptData
- var scriptName = scriptName
- if scriptData == nil,
- let bundleID,
- let preferred = preferredScript(for: bundleID) {
- scriptName = preferred.name
- scriptData = preferred.data
+ private func assignedScript(for bundleID: String) -> (data: Data, name: String)? {
+ guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String],
+ let scriptName = mapping[bundleID] else { return nil }
+ let scriptsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
+ .appendingPathComponent("scripts")
+ let scriptURL = scriptsDir.appendingPathComponent(scriptName)
+ guard FileManager.default.fileExists(atPath: scriptURL.path),
+ let data = try? Data(contentsOf: scriptURL) else { return nil }
+ return (data, scriptName)
+ }
+
+ private func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
+ if let assigned = assignedScript(for: bundleID) {
+ return assigned
+ }
+ return autoScript(for: bundleID)
+ }
+
+ private func storedFavoriteName(for bundleID: String) -> String? {
+ let defaults = UserDefaults(suiteName: "group.com.stik.sj")
+ let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String]
+ return names?[bundleID]
+ }
+
+ private func syncFavoriteAppNamesWithCache() {
+ guard let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") else { return }
+ let favorites = sharedDefaults.stringArray(forKey: "favoriteApps") ?? []
+ guard !favorites.isEmpty else { return }
+
+ var storedNames = (sharedDefaults.dictionary(forKey: "favoriteAppNames") as? [String: String]) ?? [:]
+ var changed = false
+
+ for bundle in favorites {
+ guard let rawName = cachedAppNames[bundle], !rawName.isEmpty else { continue }
+ let display = shortDisplayName(from: rawName)
+ if storedNames[bundle] != display {
+ storedNames[bundle] = display
+ changed = true
+ }
}
- var callback: DebugAppCallback? = nil
- if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
- callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script")
- if triggeredByURLScheme { usleep(500000) }
+ if changed {
+ sharedDefaults.set(storedNames, forKey: "favoriteAppNames")
+ WidgetCenter.shared.reloadTimelines(ofKind: "FavoritesWidget")
}
+ }
+
+ private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
+ switch appName {
+ case "maciOS":
+ return ("maciOS", "maciOS.js")
+ case "Amethyst":
+ return ("Amethyst", "Amethyst.js")
+ case "Geode":
+ return ("Geode", "Geode.js")
+ case "MeloNX":
+ return ("MeloNX", "MeloNX.js")
+ case "UTM", "DolphiniOS":
+ return ("UTM-Dolphin", "UTM-Dolphin.js")
+ default:
+ return nil
+ }
+ }
+
+ private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
+ return { pid, debugProxyHandle, remoteServerHandle, semaphore in
+ jsModel = RunJSViewModel(pid: Int(pid),
+ debugProxy: debugProxyHandle,
+ remoteServer: remoteServerHandle,
+ semaphore: semaphore)
+ scriptViewShow = true
+ DispatchQueue.global(qos: .background).async {
+ do { try jsModel?.runScript(data: script, name: name) }
+ catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) }
+ }
+ }
+ }
+
+ private func startJITInBackground(bundleID: String? = nil, pid : Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) {
+ isProcessing = true
+ LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
- let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } }
- var success: Bool
- if let pid {
- success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback)
- if success { DispatchQueue.main.async { addRecentPID(pid) } }
- } else if let bundleID {
- success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback)
- } else {
- DispatchQueue.main.async {
- showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true)
+ DispatchQueue.global(qos: .background).async {
+ var scriptData = scriptData
+ var scriptName = scriptName
+ if scriptData == nil,
+ let bundleID,
+ let preferred = preferredScript(for: bundleID) {
+ scriptName = preferred.name
+ scriptData = preferred.data
}
- success = false
- }
-
- if success {
- DispatchQueue.main.async {
- LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))")
- }
- }
- isProcessing = false
- }
- }
-
- private func launchSystemApp(item: SystemPinnedItem) {
- guard !launchingSystemApps.contains(item.bundleID) else { return }
- launchingSystemApps.insert(item.bundleID)
- HapticFeedbackHelper.trigger()
-
- DispatchQueue.global(qos: .userInitiated).async {
- let success = JITEnableContext.shared.launchAppWithoutDebug(item.bundleID, logger: nil)
-
- DispatchQueue.main.async {
- launchingSystemApps.remove(item.bundleID)
- if success {
- LogManager.shared.addInfoLog("Launch request sent for \(item.bundleID)")
- systemLaunchMessage = String(format: "Launch requested: %@".localized, item.displayName)
- } else {
- LogManager.shared.addErrorLog("Failed to launch \(item.bundleID)")
- systemLaunchMessage = String(format: "Failed to launch %@".localized, item.displayName)
- }
- scheduleSystemToastDismiss()
- }
- }
- }
-
- private func removePinnedSystemApp(bundleID: String) {
- Haptics.light()
- pinnedSystemApps.removeAll { $0 == bundleID }
- pinnedSystemAppNames.removeValue(forKey: bundleID)
- persistPinnedSystemApps()
- }
-
- private func scheduleSystemToastDismiss() {
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
- if systemLaunchMessage != nil {
- withAnimation {
- systemLaunchMessage = nil
- }
- }
- }
- }
-
- private func persistPinnedSystemApps() {
- if let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") {
- sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps")
- sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames")
- }
- WidgetCenter.shared.reloadAllTimelines()
- }
-
- private func addRecentPID(_ pid: Int) {
- var list = recentPIDs.filter { $0 != pid }
- list.insert(pid, at: 0)
- if list.count > 8 { list = Array(list.prefix(8)) }
- recentPIDs = list
- }
-
- func base64URLToBase64(_ base64url: String) -> String {
- var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
- let pad = 4 - (base64.count % 4)
- if pad < 4 { base64 += String(repeating: "=", count: pad) }
- return base64
- }
-
-private struct StatusDot: View {
- var color: Color
- @Environment(\.colorScheme) private var colorScheme
- var body: some View {
- ZStack {
- Circle().fill(color.opacity(0.25)).frame(width: 20, height: 20)
- Circle().fill(color).frame(width: 12, height: 12)
- .shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0)
- }
- .overlay(
- Circle().stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5)
- )
- }
-}
-
-private struct StatusGlyph: View {
- let icon: String
- let tint: Color
- var size: CGFloat = 48
- var iconSize: CGFloat = 22
-
- var body: some View {
- ZStack {
- Circle()
- .fill(tint.opacity(0.18))
- .frame(width: size, height: size)
-
- Image(systemName: icon)
- .font(.system(size: iconSize, weight: .semibold, design: .rounded))
- .foregroundStyle(tint)
- }
- .overlay(
- Circle()
- .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
- )
- }
-}
-
-private struct QuickConnectRow: View {
- let item: QuickConnectItem
- let accentColor: Color
- let isEnabled: Bool
- let action: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: 14) {
- QuickAppBadge(title: item.displayName, accentColor: accentColor)
-
- VStack(alignment: .leading, spacing: 2) {
- Text(item.displayName)
- .font(.system(size: 16, weight: .semibold, design: .rounded))
- .foregroundStyle(.primary)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
-
- Text(item.bundleID)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .textSelection(.enabled)
- }
-
- Spacer(minLength: 0)
-
- Image(systemName: "bolt.horizontal.circle.fill")
- .font(.system(size: 20, weight: .semibold))
- .foregroundStyle(isEnabled ? accentColor : Color.secondary)
- }
- .padding(.vertical, 12)
- .padding(.horizontal, 14)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(isEnabled ? 0.65 : 0.35))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.1), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(!isEnabled)
- .opacity(isEnabled ? 1 : 0.55)
- }
-}
-
-private struct SystemPinnedRow: View {
- let item: SystemPinnedItem
- let accentColor: Color
- let isLaunching: Bool
- var action: () -> Void
- var onRemove: () -> Void
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: 14) {
- QuickAppBadge(title: item.displayName, accentColor: accentColor)
-
- VStack(alignment: .leading, spacing: 2) {
- Text(item.displayName)
- .font(.system(size: 16, weight: .semibold, design: .rounded))
- .foregroundStyle(.primary)
- .lineLimit(1)
- .minimumScaleFactor(0.8)
-
- Text(item.bundleID)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .textSelection(.enabled)
- }
-
- Spacer(minLength: 0)
-
- if isLaunching {
- ProgressView()
- .controlSize(.small)
- .tint(accentColor)
- } else {
- Image(systemName: "play.fill")
- .font(.system(size: 18, weight: .semibold))
- .foregroundStyle(accentColor)
- }
- }
- .padding(.vertical, 12)
- .padding(.horizontal, 14)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.1), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(isLaunching)
- .contextMenu {
- Button("Remove from Home".localized, systemImage: "star.slash") {
- onRemove()
- }
- }
- .swipeActions(edge: .trailing, allowsFullSwipe: false) {
- Button(role: .destructive) {
- onRemove()
- } label: {
- Label("Remove".localized, systemImage: "trash")
- }
- }
- }
-}
-
-private struct QuickAppBadge: View {
- let title: String
- let accentColor: Color
-
- private var initials: String {
- let words = title.split(separator: " ")
- if let first = words.first, !first.isEmpty {
- return String(first.prefix(1)).uppercased()
- }
- return String(title.prefix(1)).uppercased()
- }
-
- var body: some View {
- Text(initials)
- .font(.system(size: 16, weight: .semibold, design: .rounded))
- .frame(width: 36, height: 36)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(accentColor.opacity(0.16))
- )
- .foregroundStyle(accentColor)
- }
-}
-
-private struct StatusLightView: View {
- let light: StatusLightData
-
- var body: some View {
- VStack(spacing: 6) {
- ZStack {
- Circle()
- .fill(light.status.tint.opacity(0.18))
- .frame(width: 48, height: 48)
- Image(systemName: light.icon)
- .font(.system(size: 18, weight: .semibold))
- .foregroundStyle(light.status.tint)
- }
- .overlay(
- Circle()
- .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
- .frame(width: 48, height: 48)
- )
- .overlay(alignment: .bottomTrailing) {
- Image(systemName: light.status.iconName)
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(light.status.symbolColor)
- .padding(4)
- .background(
- Circle()
- .fill(Color(.systemBackground))
- .shadow(color: .black.opacity(0.12), radius: 1.5, x: 0, y: 1)
- )
- .offset(x: 6, y: 6)
- }
-
- VStack(spacing: 0) {
- Text(light.title)
- .font(.caption2.weight(.semibold))
- Text(light.detail)
- .font(.caption2)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
- }
- .frame(width: 80)
- }
- .accessibilityElement(children: .ignore)
- .accessibilityLabel("\(light.title) status")
- .accessibilityValue("\(light.detail). \(light.status.accessibilityDescription)")
- }
-}
-
-private struct StatusLightData: Identifiable {
- let id = UUID()
- let type: StatusLightType
- let title: String
- let icon: String
- let status: StartupIndicatorStatus
- let detail: String
-}
-
-private enum StatusLightType {
- case ddi
- case wifi
- case heartbeat
-}
-
-private struct PairingFileSignature: Equatable {
- let modificationDate: Date?
- let fileSize: UInt64
-}
-
-private enum ConnectionCheckState: Equatable {
- case idle
- case running
- case success
- case failure(String)
- case timeout
-}
-
-private enum StartupIndicatorStatus: Equatable {
- case idle
- case running
- case success
- case warning
- case error
-
- var iconName: String {
- switch self {
- case .success: return "checkmark.circle.fill"
- case .warning: return "exclamationmark.triangle.fill"
- case .error: return "xmark.circle.fill"
- case .idle: return "circle"
- case .running: return "clock.arrow.circlepath"
- }
- }
-
- var tint: Color {
- switch self {
- case .success: return .green
- case .warning: return .yellow
- case .error: return .red
- case .idle: return .secondary
- case .running: return .orange
- }
- }
-
- var symbolColor: Color {
- switch self {
- case .success: return .green
- case .warning: return .orange
- case .error: return .red
- case .idle: return .secondary
- case .running: return .blue
- }
- }
-
- var accessibilityDescription: String {
- switch self {
- case .success: return "Success"
- case .warning: return "Warning"
- case .error: return "Error"
- case .idle: return "Idle"
- case .running: return "In progress"
- }
- }
-}
-private struct QuickConnectItem: Identifiable {
- let bundleID: String
- let displayName: String
- var id: String { bundleID }
-}
-
-private struct SystemPinnedItem: Identifiable {
- let bundleID: String
- let displayName: String
- var id: String { bundleID }
-}
-
-// MARK: - Connect-by-PID Sheet (minus/plus removed)
-
-private struct ConnectByPIDSheet: View {
- @Environment(\.dismiss) private var dismiss
- @Binding var recentPIDs: [Int]
- @State private var pidText: String = ""
- @State private var errorText: String? = nil
- @FocusState private var focused: Bool
- var onPasteCopyToast: () -> Void
- var onConnect: (Int) -> Void
-
- private var isValid: Bool {
- if let v = Int(pidText), v > 0 { return true }
- return false
- }
-
- private let capsuleHeight: CGFloat = 40
-
- var body: some View {
- NavigationView {
- ZStack {
- Color.clear.ignoresSafeArea()
- ScrollView {
- VStack(spacing: 20) {
- VStack(alignment: .leading, spacing: 14) {
- Text("Enter a Process ID").font(.headline).foregroundColor(.primary)
+ var callback: DebugAppCallback? = nil
+ if ProcessInfo.processInfo.hasTXM, let sd = scriptData {
+ callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script")
+ if triggeredByURLScheme { usleep(500000) }
+ }
+
+ let logger: LogFunc = { message in if let message { LogManager.shared.addInfoLog(message) } }
+ var success: Bool
+ if let pid {
+ success = JITEnableContext.shared.debugApp(withPID: Int32(pid), logger: logger, jsCallback: callback)
+ if success { DispatchQueue.main.async { addRecentPID(pid) } }
+ } else if let bundleID {
+ success = JITEnableContext.shared.debugApp(withBundleID: bundleID, logger: logger, jsCallback: callback)
+ } else {
+ DispatchQueue.main.async {
+ showAlert(title: "Failed to Debug App".localized, message: "Either bundle ID or PID should be specified.".localized, showOk: true)
+ }
+ success = false
+ }
+
+ if success {
+ DispatchQueue.main.async {
+ LogManager.shared.addInfoLog("Debug process completed for \(bundleID ?? String(pid ?? 0))")
+ }
+ }
+ isProcessing = false
+ }
+ }
+
+ private func launchSystemApp(item: SystemPinnedItem) {
+ guard !launchingSystemApps.contains(item.bundleID) else { return }
+ launchingSystemApps.insert(item.bundleID)
+ HapticFeedbackHelper.trigger()
+
+ DispatchQueue.global(qos: .userInitiated).async {
+ let success = JITEnableContext.shared.launchAppWithoutDebug(item.bundleID, logger: nil)
+
+ DispatchQueue.main.async {
+ launchingSystemApps.remove(item.bundleID)
+ if success {
+ LogManager.shared.addInfoLog("Launch request sent for \(item.bundleID)")
+ systemLaunchMessage = String(format: "Launch requested: %@".localized, item.displayName)
+ } else {
+ LogManager.shared.addErrorLog("Failed to launch \(item.bundleID)")
+ systemLaunchMessage = String(format: "Failed to launch %@".localized, item.displayName)
+ }
+ scheduleSystemToastDismiss()
+ }
+ }
+ }
+
+ private func removePinnedSystemApp(bundleID: String) {
+ Haptics.light()
+ pinnedSystemApps.removeAll { $0 == bundleID }
+ pinnedSystemAppNames.removeValue(forKey: bundleID)
+ persistPinnedSystemApps()
+ }
+
+ private func scheduleSystemToastDismiss() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
+ if systemLaunchMessage != nil {
+ withAnimation {
+ systemLaunchMessage = nil
+ }
+ }
+ }
+ }
+
+ private func persistPinnedSystemApps() {
+ if let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") {
+ sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps")
+ sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames")
+ }
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+
+ private func addRecentPID(_ pid: Int) {
+ var list = recentPIDs.filter { $0 != pid }
+ list.insert(pid, at: 0)
+ if list.count > 8 { list = Array(list.prefix(8)) }
+ recentPIDs = list
+ }
+
+ func base64URLToBase64(_ base64url: String) -> String {
+ var base64 = base64url.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ let pad = 4 - (base64.count % 4)
+ if pad < 4 { base64 += String(repeating: "=", count: pad) }
+ return base64
+ }
+
+ private struct StatusDot: View {
+ var color: Color
+ @Environment(\.colorScheme) private var colorScheme
+ var body: some View {
+ ZStack {
+ Circle().fill(color.opacity(0.25)).frame(width: 20, height: 20)
+ Circle().fill(color).frame(width: 12, height: 12)
+ .shadow(color: color.opacity(0.6), radius: 4, x: 0, y: 0)
+ }
+ .overlay(
+ Circle().stroke(colorScheme == .dark ? Color.white.opacity(0.15) : Color.black.opacity(0.1), lineWidth: 0.5)
+ )
+ }
+ }
+
+ private struct StatusGlyph: View {
+ let icon: String
+ let tint: Color
+ var size: CGFloat = 48
+ var iconSize: CGFloat = 22
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(tint.opacity(0.18))
+ .frame(width: size, height: size)
+
+ Image(systemName: icon)
+ .font(.system(size: iconSize, weight: .semibold, design: .rounded))
+ .foregroundStyle(tint)
+ }
+ .overlay(
+ Circle()
+ .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
+ )
+ }
+ }
+
+ private struct QuickConnectRow: View {
+ let item: QuickConnectItem
+ let accentColor: Color
+ let isEnabled: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 14) {
+ QuickAppBadge(title: item.displayName, accentColor: accentColor)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.displayName)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
- TextField("e.g. 1234", text: $pidText)
- .keyboardType(.numberPad)
- .textContentType(.oneTimeCode)
- .font(.system(.title3, design: .rounded))
- .padding(12)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(.ultraThinMaterial)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
- )
- .focused($focused)
- .onChange(of: pidText) { _, newVal in validate(newVal) }
-
- // Paste + Clear row
- HStack(spacing: 10) {
- CapsuleButton(systemName: "doc.on.clipboard", title: "Paste", height: capsuleHeight) {
- if let n = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
- let v = Int(n), v > 0 {
- pidText = String(v)
- validate(pidText)
- onPasteCopyToast()
- } else {
- errorText = "No valid PID on the clipboard."
- UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ Text(item.bundleID)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .textSelection(.enabled)
+ }
+
+ Spacer(minLength: 0)
+
+ Image(systemName: "bolt.horizontal.circle.fill")
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundStyle(isEnabled ? accentColor : Color.secondary)
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(isEnabled ? 0.65 : 0.35))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(!isEnabled)
+ .opacity(isEnabled ? 1 : 0.55)
+ }
+ }
+
+ private struct SystemPinnedRow: View {
+ let item: SystemPinnedItem
+ let accentColor: Color
+ let isLaunching: Bool
+ var action: () -> Void
+ var onRemove: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 14) {
+ QuickAppBadge(title: item.displayName, accentColor: accentColor)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(item.displayName)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.8)
+
+ Text(item.bundleID)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .textSelection(.enabled)
+ }
+
+ Spacer(minLength: 0)
+
+ if isLaunching {
+ ProgressView()
+ .controlSize(.small)
+ .tint(accentColor)
+ } else {
+ Image(systemName: "play.fill")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(accentColor)
+ }
+ }
+ .padding(.vertical, 12)
+ .padding(.horizontal, 14)
+ .background(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .fill(Color(UIColor.secondarySystemBackground).opacity(0.6))
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 18, style: .continuous)
+ .stroke(Color.white.opacity(0.1), lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ .disabled(isLaunching)
+ .contextMenu {
+ Button("Remove from Home".localized, systemImage: "star.slash") {
+ onRemove()
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ onRemove()
+ } label: {
+ Label("Remove".localized, systemImage: "trash")
+ }
+ }
+ }
+ }
+
+ private struct QuickAppBadge: View {
+ let title: String
+ let accentColor: Color
+
+ private var initials: String {
+ let words = title.split(separator: " ")
+ if let first = words.first, !first.isEmpty {
+ return String(first.prefix(1)).uppercased()
+ }
+ return String(title.prefix(1)).uppercased()
+ }
+
+ var body: some View {
+ Text(initials)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .frame(width: 36, height: 36)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(accentColor.opacity(0.16))
+ )
+ .foregroundStyle(accentColor)
+ }
+ }
+
+ private struct StatusLightView: View {
+ let light: StatusLightData
+
+ var body: some View {
+ let tint = light.tintOverride ?? light.status.tint
+ VStack(spacing: 6) {
+ ZStack {
+ Circle()
+ .fill(tint.opacity(0.18))
+ .frame(width: 48, height: 48)
+ Image(systemName: light.icon)
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(tint)
+ }
+ .overlay(
+ Circle()
+ .stroke(Color.white.opacity(0.12), lineWidth: 0.5)
+ .frame(width: 48, height: 48)
+ )
+ .overlay(alignment: .bottomTrailing) {
+ Image(systemName: light.indicatorIconName ?? light.status.iconName)
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(light.indicatorColor ?? light.status.symbolColor)
+ .padding(4)
+ .background(
+ Circle()
+ .fill(Color(.systemBackground))
+ .shadow(color: .black.opacity(0.12), radius: 1.5, x: 0, y: 1)
+ )
+ .offset(x: 6, y: 6)
+ }
+
+ VStack(spacing: 0) {
+ Text(light.title)
+ .font(.caption2.weight(.semibold))
+ Text(light.detail)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(width: 80)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("\(light.title) status")
+ .accessibilityValue("\(light.detail). \(light.status.accessibilityDescription)")
+ }
+ }
+
+ private struct StatusLightData: Identifiable {
+ let id = UUID()
+ let type: StatusLightType
+ let title: String
+ let icon: String
+ let status: StartupIndicatorStatus
+ let detail: String
+ let action: (() -> Void)?
+ let isEnabled: Bool
+ let indicatorIconName: String?
+ let indicatorColor: Color?
+ let tintOverride: Color?
+
+ init(type: StatusLightType,
+ title: String,
+ icon: String,
+ status: StartupIndicatorStatus,
+ detail: String,
+ action: (() -> Void)? = nil,
+ isEnabled: Bool = true,
+ indicatorIconName: String? = nil,
+ indicatorColor: Color? = nil,
+ tintOverride: Color? = nil) {
+ self.type = type
+ self.title = title
+ self.icon = icon
+ self.status = status
+ self.detail = detail
+ self.action = action
+ self.isEnabled = isEnabled
+ self.indicatorIconName = indicatorIconName
+ self.indicatorColor = indicatorColor
+ self.tintOverride = tintOverride
+ }
+ }
+
+ private enum StatusLightType {
+ case ddi
+ case wifi
+ case heartbeat
+ case refresh
+ }
+
+ private struct PairingFileSignature: Equatable {
+ let modificationDate: Date?
+ let fileSize: UInt64
+ }
+
+ private enum ConnectionCheckState: Equatable {
+ case idle
+ case running
+ case success
+ case failure(String)
+ case timeout
+ }
+
+ private enum StartupIndicatorStatus: Equatable {
+ case idle
+ case running
+ case success
+ case warning
+ case error
+
+ var iconName: String {
+ switch self {
+ case .success: return "checkmark.circle.fill"
+ case .warning: return "exclamationmark.triangle.fill"
+ case .error: return "xmark.circle.fill"
+ case .idle: return "circle"
+ case .running: return "clock.arrow.circlepath"
+ }
+ }
+
+ var tint: Color {
+ switch self {
+ case .success: return .green
+ case .warning: return .yellow
+ case .error: return .red
+ case .idle: return .secondary
+ case .running: return .orange
+ }
+ }
+
+ var symbolColor: Color {
+ switch self {
+ case .success: return .green
+ case .warning: return .orange
+ case .error: return .red
+ case .idle: return .secondary
+ case .running: return .blue
+ }
+ }
+
+ var accessibilityDescription: String {
+ switch self {
+ case .success: return "Success"
+ case .warning: return "Warning"
+ case .error: return "Error"
+ case .idle: return "Idle"
+ case .running: return "In progress"
+ }
+ }
+ }
+ private struct QuickConnectItem: Identifiable {
+ let bundleID: String
+ let displayName: String
+ var id: String { bundleID }
+ }
+
+ private struct SystemPinnedItem: Identifiable {
+ let bundleID: String
+ let displayName: String
+ var id: String { bundleID }
+ }
+
+ // MARK: - Connect-by-PID Sheet (minus/plus removed)
+
+ private struct ConnectByPIDSheet: View {
+ @Environment(\.dismiss) private var dismiss
+ @Binding var recentPIDs: [Int]
+ @State private var pidText: String = ""
+ @State private var errorText: String? = nil
+ @FocusState private var focused: Bool
+ var onPasteCopyToast: () -> Void
+ var onConnect: (Int) -> Void
+
+ private var isValid: Bool {
+ if let v = Int(pidText), v > 0 { return true }
+ return false
+ }
+
+ private let capsuleHeight: CGFloat = 40
+
+ var body: some View {
+ NavigationView {
+ ZStack {
+ Color.clear.ignoresSafeArea()
+
+ ScrollView {
+ VStack(spacing: 20) {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Enter a Process ID").font(.headline).foregroundColor(.primary)
+
+ TextField("e.g. 1234", text: $pidText)
+ .keyboardType(.numberPad)
+ .textContentType(.oneTimeCode)
+ .font(.system(.title3, design: .rounded))
+ .padding(12)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(.ultraThinMaterial)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
+ )
+ .focused($focused)
+ .onChange(of: pidText) { _, newVal in validate(newVal) }
+
+ // Paste + Clear row
+ HStack(spacing: 10) {
+ CapsuleButton(systemName: "doc.on.clipboard", title: "Paste", height: capsuleHeight) {
+ if let n = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
+ let v = Int(n), v > 0 {
+ pidText = String(v)
+ validate(pidText)
+ onPasteCopyToast()
+ } else {
+ errorText = "No valid PID on the clipboard."
+ UIImpactFeedbackGenerator(style: .light).impactOccurred()
+ }
+ }
+
+ CapsuleButton(systemName: "xmark", title: "Clear", height: capsuleHeight) {
+ pidText = ""
+ errorText = nil
+ }
}
- }
-
- CapsuleButton(systemName: "xmark", title: "Clear", height: capsuleHeight) {
- pidText = ""
- errorText = nil
- }
- }
-
-
- if let errorText {
- HStack(spacing: 6) {
- Image(systemName: "exclamationmark.triangle.fill").font(.footnote)
- Text(errorText).font(.footnote)
- }
- .foregroundColor(.orange)
- .transition(.opacity)
- }
-
- if !recentPIDs.isEmpty {
- VStack(alignment: .leading, spacing: 8) {
- Text("Recents")
- .font(.subheadline.weight(.semibold))
- .foregroundColor(.secondary)
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- ForEach(recentPIDs, id: \.self) { pid in
- Button {
- pidText = String(pid); validate(pidText)
- } label: {
- Text("#\(pid)")
- .font(.footnote.weight(.semibold))
- .padding(.vertical, 6)
- .padding(.horizontal, 10)
- .background(
- Capsule(style: .continuous)
- .fill(Color(UIColor.tertiarySystemBackground))
- )
- }
- .contextMenu {
- Button(role: .destructive) {
- removeRecent(pid)
- } label: { Label("Remove", systemImage: "trash") }
+
+
+ if let errorText {
+ HStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill").font(.footnote)
+ Text(errorText).font(.footnote)
+ }
+ .foregroundColor(.orange)
+ .transition(.opacity)
+ }
+
+ if !recentPIDs.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Recents")
+ .font(.subheadline.weight(.semibold))
+ .foregroundColor(.secondary)
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(recentPIDs, id: \.self) { pid in
+ Button {
+ pidText = String(pid); validate(pidText)
+ } label: {
+ Text("#\(pid)")
+ .font(.footnote.weight(.semibold))
+ .padding(.vertical, 6)
+ .padding(.horizontal, 10)
+ .background(
+ Capsule(style: .continuous)
+ .fill(Color(UIColor.tertiarySystemBackground))
+ )
+ }
+ .contextMenu {
+ Button(role: .destructive) {
+ removeRecent(pid)
+ } label: { Label("Remove", systemImage: "trash") }
+ }
+ }
}
}
}
}
+
+ Button {
+ guard let pid = Int(pidText), pid > 0 else { return }
+ onConnect(pid)
+ addRecent(pid)
+ dismiss()
+ } label: {
+ HStack {
+ Image(systemName: "bolt.horizontal.circle").font(.system(size: 20))
+ Text("Connect")
+ .font(.system(.title3, design: .rounded))
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
+ .foregroundColor(Color.accentColor.contrastText())
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
+ )
+ }
+ .disabled(!isValid)
+ .padding(.top, 8)
}
- }
-
- Button {
- guard let pid = Int(pidText), pid > 0 else { return }
- onConnect(pid)
- addRecent(pid)
- dismiss()
- } label: {
- HStack {
- Image(systemName: "bolt.horizontal.circle").font(.system(size: 20))
- Text("Connect")
- .font(.system(.title3, design: .rounded))
- .fontWeight(.semibold)
- }
- .frame(maxWidth: .infinity)
- .padding(.vertical, 14)
- .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
- .foregroundColor(Color.accentColor.contrastText())
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.2), lineWidth: 0.5)
- )
- }
- .disabled(!isValid)
- .padding(.top, 8)
- }
- .padding(20)
- .background(
- RoundedRectangle(cornerRadius: 20, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay(
+ .padding(20)
+ .background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
- .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
+ .fill(.ultraThinMaterial)
+ .overlay(
+ RoundedRectangle(cornerRadius: 20, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
+ )
)
- )
- .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
+ .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 30)
+ }
}
- .padding(.horizontal, 20)
- .padding(.vertical, 30)
+ .navigationTitle("Connect by PID")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } }
+ .onAppear { focused = true }
}
}
- .navigationTitle("Connect by PID")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } } }
- .onAppear { focused = true }
- }
- }
-
- // Small glassy square icon button
- private func iconSquareButton(systemName: String, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- Image(systemName: systemName)
- .font(.headline)
- .frame(width: 36, height: 36)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(Color(UIColor.tertiarySystemBackground))
- )
- }
- .buttonStyle(.plain)
- .contentShape(Rectangle())
- }
-
- private func validate(_ text: String) {
- if text.isEmpty { errorText = nil; return }
- if Int(text) == nil || Int(text)! <= 0 { errorText = "Please enter a positive number." }
- else { errorText = nil }
- }
- private func addRecent(_ pid: Int) {
- var list = recentPIDs.filter { $0 != pid }
- list.insert(pid, at: 0)
- if list.count > 8 { list = Array(list.prefix(8)) }
- recentPIDs = list
- }
- private func removeRecent(_ pid: Int) { recentPIDs.removeAll { $0 == pid } }
- private func prefillFromClipboardIfPossible() {
- if let s = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
- let v = Int(s), v > 0 {
- pidText = String(v); errorText = nil
- }
- }
-
- @ViewBuilder private func CapsuleButton(systemName: String, title: String, height: CGFloat = 40, action: @escaping () -> Void) -> some View {
- Button(action: action) {
- HStack(spacing: 6) {
- Image(systemName: systemName)
- Text(title).font(.subheadline.weight(.semibold))
+
+ // Small glassy square icon button
+ private func iconSquareButton(systemName: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ Image(systemName: systemName)
+ .font(.headline)
+ .frame(width: 36, height: 36)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(UIColor.tertiarySystemBackground))
+ )
+ }
+ .buttonStyle(.plain)
+ .contentShape(Rectangle())
+ }
+
+ private func validate(_ text: String) {
+ if text.isEmpty { errorText = nil; return }
+ if Int(text) == nil || Int(text)! <= 0 { errorText = "Please enter a positive number." }
+ else { errorText = nil }
+ }
+ private func addRecent(_ pid: Int) {
+ var list = recentPIDs.filter { $0 != pid }
+ list.insert(pid, at: 0)
+ if list.count > 8 { list = Array(list.prefix(8)) }
+ recentPIDs = list
+ }
+ private func removeRecent(_ pid: Int) { recentPIDs.removeAll { $0 == pid } }
+ private func prefillFromClipboardIfPossible() {
+ if let s = UIPasteboard.general.string?.trimmingCharacters(in: .whitespacesAndNewlines),
+ let v = Int(s), v > 0 {
+ pidText = String(v); errorText = nil
+ }
+ }
+
+ @ViewBuilder private func CapsuleButton(systemName: String, title: String, height: CGFloat = 40, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ HStack(spacing: 6) {
+ Image(systemName: systemName)
+ Text(title).font(.subheadline.weight(.semibold))
+ }
+ .frame(height: height) // enforce uniform height
+ .padding(.horizontal, 12)
+ .background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground)))
+ }
+ .buttonStyle(.plain)
+ .contentShape(Rectangle())
}
- .frame(height: height) // enforce uniform height
- .padding(.horizontal, 12)
- .background(Capsule(style: .continuous).fill(Color(UIColor.tertiarySystemBackground)))
}
- .buttonStyle(.plain)
- .contentShape(Rectangle())
+
}
-}
-
-}
// MARK: - TXM detection
public extension ProcessInfo {
- var hasTXM: Bool {
- if isTXMOverridden {
- return true
- }
-
- return {
- 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
+ var hasTXM: Bool {
+ if isTXMOverridden {
+ return true
}
- }()
+
+ return {
+ 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
+ }
+ }()
+ }
+
+ var isTXMOverridden: Bool {
+ UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
+ }
}
-
- var isTXMOverridden: Bool {
- UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
- }
-}
diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift
index 0951a0b..dbb7a9f 100644
--- a/StikJIT/Views/MainTabView.swift
+++ b/StikJIT/Views/MainTabView.swift
@@ -49,19 +49,27 @@ struct MainTabView: View {
#endif
}
- private let configurableTabs: [TabDescriptor] = [
- TabDescriptor(id: "home", title: "Home", systemImage: "house") { AnyView(HomeView()) },
- TabDescriptor(id: "console", title: "Console", systemImage: "terminal") { AnyView(ConsoleLogsView()) },
- TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) },
- TabDescriptor(id: "profiles", title: "Profiles", systemImage: "magazine.fill") { AnyView(ProfileView()) },
- TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) },
- TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) },
- TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) }
- ]
+ private var configurableTabs: [TabDescriptor] {
+ var tabs: [TabDescriptor] = [
+ TabDescriptor(id: "home", title: "Home", systemImage: "house") { AnyView(HomeView()) },
+ TabDescriptor(id: "console", title: "Console", systemImage: "terminal") { AnyView(ConsoleLogsView()) },
+ TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) },
+ TabDescriptor(id: "profiles", title: "Profiles", systemImage: "magazine.fill") { AnyView(ProfileView()) },
+ TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) }
+ ]
+ if FeatureFlags.showBetaTabs {
+ tabs.append(TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) })
+ tabs.append(TabDescriptor(id: "devicelibrary", title: "Devices", systemImage: "list.bullet.rectangle") { AnyView(DeviceLibraryView()) })
+ if FeatureFlags.isLocationSpoofingEnabled {
+ tabs.append(TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) })
+ }
+ }
+ return tabs
+ }
private var availableTabs: [TabDescriptor] {
configurableTabs.filter { descriptor in
- descriptor.id != "location" || (FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild)
+ descriptor.id != "location" || (!isAppStoreBuild && FeatureFlags.isLocationSpoofingEnabled && FeatureFlags.showBetaTabs)
}
}
diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift
index 0bd6917..26b0c66 100644
--- a/StikJIT/Views/SettingsView.swift
+++ b/StikJIT/Views/SettingsView.swift
@@ -90,11 +90,14 @@ struct SettingsView: View {
TabOption(id: "console", title: "Console", detail: "Live device logs", icon: "terminal", isBeta: false),
TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false),
TabOption(id: "profiles", title: "Profiles", detail: "Install/remove profiles", icon: "magazine.fill", isBeta: false),
- TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: true),
TabOption(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", icon: "iphone.and.arrow.forward", isBeta: false)
]
- if FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild {
- options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: true))
+ if FeatureFlags.showBetaTabs {
+ options.append(TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: true))
+ options.append(TabOption(id: "devicelibrary", title: "Devices", detail: "Manage external devices", icon: "list.bullet.rectangle", isBeta: true))
+ if FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild {
+ options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: true))
+ }
}
return options
}
@@ -266,7 +269,7 @@ struct SettingsView: View {
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
}
- Text("StikDebug")
+ Text("StikDebug 26")
.font(.title2.weight(.semibold))
.foregroundColor(.primary)
}
@@ -404,7 +407,6 @@ struct SettingsView: View {
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
)
}
-
if showPairingFileMessage && !isImportingFile {
HStack {
Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
@@ -571,14 +573,6 @@ struct SettingsView: View {
.padding(.vertical, 8)
}
- HStack(alignment: .center, spacing: 8) {
- Image(systemName: "shield.slash")
- .font(.system(size: 18))
- .foregroundColor(.primary.opacity(0.8))
- Text("You can turn off the VPN in the Settings app.")
- .foregroundColor(.secondary)
- }
- .padding(.top, 4)
}
}
}
diff --git a/StikJIT/idevice/heartbeat.m b/StikJIT/idevice/heartbeat.m
index fafe403..6e22cd2 100644
--- a/StikJIT/idevice/heartbeat.m
+++ b/StikJIT/idevice/heartbeat.m
@@ -10,6 +10,7 @@
#include
#include
#include "heartbeat.h"
+@import Foundation;
bool isHeartbeat = false;
@@ -25,7 +26,11 @@ void startHeartbeat(IdevicePairingFile* pairing_file, IdeviceProviderHandle** pr
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(LOCKDOWN_PORT);
- inet_pton(AF_INET, "10.7.0.2", &addr.sin_addr);
+ NSString *ipOverride = [[NSUserDefaults standardUserDefaults] stringForKey:@"TunnelDeviceIP"];
+ if (ipOverride.length == 0) {
+ ipOverride = @"10.7.0.2";
+ }
+ inet_pton(AF_INET, ipOverride.UTF8String, &addr.sin_addr);
IdeviceProviderHandle* newProvider = 0;
IdeviceFfiError* err = idevice_tcp_provider_new((struct sockaddr *)&addr, pairing_file,
diff --git a/StikJIT/idevice/profiles.h b/StikJIT/idevice/profiles.h
index 3bd5dd0..00f2e9f 100644
--- a/StikJIT/idevice/profiles.h
+++ b/StikJIT/idevice/profiles.h
@@ -9,9 +9,9 @@
#define PROFILES_H
#include "idevice.h"
#include
-NSArray* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** error);
-bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** error);
-bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** error);
+NSArray* _Nullable fetchAppProfiles(IdeviceProviderHandle* _Nonnull provider, NSError* _Nullable * _Nullable error);
+bool removeProfile(IdeviceProviderHandle* _Nonnull provider, NSString* _Nonnull uuid, NSError* _Nullable * _Nullable error);
+bool addProfile(IdeviceProviderHandle* _Nonnull provider, NSData* _Nonnull profile, NSError* _Nullable * _Nullable error);
@interface CMSDecoderHelper : NSObject
// Decode CMS/PKCS7 data and return decoded payload and any embedded certs
diff --git a/StikJIT/idevice/something.c b/StikJIT/idevice/something.c
index 3ee5da4..b71382f 100644
--- a/StikJIT/idevice/something.c
+++ b/StikJIT/idevice/something.c
@@ -59,7 +59,7 @@ int install_ipa(const char *ip,
addr.sin_port = htons(LOCKDOWN_PORT);
if (inet_pton(AF_INET, ip, &addr.sin_addr) != 1) {
fprintf(stderr, "Invalid IP address: %s\n", ip);
- return IPA_ERR_INVALID_IP;
+ return IPA_INSTALLER_ERR_INVALID_IP;
}
IdevicePairingFile *pairing_file = NULL;
@@ -69,7 +69,7 @@ int install_ipa(const char *ip,
fprintf(stderr, "Pairing file read failed: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
- return IPA_ERR_PAIRING_READ;
+ return IPA_INSTALLER_ERR_PAIRING_READ;
}
IdeviceProviderHandle *provider = NULL;
@@ -82,7 +82,7 @@ int install_ipa(const char *ip,
err->code, err->message);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_PROVIDER_CREATE;
+ return IPA_INSTALLER_ERR_PROVIDER_CREATE;
}
AfcClientHandle *afc = NULL;
@@ -93,7 +93,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_AFC_CONNECT;
+ return IPA_INSTALLER_ERR_AFC_CONNECT;
}
uint8_t *ipa_data = NULL;
@@ -103,7 +103,7 @@ int install_ipa(const char *ip,
afc_client_free(afc);
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
- return IPA_ERR_IPA_READ;
+ return IPA_INSTALLER_ERR_IPA_READ;
}
const char *slash = strrchr(ipa_path, '/');
@@ -121,7 +121,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_AFC_OPEN;
+ return IPA_INSTALLER_ERR_AFC_OPEN;
}
err = afc_file_write(remote, ipa_data, ipa_len);
@@ -134,7 +134,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_AFC_WRITE;
+ return IPA_INSTALLER_ERR_AFC_WRITE;
}
InstallationProxyClientHandle *ipc = NULL;
@@ -146,7 +146,7 @@ int install_ipa(const char *ip,
idevice_provider_free(provider);
idevice_pairing_file_free(pairing_file);
idevice_error_free(err);
- return IPA_ERR_INSTALLPROXY_CONNECT;
+ return IPA_INSTALLER_ERR_INSTALLPROXY;
}
err = installation_proxy_install(ipc, dest, NULL);
@@ -158,9 +158,9 @@ int install_ipa(const char *ip,
fprintf(stderr, "IPA install failed: [%d] %s\n",
err->code, err->message);
idevice_error_free(err);
- return IPA_ERR_INSTALL;
+ return IPA_INSTALLER_ERR_INSTALL;
}
fprintf(stderr, "IPA installed successfully\n");
- return IPA_OK;
+ return IPA_INSTALLER_OK;
}
diff --git a/StikJIT/idevice/something.h b/StikJIT/idevice/something.h
index f0e00eb..ccb1a8c 100644
--- a/StikJIT/idevice/something.h
+++ b/StikJIT/idevice/something.h
@@ -12,16 +12,16 @@
#include
typedef enum {
- IPA_OK = 0, /* success */
- IPA_ERR_PAIRING_READ = 1, /* could not read pairing file */
- IPA_ERR_PROVIDER_CREATE = 2, /* idevice_tcp_provider_new fail */
- IPA_ERR_AFC_CONNECT = 3, /* afc_client_connect fail */
- IPA_ERR_IPA_READ = 4, /* failed to mmap or read IPA */
- IPA_ERR_AFC_OPEN = 5, /* afc_file_open fail */
- IPA_ERR_AFC_WRITE = 6, /* afc_file_write fail */
- IPA_ERR_INSTALLPROXY_CONNECT = 7, /* installation_proxy connect */
- IPA_ERR_INSTALL = 8, /* installation_proxy_install */
- IPA_ERR_INVALID_IP = 9, /* inet_pton failed */
+ IPA_INSTALLER_OK = 0, /* success */
+ IPA_INSTALLER_ERR_PAIRING_READ = 1, /* could not read pairing file */
+ IPA_INSTALLER_ERR_PROVIDER_CREATE = 2, /* idevice_tcp_provider_new fail */
+ IPA_INSTALLER_ERR_AFC_CONNECT = 3, /* afc_client_connect fail */
+ IPA_INSTALLER_ERR_IPA_READ = 4, /* failed to mmap or read IPA */
+ IPA_INSTALLER_ERR_AFC_OPEN = 5, /* afc_file_open fail */
+ IPA_INSTALLER_ERR_AFC_WRITE = 6, /* afc_file_write fail */
+ IPA_INSTALLER_ERR_INSTALLPROXY = 7, /* installation_proxy connect */
+ IPA_INSTALLER_ERR_INSTALL = 8, /* installation_proxy_install */
+ IPA_INSTALLER_ERR_INVALID_IP = 9, /* inet_pton failed */
} ipa_installer_error_t;
int install_ipa(const char *ip,