mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
Add device library and external device support (Semi WIP)
Introduces DeviceLibraryStore and DeviceConnectionContext to manage external device profiles and connections. Updates app logic to support switching between local loopback VPN and direct external device connections, including UI for device management and improved connection handling throughout the app.
This commit is contained in:
parent
df2fb37475
commit
06b64192c4
18 changed files with 2347 additions and 1387 deletions
|
|
@ -28,5 +28,12 @@
|
|||
<false/>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>StikDebug needs access to devices on your local network so it can connect to the targets you add to the Device Library.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_stikdebug._tcp</string>
|
||||
<string>_stikdebug._udp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@
|
|||
#include "idevice/ideviceinfo.h"
|
||||
#include "idevice/ls.h"
|
||||
#include "idevice/profiles.h"
|
||||
#include "idevice/something.h"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
25
StikJIT/Utilities/DeviceConnectionContext.swift
Normal file
25
StikJIT/Utilities/DeviceConnectionContext.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
265
StikJIT/Utilities/DeviceLibraryStore.swift
Normal file
265
StikJIT/Utilities/DeviceLibraryStore.swift
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Void, Never>?
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
503
StikJIT/Views/DeviceLibraryView.swift
Normal file
503
StikJIT/Views/DeviceLibraryView.swift
Normal file
|
|
@ -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<Content: View>(@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
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <limits.h>
|
||||
#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,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@
|
|||
#define PROFILES_H
|
||||
#include "idevice.h"
|
||||
#include <Foundation/Foundation.h>
|
||||
NSArray<NSData*>* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** error);
|
||||
bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** error);
|
||||
bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** error);
|
||||
NSArray<NSData*>* _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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,16 +12,16 @@
|
|||
#include <stdint.h>
|
||||
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue