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,