mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
iOS 26 Improvements - 2.2.6
This commit is contained in:
parent
9d57adf9fa
commit
5e5c3c2afa
9 changed files with 552 additions and 279 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,14 +738,16 @@ 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
|
||||
if vpnAutoStart {
|
||||
TunnelManager.shared.startVPN()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
if newPhase == .active {
|
||||
print("App became active – restarting heartbeat")
|
||||
|
|
|
|||
|
|
@ -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,6 +72,10 @@ 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 {
|
||||
|
|
@ -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,26 +488,11 @@ 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
|
||||
|
|
@ -584,27 +500,7 @@ struct HomeView: View {
|
|||
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 {
|
|||
}()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
.navigationTitle("Installed Apps".localized)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showSafeModeToast)
|
||||
}
|
||||
}
|
||||
.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,15 +244,18 @@ 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
|
||||
ForEach(favoritesForDisplay, id: \.self) { bundleID in
|
||||
if let meta = viewModel.apps[bundleID] {
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
appName: meta.name,
|
||||
version: meta.displayVersion,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
|
|
@ -147,18 +263,23 @@ struct InstalledAppsListView: View {
|
|||
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
|
||||
ForEach(recentsForDisplay, id: \.self) { bundleID in
|
||||
if let meta = viewModel.apps[bundleID] {
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.apps[bundleID] ?? bundleID,
|
||||
appName: meta.name,
|
||||
version: meta.displayVersion,
|
||||
recentApps: $recentApps,
|
||||
favoriteApps: $favoriteApps,
|
||||
appIcons: $appIcons,
|
||||
|
|
@ -166,17 +287,21 @@ struct InstalledAppsListView: View {
|
|||
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) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!");
|
||||
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) {
|
||||
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) {
|
||||
if (completionHandler) {
|
||||
completionHandler(result,
|
||||
[NSString stringWithCString:message
|
||||
encoding:NSASCIIStringEncoding]);
|
||||
[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 {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
plist_t nameNode = plist_dict_get_item(app, "CFBundleName");
|
||||
if (nameNode) {
|
||||
char *nameC = NULL;
|
||||
plist_t nameNode = plist_dict_get_item(app, "CFBundleName");
|
||||
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);
|
||||
|
||||
result[bundleID] = appName;
|
||||
}
|
||||
|
||||
installation_proxy_client_free(client);
|
||||
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;
|
||||
// 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;
|
||||
if (springboard_services_get_icon(client, [bundleID UTF8String], &pngData, &dataLen)) {
|
||||
springboard_services_free(client);
|
||||
*error = @"Failed to get app icon";
|
||||
return nil;
|
||||
}
|
||||
|
||||
UIImage *icon = nil;
|
||||
if (!springboard_services_get_icon(sbClient, [bundleID UTF8String], &pngData, &dataLen)) {
|
||||
NSData *data = [NSData dataWithBytes:pngData length:dataLen];
|
||||
free(pngData);
|
||||
UIImage *icon = [UIImage imageWithData:data];
|
||||
icon = [UIImage imageWithData:data];
|
||||
}
|
||||
|
||||
springboard_services_free(client);
|
||||
return icon;
|
||||
// Store info
|
||||
result[bundleID] = @{
|
||||
@"name": appName,
|
||||
@"version": version,
|
||||
@"build": build,
|
||||
@"icon": icon ?: [NSNull null]
|
||||
};
|
||||
}
|
||||
|
||||
springboard_services_free(sbClient);
|
||||
installation_proxy_client_free(ipClient);
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue