iOS 26 Improvements - 2.2.6

This commit is contained in:
Stephen B 2025-09-20 15:38:06 -04:00
parent 9d57adf9fa
commit 5e5c3c2afa
9 changed files with 552 additions and 279 deletions

View file

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

View file

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

View file

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

View file

@ -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<String> = ["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..<n {
let av = i < a.count ? a[i] : 0
let bv = i < b.count ? b[i] : 0
if av != bv { return av > 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<NSError?>?) -> 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
}
}

View file

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

View file

@ -21,4 +21,10 @@ typedef void (^LogFunc)(NSString *message);
- (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback;
- (NSDictionary<NSString*, NSString*>*)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<NSString*, NSDictionary<NSString*, id>*>*)getDetailedAppListWithError:(NSError**)error;
@end

View file

@ -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<NSString*, NSString*>*)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<NSString*, NSString*>* apps = list_installed_apps(provider, &errorStr);
NSDictionary<NSString*, NSDictionary<NSString*, id>*>* 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<NSString*, NSString*>* simple = [NSMutableDictionary dictionaryWithCapacity:full.count];
[full enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSDictionary<NSString *,id> * _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<NSString*, NSDictionary<NSString*, id>*>*)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<NSString*, NSDictionary<NSString*, id>*>* 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 {

View file

@ -10,7 +10,18 @@
@import Foundation;
@import UIKit;
NSDictionary<NSString*, NSString*>* 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<NSString*, NSDictionary<NSString*, id>*>*
list_installed_apps_with_icons(IdeviceProviderHandle* provider, NSString** error);
#ifdef __cplusplus
} // extern "C"
#endif
#endif /* APPLIST_H */

View file

@ -11,39 +11,48 @@
#include <string.h>
#import "applist.h"
NSDictionary<NSString*, NSString*>* list_installed_apps(IdeviceProviderHandle* provider, NSString** error) {
InstallationProxyClientHandle *client = NULL;
if (installation_proxy_connect_tcp(provider, &client)) {
NSDictionary<NSString*, NSDictionary<NSString*, id>*>*
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<NSString*, NSString*> *result = [NSMutableDictionary dictionaryWithCapacity:count];
NSMutableDictionary<NSString*, NSDictionary<NSString*, id>*> *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<NSString*, NSString*>* 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;
}