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:
Stephen B 2025-12-06 15:20:23 -05:00
parent df2fb37475
commit 06b64192c4
18 changed files with 2347 additions and 1387 deletions

View file

@ -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>

View file

@ -10,3 +10,4 @@
#include "idevice/ideviceinfo.h"
#include "idevice/ls.h"
#include "idevice/profiles.h"
#include "idevice/something.h"

View file

@ -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

View 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"
}
}

View 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
}
}

View file

@ -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"
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View 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

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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,

View 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

View file

@ -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;
}

View file

@ -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,