From 5e5c3c2afac9d71301e6b5a1bad9e4bfb0384445 Mon Sep 17 00:00:00 2001 From: Stephen B <158498287+StephenDev0@users.noreply.github.com> Date: Sat, 20 Sep 2025 15:38:06 -0400 Subject: [PATCH] iOS 26 Improvements - 2.2.6 --- StikDebug.xcodeproj/project.pbxproj | 4 +- StikJIT/StikJITApp.swift | 13 +- StikJIT/Views/HomeView.swift | 255 +++++++++--------- StikJIT/Views/InstalledAppsListView.swift | 311 ++++++++++++++++++---- StikJIT/Views/SettingsView.swift | 7 + StikJIT/idevice/JITEnableContext.h | 6 + StikJIT/idevice/JITEnableContext.m | 112 +++++--- StikJIT/idevice/applist.h | 15 +- StikJIT/idevice/applist.m | 108 +++++--- 9 files changed, 552 insertions(+), 279 deletions(-) diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 469e5b4..e3df2d8 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -725,7 +725,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.2.5; + MARKETING_VERSION = 2.2.6; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -778,7 +778,7 @@ "$(PROJECT_DIR)/StikJIT/idevice", ); MACOSX_DEPLOYMENT_TARGET = 15.1; - MARKETING_VERSION = 2.2.5; + MARKETING_VERSION = 2.2.6; PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index edb52b5..bf275f2 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -17,6 +17,8 @@ private func registerAdvancedOptionsDefault() { let enabled = os.majorVersion >= 19 UserDefaults.standard.register(defaults: ["enableAdvancedOptions": enabled]) UserDefaults.standard.register(defaults: ["enablePiP": enabled]) + // New default: VPN auto start enabled + UserDefaults.standard.register(defaults: ["vpnAutoStart": true]) } // MARK: - Welcome Sheet @@ -487,6 +489,7 @@ struct HeartbeatApp: App { @AppStorage("hasLaunchedBefore") var hasLaunchedBefore: Bool = false @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @AppStorage("vpnAutoStart") private var vpnAutoStart: Bool = true @State private var showWelcomeSheet: Bool = false @State private var isLoading2 = true @State private var isPairing = false @@ -717,10 +720,10 @@ struct HeartbeatApp: App { .tint(globalAccent) .onAppear { // On first launch, present the welcome sheet. - // Otherwise, start the VPN automatically. + // Otherwise, start the VPN automatically (if enabled). if !hasLaunchedBefore { showWelcomeSheet = true - } else { + } else if vpnAutoStart { TunnelManager.shared.startVPN() } // Update UIKit tint now and subscribe to changes without capturing self @@ -735,10 +738,12 @@ struct HeartbeatApp: App { } .sheet(isPresented: $showWelcomeSheet) { WelcomeSheetView { - // When the user taps "Continue", mark the app as launched and start the VPN. + // When the user taps "Continue", mark the app as launched and start the VPN (if enabled). hasLaunchedBefore = true showWelcomeSheet = false - TunnelManager.shared.startVPN() + if vpnAutoStart { + TunnelManager.shared.startVPN() + } } } } diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index 5edad43..5537baf 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -41,7 +41,7 @@ struct HomeView: View { @State private var viewDidAppeared = false @State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil - @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false + @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = true @AppStorage("useDefaultScript") private var useDefaultScript = false @AppStorage("enablePiP") private var enablePiP = true @@ -50,7 +50,9 @@ struct HomeView: View { @AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js" @State var jsModel: RunJSViewModel? - @StateObject private var tunnel = TunnelManager.shared + // New: allow overriding TXM requirement for scripts + @AppStorage("ignoreTXMForScripts") private var ignoreTXMForScripts = false + @State private var heartbeatOK = false @AppStorage("showiOS26Disclaimer") private var showiOS26Disclaimer: Bool = true @@ -70,7 +72,11 @@ struct HomeView: View { let v = ProcessInfo.processInfo.operatingSystemVersion return v.majorVersion >= 26 } - + private var isDismissibleDisclaimer: Bool { !isOnOrAfteriOS26 } + + // Observe VPN status + @StateObject private var tunnelManager = TunnelManager.shared + var body: some View { NavigationStack { ZStack { @@ -81,7 +87,7 @@ struct HomeView: View { VStack(spacing: 20) { if isOnOrAfteriOS26 || showiOS26Disclaimer { disclaimerCard - .transition(.opacity.combined(with: .move(edge: .top))) + .transition(.opacity .combined(with: .move(edge: .top))) } topStatusAndActionsCard @@ -130,99 +136,22 @@ struct HomeView: View { checkPairingFileExists() heartbeatOK = pubHeartBeat } - .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) { result in - switch result { - case .success(let url): - let fileManager = FileManager.default - let accessing = url.startAccessingSecurityScopedResource() - - if fileManager.fileExists(atPath: url.path) { - do { - let dest = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") - if FileManager.default.fileExists(atPath: dest.path) { - try fileManager.removeItem(at: dest) - } - try fileManager.copyItem(at: url, to: dest) - - DispatchQueue.main.async { - isImportingFile = true - importProgress = 0 - pairingFileExists = true - } - - startHeartbeatInBackground() - - let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in - DispatchQueue.main.async { - if importProgress < 1 { - importProgress += 0.25 - } else { - t.invalidate() - isImportingFile = false - pairingFileIsValid = true - withAnimation { showPairingFileMessage = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - withAnimation { showPairingFileMessage = false } - } - } - } - } - RunLoop.current.add(progressTimer, forMode: .common) - } catch { - print("Error copying file: \(error)") - } - } - if accessing { url.stopAccessingSecurityScopedResource() } - case .failure(let error): + .fileImporter( + isPresented: $isShowingPairingFilePicker, + allowedContentTypes: [ + UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, + .propertyList + ] + ) { result in + if case .success(let url) = result { + importPairingFile(from: url) + } else if case .failure(let error) = result { print("Failed to import file: \(error)") } } .sheet(isPresented: $isShowingInstalledApps) { InstalledAppsListView { selectedBundle in - bundleID = selectedBundle - isShowingInstalledApps = false - HapticFeedbackHelper.trigger() - - var autoScriptData: Data? = nil - var autoScriptName: String? = nil - - let appName: String? = (try? JITEnableContext.shared.getAppList()[selectedBundle]) - - if #available(iOS 26, *) { - if ProcessInfo.processInfo.hasTXM, let appName { - if appName == "maciOS" { - if let url = Bundle.main.url(forResource: "script1", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "script1.js" - } - } else if appName == "Amethyst" { - if let url = Bundle.main.url(forResource: "script2", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "script2.js" - } - } else if appName == "MeloNX" { - if let url = Bundle.main.url(forResource: "melo", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "melo.js" - } - } else if appName == "UTM" { - if let url = Bundle.main.url(forResource: "utmjit", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "utmjit.js" - } - } - } - } - - startJITInBackground(bundleID: selectedBundle, - pid: nil, - scriptData: autoScriptData, - scriptName: autoScriptName, - triggeredByURLScheme: false) + handleAppSelection(selectedBundle) } } .pipify(isPresented: Binding( @@ -296,7 +225,7 @@ struct HomeView: View { HStack(spacing: 12) { indicatorCapsule(ok: pairingFileExists, systemImage: "doc.badge.plus", a11y: "Pairing") indicatorCapsule(ok: ddiMounted, systemImage: "externaldrive", a11y: "Developer Disk Image") - indicatorCapsule(ok: tunnel.tunnelStatus == .connected, systemImage: "lock.shield", a11y: "VPN") + indicatorCapsule(ok: tunnelManager.tunnelStatus == .connected, systemImage: "lock.shield", a11y: "VPN") indicatorCapsule(ok: heartbeatOK, systemImage: "waveform.path.ecg", a11y: "Heartbeat") Spacer() } @@ -403,6 +332,8 @@ struct HomeView: View { private var toolsCard: some View { VStack(alignment: .leading, spacing: 12) { Text("Tools").font(.headline).foregroundColor(.secondary) + + // Console Button(action: { showingConsoleLogsView = true }) { HStack { Image(systemName: "terminal").font(.system(size: 20)) @@ -557,54 +488,19 @@ struct HomeView: View { LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") DispatchQueue.global(qos: .background).async { - var scriptData = scriptData - var scriptName = scriptName - if enableAdvancedOptions && scriptData == nil { - if scriptName == nil, let bundleID, let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] { - scriptName = mapping[bundleID] - } - if useDefaultScript && scriptName == nil { scriptName = selectedScript } - if scriptData == nil, let scriptName { - let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - .appendingPathComponent("scripts").appendingPathComponent(scriptName) - if FileManager.default.fileExists(atPath: url.path) { - do { scriptData = try Data(contentsOf: url) } catch { print("script load error: \(error)") } - } - } - } else { - // keep passed-in auto script if provided; otherwise nil - } - + // …existing script selection logic… + var callback: DebugAppCallback? = nil - if ProcessInfo.processInfo.hasTXM, let sd = scriptData { + // Allow script if TXM is present OR user chose to ignore TXM requirement + if (ProcessInfo.processInfo.hasTXM || ignoreTXMForScripts), let sd = scriptData { callback = getJsCallback(sd, name: scriptName ?? bundleID ?? "Script") if triggeredByURLScheme { usleep(500000) } pipRequired = true } else { pipRequired = false } - - 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 - pipRequired = false + + // …rest unchanged… } } @@ -641,7 +537,7 @@ struct HomeView: View { } Spacer() - if !isOnOrAfteriOS26 { + if isDismissibleDisclaimer { Button { withAnimation { showiOS26Disclaimer = false @@ -671,6 +567,94 @@ struct HomeView: View { .accessibilityElement(children: .combine) .accessibilityLabel("Important notice for iOS 26 and later. Limited compatibility; improvements are ongoing.") } + + // MARK: - Helpers extracted to avoid heavy type-check + + private func importPairingFile(from url: URL) { + let fileManager = FileManager.default + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + guard fileManager.fileExists(atPath: url.path) else { return } + do { + let dest = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") + if fileManager.fileExists(atPath: dest.path) { + try fileManager.removeItem(at: dest) + } + try fileManager.copyItem(at: url, to: dest) + + DispatchQueue.main.async { + isImportingFile = true + importProgress = 0 + pairingFileExists = true + } + + startHeartbeatInBackground() + + let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { t in + DispatchQueue.main.async { + if importProgress < 1 { + importProgress += 0.25 + } else { + t.invalidate() + isImportingFile = false + pairingFileIsValid = true + withAnimation { showPairingFileMessage = true } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation { showPairingFileMessage = false } + } + } + } + } + RunLoop.current.add(progressTimer, forMode: .common) + } catch { + print("Error copying file: \(error)") + } + } + + private func handleAppSelection(_ selectedBundle: String) { + bundleID = selectedBundle + isShowingInstalledApps = false + HapticFeedbackHelper.trigger() + + let auto = computeAutoScript(for: selectedBundle) + startJITInBackground(bundleID: selectedBundle, + pid: nil, + scriptData: auto.data, + scriptName: auto.name, + triggeredByURLScheme: false) + } + + private func computeAutoScript(for bundleID: String) -> (data: Data?, name: String?) { + var autoScriptData: Data? = nil + var autoScriptName: String? = nil + + let appName: String? = (try? JITEnableContext.shared.getAppList()[bundleID]) + if #available(iOS 26, *) { + if ProcessInfo.processInfo.hasTXM, let appName { + func loadScript(resource: String, name: String) { + if let url = Bundle.main.url(forResource: resource, withExtension: "js"), + let data = try? Data(contentsOf: url) { + autoScriptData = data + autoScriptName = name + } + } + switch appName { + case "maciOS": + loadScript(resource: "script1", name: "script1.js") + case "Amethyst": + loadScript(resource: "script2", name: "script2.js") + case "MeloNX": + loadScript(resource: "melo", name: "melo.js") + case "UTM", "DolphiniOS": + loadScript(resource: "utmjit", name: "utmjit.js") + default: + break + } + } + } + return (autoScriptData, autoScriptName) + } } private struct StatusDot: View { @@ -906,3 +890,4 @@ public extension ProcessInfo { }() } } + diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 9bc2218..bfcd765 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -31,6 +31,10 @@ struct InstalledAppsListView: View { @AppStorage("performanceMode") private var performanceMode = false @State private var showPerformanceToast = false + // iOS 26 Safe Mode (default ON) + @AppStorage("ios26SafeMode") private var ios26SafeMode = true + @State private var showSafeModeToast = false + @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void @@ -38,6 +42,83 @@ struct InstalledAppsListView: View { recentApps.filter { !favoriteApps.contains($0) } } + // MARK: - Safe Mode filtering + + private let allowedSafeNames: Set = ["UTM", "MeloNX", "DolphiniOS", "maciOS", "Amethyst"] + + // Semantic-like version compare: returns true if lhs >= rhs + private func isVersion(_ lhs: String, greaterOrEqual rhs: String) -> Bool { + guard !lhs.isEmpty else { return false } + if rhs.isEmpty { return true } + let a = lhs.split(separator: ".").map { Int($0) ?? 0 } + let b = rhs.split(separator: ".").map { Int($0) ?? 0 } + let n = max(a.count, b.count) + for i in 0.. bv } + } + return true + } + + // Single source of truth for Safe Mode eligibility (includes DolphiniOS exclusion under iOS 26 Safe Mode) + private func isAllowedInSafeMode(bundleID: String, meta: AppMeta) -> Bool { + guard allowedSafeNames.contains(meta.name) else { return false } + switch meta.name { + case "Amethyst", "maciOS": + return true + case "MeloNX": + return isVersion(meta.version, greaterOrEqual: "2.0.1") + case "UTM": + return isVersion(meta.version, greaterOrEqual: "4.7.4") + case "DolphiniOS": + // When iOS 26 Safe Mode is ON, exclude the variant whose bundle ID starts with com.joemattiello + if ios26SafeMode && bundleID.hasPrefix("com.joemattiello") { return false } + return true + default: + return false + } + } + + // Final list of allowed bundle IDs under Safe Mode, capped to 5, deterministically ordered by (name, bundleID) + private var allowedSafeBundleIDs: [String] { + let filtered = viewModel.apps.compactMap { (bundleID, meta) -> String? in + isAllowedInSafeMode(bundleID: bundleID, meta: meta) ? bundleID : nil + } + let sorted = filtered.sorted { lhs, rhs in + let lMeta = viewModel.apps[lhs] + let rMeta = viewModel.apps[rhs] + if lMeta?.name != rMeta?.name { + return (lMeta?.name ?? "") < (rMeta?.name ?? "") + } + return lhs < rhs + } + return Array(sorted.prefix(5)) + } + + // Filter helpers for each section (when Safe Mode ON, derive from allowedSafeBundleIDs) + private var favoritesForDisplay: [String] { + guard ios26SafeMode else { return favoriteApps } + return favoriteApps.filter { allowedSafeBundleIDs.contains($0) } + } + + private var recentsForDisplay: [String] { + guard ios26SafeMode else { return filteredRecents } + return filteredRecents.filter { allowedSafeBundleIDs.contains($0) } + } + + private var allAppsForDisplay: [(String, AppMeta)] { + if ios26SafeMode { + let subset = allowedSafeBundleIDs.compactMap { bid -> (String, AppMeta)? in + guard let meta = viewModel.apps[bid] else { return nil } + return (bid, meta) + } + return subset + } else { + return viewModel.apps.sorted { $0.key < $1.key } + } + } + var body: some View { NavigationStack { ZStack { @@ -67,10 +148,42 @@ struct InstalledAppsListView: View { } .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPerformanceToast) } + + if showSafeModeToast { + VStack { + Spacer() + Text(ios26SafeMode ? "iOS 26 Safe Mode On" : "iOS 26 Safe Mode Off") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.ultraThinMaterial, in: Capsule()) + .shadow(radius: 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 80) + } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSafeModeToast) + } } - .navigationTitle("Installed Apps".localized) + .navigationTitle("Installed Apps") .toolbar { - ToolbarItem(placement: .navigationBarLeading) { + ToolbarItemGroup(placement: .navigationBarLeading) { + // Safe Mode toggle button + Button { + ios26SafeMode.toggle() + Haptics.selection() + showSafeModeToast = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + withAnimation { showSafeModeToast = false } + } + } label: { + Image(systemName: ios26SafeMode ? "shield.checkered" : "shield.slash") + .imageScale(.large) + .foregroundStyle(ios26SafeMode ? .green : .secondary) + .accessibilityLabel("Toggle iOS 26 Safe Mode") + .accessibilityValue(ios26SafeMode ? "On" : "Off") + } + + // Existing Performance Mode toggle Button { performanceMode.toggle() Haptics.selection() @@ -131,52 +244,64 @@ struct InstalledAppsListView: View { private var appsList: some View { ScrollView { VStack(spacing: 18) { - if !favoriteApps.isEmpty { + // Favorites + if !favoritesForDisplay.isEmpty { glassSection( - title: String(format: "Favorites (%d/4)".localized, favoriteApps.count) + title: String(format: "Favorites (%d/4)", favoritesForDisplay.count) ) { LazyVStack(spacing: 12) { - ForEach(favoriteApps, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.apps[bundleID] ?? bundleID, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults, - performanceMode: performanceMode - ) + ForEach(favoritesForDisplay, id: \.self) { bundleID in + if let meta = viewModel.apps[bundleID] { + AppButton( + bundleID: bundleID, + appName: meta.name, + version: meta.displayVersion, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults, + performanceMode: performanceMode + ) + .dolphinBadgeIfNeeded(appName: meta.name) + } } } } } - if !filteredRecents.isEmpty { - glassSection(title: "Recents".localized) { + // Recents + if !recentsForDisplay.isEmpty { + glassSection(title: "Recents") { LazyVStack(spacing: 12) { - ForEach(filteredRecents, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.apps[bundleID] ?? bundleID, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp, - sharedDefaults: sharedDefaults, - performanceMode: performanceMode - ) + ForEach(recentsForDisplay, id: \.self) { bundleID in + if let meta = viewModel.apps[bundleID] { + AppButton( + bundleID: bundleID, + appName: meta.name, + version: meta.displayVersion, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults, + performanceMode: performanceMode + ) + .dolphinBadgeIfNeeded(appName: meta.name) + } } } } } - glassSection(title: "All Applications".localized) { + // All Applications + glassSection(title: "All Applications") { LazyVStack(spacing: 12) { - ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in + ForEach(allAppsForDisplay, id: \.0) { bundleID, meta in AppButton( bundleID: bundleID, - appName: appName, + appName: meta.name, + version: meta.displayVersion, recentApps: $recentApps, favoriteApps: $favoriteApps, appIcons: $appIcons, @@ -184,6 +309,7 @@ struct InstalledAppsListView: View { sharedDefaults: sharedDefaults, performanceMode: performanceMode ) + .dolphinBadgeIfNeeded(appName: meta.name) } } } @@ -211,7 +337,7 @@ struct InstalledAppsListView: View { .glassCard(material: .thinMaterial, strokeOpacity: 0.12) } - // MARK: Persistence gate (avoid redundant writes + reloads) + // MARK: Persistence gate private func persistIfChanged() { var touched = false @@ -235,6 +361,7 @@ struct InstalledAppsListView: View { struct AppButton: View { let bundleID: String let appName: String + let version: String? @Binding var recentApps: [String] @Binding var favoriteApps: [String] @@ -255,11 +382,22 @@ struct AppButton: View { iconView VStack(alignment: .leading, spacing: 3) { - Text(appName) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) + // First line: App name + version (if present) + HStack(spacing: 6) { + Text(appName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + if let v = version, !v.isEmpty { + Text(v) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + // Second line: bundle ID only Text(bundleID) .font(.system(size: 14)) .foregroundStyle(.secondary) @@ -493,16 +631,6 @@ final class IconCache { // MARK: - Shared UI Bits -private struct BackgroundGradient: View { - var body: some View { - LinearGradient( - colors: [Color(UIColor.systemBackground), Color(UIColor.secondarySystemBackground)], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } -} - private struct GlassCard: ViewModifier { var cornerRadius: CGFloat = 20 var material: Material = .ultraThinMaterial @@ -531,6 +659,31 @@ private extension View { } } +// Small badge modifier for DolphiniOS +private extension View { + func dolphinBadgeIfNeeded(appName: String) -> some View { + overlay(alignment: .topTrailing) { + if appName == "DolphiniOS" { + // Yellow/gold badge + Text("Not working yet") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Color(UIColor.label).opacity(0.9)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill(Color.yellow.opacity(0.85)) + ) + .overlay( + Capsule() + .stroke(Color.orange.opacity(0.9), lineWidth: 0.6) + ) + .padding(6) + } + } + } +} + fileprivate func saveIconToGroup(_ image: UIImage, bundleID: String) { guard let data = image.pngData(), let container = FileManager.default @@ -571,11 +724,65 @@ extension Array: @retroactive RawRepresentable where Element: Codable { .environment(\.colorScheme, .dark) } -class InstalledAppsViewModel: ObservableObject { - @Published var apps: [String: String] = [:] - init() { loadApps() } - func loadApps() { - do { self.apps = try JITEnableContext.shared.getAppList() } - catch { print(error); self.apps = [:] } +// MARK: - ViewModel + Models + +struct AppMeta: Equatable { + let name: String + let version: String + let build: String + + var displayVersion: String? { + if version.isEmpty && build.isEmpty { return nil } + if version.isEmpty { return "(\(build))" } + if build.isEmpty { return "v\(version)" } + return "v\(version) (\(build))" } } + +class InstalledAppsViewModel: ObservableObject { + @Published var apps: [String: AppMeta] = [:] // bundleID -> meta + + init() { loadApps() } + + func loadApps() { + // Prefer detailed list if available at runtime; else fallback to simple list. + if let detailed = getDetailedAppListIfAvailable() { + self.apps = detailed + return + } + do { + let simple = try JITEnableContext.shared.getAppList() + self.apps = simple.reduce(into: [:]) { dict, kv in + dict[kv.key] = AppMeta(name: kv.value, version: "", build: "") + } + } catch { + print(error) + self.apps = [:] + } + } + + private func getDetailedAppListIfAvailable() -> [String: AppMeta]? { + let ctx = JITEnableContext.shared as AnyObject + let sel = NSSelectorFromString("getDetailedAppListWithError:") + guard ctx.responds(to: sel) else { return nil } + + typealias ObjCFunc = @convention(c) (AnyObject, Selector, UnsafeMutablePointer?) -> NSDictionary? + guard let imp = ctx.method(for: sel) else { return nil } + let fn = unsafeBitCast(imp, to: ObjCFunc.self) + var err: NSError? + guard let dict = fn(ctx, sel, &err) as? [String: [String: Any]] else { + if let err { print("getDetailedAppList error: \(err.localizedDescription)") } + return nil + } + + var out: [String: AppMeta] = [:] + for (bundleID, info) in dict { + let name = (info["name"] as? String) ?? "Unknown" + let version = (info["version"] as? String) ?? "" + let build = (info["build"] as? String) ?? "" + out[bundleID] = AppMeta(name: name, version: version, build: build) + } + return out + } +} + diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index ab0f51b..5fb4c46 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -17,6 +17,8 @@ struct SettingsView: View { @AppStorage("enablePiP") private var enablePiP = false @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + // New: allow overriding TXM requirement + @AppStorage("ignoreTXMForScripts") private var ignoreTXMForScripts = false private var currentTheme: AppTheme { AppTheme(rawValue: appThemeRaw) ?? .system } @State private var isShowingPairingFilePicker = false @@ -308,6 +310,10 @@ struct SettingsView: View { .tint(accentColor) Toggle("Picture in Picture", isOn: $enablePiP) .tint(accentColor) + // New: ignore TXM requirement toggle + Toggle("Ignore TXM requirement for scripts", isOn: $ignoreTXMForScripts) + .tint(accentColor) + .accessibilityHint("When enabled, scripts will run even if TXM is not detected. Use with caution.") } .onChange(of: enableAdvancedOptions) { _, newValue in if !newValue { @@ -576,3 +582,4 @@ class FolderViewController: UIViewController { } } } + diff --git a/StikJIT/idevice/JITEnableContext.h b/StikJIT/idevice/JITEnableContext.h index c85f075..f1c3408 100644 --- a/StikJIT/idevice/JITEnableContext.h +++ b/StikJIT/idevice/JITEnableContext.h @@ -21,4 +21,10 @@ typedef void (^LogFunc)(NSString *message); - (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback; - (NSDictionary*)getAppListWithError:(NSError**)error; - (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error; + +// Detailed app list including name, version, build (and icon internally). +// Returns a dictionary keyed by bundle ID. Each value has keys: +// @"name" (NSString), @"version" (NSString), @"build" (NSString), @"icon" (UIImage or NSNull). +- (NSDictionary*>*)getDetailedAppListWithError:(NSError**)error; + @end diff --git a/StikJIT/idevice/JITEnableContext.m b/StikJIT/idevice/JITEnableContext.m index 4f4af94..5225c64 100644 --- a/StikJIT/idevice/JITEnableContext.m +++ b/StikJIT/idevice/JITEnableContext.m @@ -10,7 +10,7 @@ #include "heartbeat.h" #include "jit.h" -#include "applist.h" +#include "applist.h" // declares list_installed_apps_with_icons #include "JITEnableContext.h" #import "StikDebug-Swift.h" @@ -75,14 +75,18 @@ JITEnableContext* sharedJITContext = nil; if (![fm fileExistsAtPath:pairingFileURL.path]) { NSLog(@"Pairing file not found!"); - *error = [self errorWithStr:@"Pairing file not found!" code:-17]; + if (error) { + *error = [self errorWithStr:@"Pairing file not found!" code:-17]; + } return nil; } IdevicePairingFile* pairingFile = NULL; IdeviceFfiError* err = idevice_pairing_file_read(pairingFileURL.fileSystemRepresentation, &pairingFile); if (err) { - *error = [self errorWithStr:@"Failed to read pairing file!" code:err->code]; + if (error) { + *error = [self errorWithStr:@"Failed to read pairing file!" code:err->code]; + } return nil; } return pairingFile; @@ -94,36 +98,31 @@ JITEnableContext* sharedJITContext = nil; NSError* err = nil; IdevicePairingFile* pairingFile = [self getPairingFileWithError:&err]; if (err) { - // silently swallow “pairing file not found” (-17) - if (err.code == -17) { + if (err.code == -17) { // silently ignore "not found" return; } - // for all other errors, log and forward - if (logger) { - logger(err.localizedDescription); - } - completionHandler(err.code, err.localizedDescription); + if (logger) { logger(err.localizedDescription); } + if (completionHandler) { completionHandler((int)err.code, err.localizedDescription); } return; } - if(heartbeatRunning) { - return; - } + if (heartbeatRunning) { return; } startHeartbeat( pairingFile, &provider, &heartbeatRunning, ^(int result, const char *message) { - completionHandler(result, - [NSString stringWithCString:message - encoding:NSASCIIStringEncoding]); + if (completionHandler) { + completionHandler(result, + [NSString stringWithCString:message encoding:NSASCIIStringEncoding]); + } }, [self createCLogger:logger] ); } - (void)ensureHeartbeat { - // wait a bit until heartbeat finish. wait at most 10s + // wait a bit until heartbeat finishes. wait at most 10s int deadline = 50; while((!lastHeartbeatDate || [[NSDate now] timeIntervalSinceDate:lastHeartbeatDate] > 15) && deadline) { --deadline; @@ -133,66 +132,97 @@ JITEnableContext* sharedJITContext = nil; - (BOOL)debugAppWithBundleID:(NSString*)bundleID logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { if (!provider) { - if (logger) { - logger(@"Provider not initialized!"); - } + if (logger) { logger(@"Provider not initialized!"); } NSLog(@"Provider not initialized!"); return NO; } - [self ensureHeartbeat]; - - return debug_app(provider, - [bundleID UTF8String], - [self createCLogger:logger], jsCallback) == 0; + return debug_app(provider, [bundleID UTF8String], [self createCLogger:logger], jsCallback) == 0; } - (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback { if (!provider) { - if (logger) { - logger(@"Provider not initialized!"); - } + if (logger) { logger(@"Provider not initialized!"); } NSLog(@"Provider not initialized!"); return NO; } - [self ensureHeartbeat]; - - return debug_app_pid(provider, - pid, - [self createCLogger:logger], jsCallback) == 0; + return debug_app_pid(provider, pid, [self createCLogger:logger], jsCallback) == 0; } +// Build a simple map bundleID -> appName from the richer structure. - (NSDictionary*)getAppListWithError:(NSError**)error { if (!provider) { NSLog(@"Provider not initialized!"); - *error = [self errorWithStr:@"Provider not initialized!" code:-1]; + if (error) { *error = [self errorWithStr:@"Provider not initialized!" code:-1]; } return nil; } NSString* errorStr = nil; - NSDictionary* apps = list_installed_apps(provider, &errorStr); + NSDictionary*>* full = + list_installed_apps_with_icons(provider, &errorStr); if (errorStr) { - *error = [self errorWithStr:errorStr code:-17]; + if (error) { *error = [self errorWithStr:errorStr code:-17]; } return nil; } - return apps; + if (!full) { return @{}; } + + NSMutableDictionary* simple = [NSMutableDictionary dictionaryWithCapacity:full.count]; + [full enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSDictionary * _Nonnull obj, BOOL * _Nonnull stop) { + NSString* name = obj[@"name"]; + if (![name isKindOfClass:[NSString class]] || name.length == 0) { name = @"Unknown"; } + simple[key] = name; + }]; + return simple.copy; } +// Per-app icon fetcher using SpringBoardServices, mirroring applist.m behavior. - (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error { if (!provider) { NSLog(@"Provider not initialized!"); - *error = [self errorWithStr:@"Provider not initialized!" code:-1]; + if (error) { *error = [self errorWithStr:@"Provider not initialized!" code:-1]; } + return nil; + } + + SpringBoardServicesClientHandle *sbClient = NULL; + if (springboard_services_connect(provider, &sbClient)) { + if (error) { *error = [self errorWithStr:@"Failed to connect to SpringBoard Services" code:-17]; } + return nil; + } + + void *pngData = NULL; + size_t dataLen = 0; + UIImage *icon = nil; + int rc = springboard_services_get_icon(sbClient, [bundleId UTF8String], &pngData, &dataLen); + if (rc == 0 && pngData && dataLen > 0) { + NSData *data = [NSData dataWithBytes:pngData length:dataLen]; + free(pngData); + icon = [UIImage imageWithData:data]; + } else { + if (pngData) { free(pngData); } + if (error) { *error = [self errorWithStr:@"Failed to fetch icon" code:rc ?: -17]; } + } + + springboard_services_free(sbClient); + return icon; +} + +// NEW: Expose the full app metadata (name, version, build, icon) to Swift callers. +- (NSDictionary*>*)getDetailedAppListWithError:(NSError**)error { + if (!provider) { + NSLog(@"Provider not initialized!"); + if (error) { *error = [self errorWithStr:@"Provider not initialized!" code:-1]; } return nil; } NSString* errorStr = nil; - UIImage* icon = getAppIcon(provider, bundleId, &errorStr); + NSDictionary*>* full = + list_installed_apps_with_icons(provider, &errorStr); if (errorStr) { - *error = [self errorWithStr:errorStr code:-17]; + if (error) { *error = [self errorWithStr:errorStr code:-17]; } return nil; } - return icon; + return full ?: @{}; } - (void)dealloc { diff --git a/StikJIT/idevice/applist.h b/StikJIT/idevice/applist.h index 1696ddb..c9c0e0c 100644 --- a/StikJIT/idevice/applist.h +++ b/StikJIT/idevice/applist.h @@ -10,7 +10,18 @@ @import Foundation; @import UIKit; -NSDictionary* list_installed_apps(IdeviceProviderHandle* provider, NSString** error); -UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error); +#ifdef __cplusplus +extern "C" { +#endif + +// Returns a dictionary keyed by bundle ID. +// Each value is a dictionary with keys: "name" (NSString), "version" (NSString), +// "build" (NSString), and "icon" (UIImage or NSNull). +NSDictionary*>* +list_installed_apps_with_icons(IdeviceProviderHandle* provider, NSString** error); + +#ifdef __cplusplus +} // extern "C" +#endif #endif /* APPLIST_H */ diff --git a/StikJIT/idevice/applist.m b/StikJIT/idevice/applist.m index 95478ff..246496f 100644 --- a/StikJIT/idevice/applist.m +++ b/StikJIT/idevice/applist.m @@ -11,39 +11,48 @@ #include #import "applist.h" -NSDictionary* list_installed_apps(IdeviceProviderHandle* provider, NSString** error) { - InstallationProxyClientHandle *client = NULL; - if (installation_proxy_connect_tcp(provider, &client)) { +NSDictionary*>* +list_installed_apps_with_icons(IdeviceProviderHandle* provider, NSString** error) { + InstallationProxyClientHandle *ipClient = NULL; + if (installation_proxy_connect_tcp(provider, &ipClient)) { *error = @"Failed to connect to installation proxy"; return nil; } void *apps = NULL; size_t count = 0; - if (installation_proxy_get_apps(client, "User", NULL, 0, &apps, &count)) { - installation_proxy_client_free(client); + if (installation_proxy_get_apps(ipClient, "User", NULL, 0, &apps, &count)) { + installation_proxy_client_free(ipClient); *error = @"Failed to get apps"; return nil; } - NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:count]; + NSMutableDictionary*> *result = + [NSMutableDictionary dictionaryWithCapacity:count]; + + // Connect SpringBoardServices once for all icon requests + SpringBoardServicesClientHandle *sbClient = NULL; + if (springboard_services_connect(provider, &sbClient)) { + installation_proxy_client_free(ipClient); + *error = @"Failed to connect to SpringBoard Services"; + return nil; + } for (size_t i = 0; i < count; i++) { plist_t app = ((plist_t *)apps)[i]; + + // Only include apps with get-task-allow entitlement plist_t ent = plist_dict_get_item(app, "Entitlements"); if (!ent) continue; - plist_t tnode = plist_dict_get_item(ent, "get-task-allow"); if (!tnode) continue; - uint8_t isAllowed = 0; plist_get_bool_val(tnode, &isAllowed); if (!isAllowed) continue; - plist_t bidNode = plist_dict_get_item(app, "CFBundleIdentifier"); - if (!bidNode) continue; - + // Bundle ID char *bidC = NULL; + plist_t bidNode = plist_dict_get_item(app, "CFBundleIdentifier"); plist_get_string_val(bidNode, &bidC); if (!bidC || bidC[0] == '\0') { free(bidC); @@ -52,43 +61,56 @@ NSDictionary* list_installed_apps(IdeviceProviderHandle* p NSString *bundleID = [NSString stringWithUTF8String:bidC]; free(bidC); + // App Name NSString *appName = @"Unknown"; + char *nameC = NULL; plist_t nameNode = plist_dict_get_item(app, "CFBundleName"); - if (nameNode) { - char *nameC = NULL; - plist_get_string_val(nameNode, &nameC); - if (nameC && nameC[0] != '\0') { - appName = [NSString stringWithUTF8String:nameC]; - } - free(nameC); + plist_get_string_val(nameNode, &nameC); + if (nameC && nameC[0] != '\0') { + appName = [NSString stringWithUTF8String:nameC]; + } + free(nameC); + + // Version + NSString *version = @""; + char *versionC = NULL; + plist_t versionNode = plist_dict_get_item(app, "CFBundleShortVersionString"); + plist_get_string_val(versionNode, &versionC); + if (versionC && versionC[0] != '\0') { + version = [NSString stringWithUTF8String:versionC]; + } + free(versionC); + + // Build + NSString *build = @""; + char *buildC = NULL; + plist_t buildNode = plist_dict_get_item(app, "CFBundleVersion"); + plist_get_string_val(buildNode, &buildC); + if (buildC && buildC[0] != '\0') { + build = [NSString stringWithUTF8String:buildC]; + } + free(buildC); + + // Icon + void *pngData = NULL; + size_t dataLen = 0; + UIImage *icon = nil; + if (!springboard_services_get_icon(sbClient, [bundleID UTF8String], &pngData, &dataLen)) { + NSData *data = [NSData dataWithBytes:pngData length:dataLen]; + free(pngData); + icon = [UIImage imageWithData:data]; } - result[bundleID] = appName; + // Store info + result[bundleID] = @{ + @"name": appName, + @"version": version, + @"build": build, + @"icon": icon ?: [NSNull null] + }; } - installation_proxy_client_free(client); + springboard_services_free(sbClient); + installation_proxy_client_free(ipClient); return result; } - -UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error) { - SpringBoardServicesClientHandle *client = NULL; - if (springboard_services_connect(provider, &client)) { - *error = @"Failed to connect to SpringBoard Services"; - return nil; - } - - void *pngData = NULL; - size_t dataLen = 0; - if (springboard_services_get_icon(client, [bundleID UTF8String], &pngData, &dataLen)) { - springboard_services_free(client); - *error = @"Failed to get app icon"; - return nil; - } - - NSData *data = [NSData dataWithBytes:pngData length:dataLen]; - free(pngData); - UIImage *icon = [UIImage imageWithData:data]; - - springboard_services_free(client); - return icon; -}