mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 12:02:14 +00:00
1297 lines
48 KiB
Swift
1297 lines
48 KiB
Swift
//
|
|
// InstalledAppsListView.swift
|
|
// StikDebug
|
|
//
|
|
// Created by Stossy11 on 28/03/2025.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
import WidgetKit
|
|
import Combine
|
|
|
|
// MARK: - Installed Apps List
|
|
|
|
struct InstalledAppListItem: Identifiable, Equatable {
|
|
let bundleID: String
|
|
let name: String
|
|
private let normalizedBundleID: String
|
|
private let normalizedName: String
|
|
|
|
var id: String {
|
|
bundleID
|
|
}
|
|
|
|
init(bundleID: String, name: String) {
|
|
self.bundleID = bundleID
|
|
self.name = name
|
|
self.normalizedBundleID = Self.normalized(bundleID)
|
|
self.normalizedName = Self.normalized(name)
|
|
}
|
|
|
|
func matches(_ query: String) -> Bool {
|
|
query.isEmpty || normalizedBundleID.contains(query) || normalizedName.contains(query)
|
|
}
|
|
|
|
static func sorted(from apps: [String: String]) -> [InstalledAppListItem] {
|
|
apps.map { InstalledAppListItem(bundleID: $0.key, name: $0.value) }
|
|
.sorted { lhs, rhs in
|
|
let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name)
|
|
if comparison == .orderedSame {
|
|
return lhs.bundleID < rhs.bundleID
|
|
}
|
|
return comparison == .orderedAscending
|
|
}
|
|
}
|
|
|
|
static func normalized(_ text: String) -> String {
|
|
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
|
.lowercased()
|
|
}
|
|
}
|
|
|
|
struct InstalledAppsListView: View {
|
|
@StateObject private var viewModel = InstalledAppsViewModel()
|
|
|
|
private let sharedDefaults = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard
|
|
|
|
@AppStorage("recentApps") private var recentApps: [String] = []
|
|
@AppStorage("favoriteApps") private var favoriteApps: [String] = [] {
|
|
didSet {
|
|
if favoriteApps.count > 4 {
|
|
favoriteApps = Array(favoriteApps.prefix(4))
|
|
}
|
|
persistIfChanged()
|
|
}
|
|
}
|
|
|
|
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
|
private let performanceMode = true
|
|
@State private var launchingBundles: Set<String> = []
|
|
@State private var launchFeedback: LaunchFeedback? = nil
|
|
@State private var debuggableSearchText: String = ""
|
|
@State private var launchSearchText: String = ""
|
|
@State private var prefetchedBundleIDs: Set<String> = []
|
|
@State private var selectedTab: AppListTab = .debuggable
|
|
@AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = []
|
|
@AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:]
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
var onSelectApp: (String, String) -> Void
|
|
var showDoneButton: Bool = true
|
|
var onImportPairingFile: (() -> Void)? = nil
|
|
|
|
|
|
private var currentSearchBinding: Binding<String> {
|
|
Binding(
|
|
get: { selectedTab == .debuggable ? debuggableSearchText : launchSearchText },
|
|
set: {
|
|
if selectedTab == .debuggable { debuggableSearchText = $0 }
|
|
else { launchSearchText = $0 }
|
|
}
|
|
)
|
|
}
|
|
|
|
private var debuggableSearchIsActive: Bool {
|
|
!InstalledAppListItem.normalized(debuggableSearchText).isEmpty
|
|
}
|
|
|
|
private var filteredDebuggableApps: [InstalledAppListItem] {
|
|
let query = InstalledAppListItem.normalized(debuggableSearchText)
|
|
guard !query.isEmpty else { return viewModel.debuggableItems }
|
|
return viewModel.debuggableItems.filter { $0.matches(query) }
|
|
}
|
|
|
|
private var filteredDebuggableSet: Set<String> {
|
|
Set(filteredDebuggableApps.map(\.bundleID))
|
|
}
|
|
|
|
private var filteredFavoriteBundles: [String] {
|
|
favoriteApps.filter { filteredDebuggableSet.contains($0) }
|
|
}
|
|
|
|
private var filteredRecentBundles: [String] {
|
|
recentApps.filter { filteredDebuggableSet.contains($0) && !favoriteApps.contains($0) }
|
|
}
|
|
|
|
private var launchSearchIsActive: Bool {
|
|
!InstalledAppListItem.normalized(launchSearchText).isEmpty
|
|
}
|
|
|
|
private var filteredLaunchApps: [InstalledAppListItem] {
|
|
let query = InstalledAppListItem.normalized(launchSearchText)
|
|
guard !query.isEmpty else { return viewModel.launchItems }
|
|
return viewModel.launchItems.filter { $0.matches(query) }
|
|
}
|
|
|
|
private enum AppListTab: Int, CaseIterable, Identifiable {
|
|
case debuggable
|
|
case launch
|
|
|
|
var id: Int { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .debuggable: return "JIT"
|
|
case .launch: return "Other"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LaunchFeedback: Identifiable {
|
|
let id = UUID()
|
|
let message: String
|
|
let success: Bool
|
|
}
|
|
|
|
private func isEmpty(for tab: AppListTab) -> Bool {
|
|
switch tab {
|
|
case .debuggable:
|
|
return viewModel.debuggableItems.isEmpty
|
|
case .launch:
|
|
return filteredLaunchApps.isEmpty
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
tabContent(for: selectedTab)
|
|
.transition(.opacity)
|
|
.transaction { t in t.disablesAnimations = true }
|
|
.navigationTitle(selectedTab == .debuggable ? "Enable JIT".localized : "Launch Apps".localized)
|
|
.searchable(
|
|
text: currentSearchBinding,
|
|
placement: .navigationBarDrawer(displayMode: .always),
|
|
prompt: selectedTab == .debuggable
|
|
? "Search apps or bundle ID".localized
|
|
: "Search".localized
|
|
)
|
|
.toolbar {
|
|
ToolbarItem(placement: .principal) {
|
|
Picker("", selection: $selectedTab) {
|
|
ForEach(AppListTab.allCases) { tab in
|
|
Text(tab.title.localized).tag(tab)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 220)
|
|
}
|
|
if let onImportPairingFile {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(action: onImportPairingFile) {
|
|
Image(systemName: "doc.badge.plus")
|
|
}
|
|
}
|
|
}
|
|
if showDoneButton {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Done") { dismiss() }.fontWeight(.semibold)
|
|
}
|
|
} else {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button {
|
|
viewModel.refreshAppLists()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.disabled(viewModel.isLoading)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
}
|
|
}
|
|
.overlay {
|
|
if let feedback = launchFeedback {
|
|
VStack {
|
|
Spacer()
|
|
Text(feedback.message)
|
|
.font(.subheadline.weight(.semibold))
|
|
.padding(.horizontal, 16).padding(.vertical, 8)
|
|
.background(Capsule().fill(.ultraThinMaterial))
|
|
.foregroundStyle(feedback.success ? .green : .red)
|
|
.shadow(radius: 4)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.padding(.bottom, 40)
|
|
}
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id)
|
|
}
|
|
}
|
|
.onAppear {
|
|
prefetchedBundleIDs.removeAll()
|
|
prefetchPriorityIcons()
|
|
}
|
|
.onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() }
|
|
.onChange(of: recentApps) { _, _ in prefetchPriorityIcons() }
|
|
.onChange(of: viewModel.isLoading) { _, newValue in
|
|
if newValue {
|
|
prefetchedBundleIDs.removeAll()
|
|
} else {
|
|
prefetchPriorityIcons()
|
|
// Ensure names for existing favorites are persisted once app list is ready
|
|
persistIfChanged()
|
|
}
|
|
}
|
|
.onChange(of: selectedTab) { _, _ in prefetchPriorityIcons() }
|
|
.onChange(of: pinnedSystemApps) { _, _ in prefetchPriorityIcons() }
|
|
.onReceive(NotificationCenter.default.publisher(for: .pairingFileImported)) { _ in
|
|
viewModel.refreshAppLists()
|
|
}
|
|
}
|
|
|
|
// MARK: Apps List
|
|
|
|
private func prefetchPriorityIcons(limit: Int = 32) {
|
|
guard loadAppIconsOnJIT else { return }
|
|
|
|
var priorityIDs: [String] = []
|
|
var seen = Set<String>()
|
|
|
|
func appendUnique<S: Sequence>(_ ids: S) where S.Element == String {
|
|
guard priorityIDs.count < limit else { return }
|
|
for id in ids {
|
|
guard seen.insert(id).inserted else { continue }
|
|
priorityIDs.append(id)
|
|
if priorityIDs.count >= limit { break }
|
|
}
|
|
}
|
|
|
|
appendUnique(favoriteApps)
|
|
appendUnique(recentApps)
|
|
appendUnique(pinnedSystemApps)
|
|
appendUnique(viewModel.debuggableItems.map(\.bundleID))
|
|
appendUnique(viewModel.launchItems.map(\.bundleID))
|
|
|
|
let toPrefetch = priorityIDs.filter { !prefetchedBundleIDs.contains($0) }
|
|
guard !toPrefetch.isEmpty else { return }
|
|
|
|
prefetchedBundleIDs.formUnion(toPrefetch)
|
|
AppIconRepository.prefetch(bundleIDs: toPrefetch)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabContent(for tab: AppListTab) -> some View {
|
|
switch tab {
|
|
case .debuggable:
|
|
List {
|
|
if let error = viewModel.lastError {
|
|
Section {
|
|
Text(error).font(.footnote).foregroundStyle(.orange)
|
|
}
|
|
}
|
|
if filteredDebuggableApps.isEmpty && !viewModel.isLoading {
|
|
Section {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: debuggableSearchIsActive ? "text.magnifyingglass" : "magnifyingglass")
|
|
.font(.system(size: 36)).foregroundStyle(.secondary)
|
|
Text(debuggableSearchIsActive ? "No matching apps".localized : "No JIT Apps Found".localized)
|
|
.font(.headline)
|
|
Text(debuggableSearchIsActive
|
|
? "Try a different name or bundle identifier.".localized
|
|
: "StikDebug can only connect to apps with the \"get-task-allow\" entitlement.".localized)
|
|
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
} else {
|
|
if !filteredFavoriteBundles.isEmpty {
|
|
Section(String(format: "Favorites (%d/4)".localized, filteredFavoriteBundles.count)) {
|
|
ForEach(filteredFavoriteBundles, id: \.self) { bundleID in
|
|
AppButton(
|
|
bundleID: bundleID,
|
|
appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID),
|
|
recentApps: $recentApps, favoriteApps: $favoriteApps,
|
|
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
|
|
)
|
|
}
|
|
}
|
|
}
|
|
if !filteredRecentBundles.isEmpty {
|
|
Section("Recents".localized) {
|
|
ForEach(filteredRecentBundles, id: \.self) { bundleID in
|
|
AppButton(
|
|
bundleID: bundleID,
|
|
appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID),
|
|
recentApps: $recentApps, favoriteApps: $favoriteApps,
|
|
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
|
|
)
|
|
}
|
|
}
|
|
}
|
|
Section("Apps with get-task-allow".localized) {
|
|
ForEach(filteredDebuggableApps) { app in
|
|
AppButton(
|
|
bundleID: app.bundleID, appName: app.name,
|
|
recentApps: $recentApps, favoriteApps: $favoriteApps,
|
|
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
|
|
case .launch:
|
|
List {
|
|
if let error = viewModel.lastError {
|
|
Section {
|
|
Text(error).font(.footnote).foregroundStyle(.orange)
|
|
}
|
|
}
|
|
if filteredLaunchApps.isEmpty {
|
|
Section {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.font(.system(size: 36)).foregroundStyle(.secondary)
|
|
Text(launchSearchIsActive ? "No matches".localized : "No Apps Found".localized)
|
|
.font(.headline)
|
|
Text(launchSearchIsActive
|
|
? "Try another name or bundle identifier.".localized
|
|
: "Once your device pairing file is imported and CoreDevice is connected, all apps will appear here.".localized)
|
|
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
} else {
|
|
Section("All Apps".localized) {
|
|
ForEach(filteredLaunchApps) { app in
|
|
let isPinned = pinnedSystemApps.contains(app.bundleID)
|
|
LaunchAppRow(
|
|
bundleID: app.bundleID, appName: app.name,
|
|
isLaunching: launchingBundles.contains(app.bundleID),
|
|
performanceMode: performanceMode
|
|
) { startLaunching(bundleID: app.bundleID, appName: app.name) }
|
|
.overlay(alignment: .topTrailing) {
|
|
if isPinned {
|
|
Image(systemName: "star.fill")
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.yellow).padding(6)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
.contextMenu {
|
|
Button((isPinned ? "Remove from Home" : "Add to Home").localized,
|
|
systemImage: isPinned ? "star.slash" : "star") {
|
|
toggleSystemPin(bundleID: app.bundleID, appName: app.name)
|
|
}
|
|
Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") {
|
|
UIPasteboard.general.string = app.bundleID
|
|
Haptics.light()
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button {
|
|
toggleSystemPin(bundleID: app.bundleID, appName: app.name)
|
|
} label: {
|
|
Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star")
|
|
}
|
|
.tint(.yellow)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
}
|
|
}
|
|
|
|
// MARK: Persistence gate (avoid redundant writes + reloads)
|
|
|
|
private func persistIfChanged() {
|
|
var touched = false
|
|
let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? []
|
|
let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? []
|
|
let prevPinned = (sharedDefaults.array(forKey: "pinnedSystemApps") as? [String]) ?? []
|
|
let prevPinnedNames = (sharedDefaults.dictionary(forKey: "pinnedSystemAppNames") as? [String: String]) ?? [:]
|
|
let prevFavNames = (sharedDefaults.dictionary(forKey: ScriptStore.favoriteAppNamesKey) as? [String: String]) ?? [:]
|
|
|
|
if prevR != recentApps {
|
|
sharedDefaults.set(recentApps, forKey: "recentApps")
|
|
touched = true
|
|
}
|
|
if prevF != favoriteApps {
|
|
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
|
touched = true
|
|
}
|
|
if prevPinned != pinnedSystemApps {
|
|
sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps")
|
|
touched = true
|
|
}
|
|
if prevPinnedNames != pinnedSystemAppNames {
|
|
sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames")
|
|
touched = true
|
|
}
|
|
|
|
// Persist favorite names for the widget (prefer actual names from lists)
|
|
let computedFavNames: [String: String] = Dictionary(uniqueKeysWithValues: favoriteApps.map { id in
|
|
let name = viewModel.displayName(for: id)
|
|
?? fallbackReadableName(from: id)
|
|
return (id, name)
|
|
})
|
|
if prevFavNames != computedFavNames {
|
|
sharedDefaults.set(computedFavNames, forKey: ScriptStore.favoriteAppNamesKey)
|
|
touched = true
|
|
}
|
|
|
|
if touched { WidgetCenter.shared.reloadAllTimelines() }
|
|
}
|
|
|
|
private func startLaunching(bundleID: String, appName: String) {
|
|
guard !launchingBundles.contains(bundleID) else { return }
|
|
launchingBundles.insert(bundleID)
|
|
Haptics.selection()
|
|
AccessibilityAnnouncer.announce(String(format: "Launching %@".localized, appName))
|
|
|
|
viewModel.launchWithoutDebug(bundleID: bundleID) { success in
|
|
launchingBundles.remove(bundleID)
|
|
|
|
let message = success
|
|
? String(format: "Launch request sent for %@".localized, appName)
|
|
: String(format: "Launch failed for %@".localized, appName)
|
|
let feedback = LaunchFeedback(message: message, success: success)
|
|
|
|
if success {
|
|
Haptics.light()
|
|
}
|
|
AccessibilityAnnouncer.announce(message)
|
|
|
|
withAnimation {
|
|
launchFeedback = feedback
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
if launchFeedback?.id == feedback.id {
|
|
withAnimation {
|
|
launchFeedback = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pin/unpin any launchable app (not just hidden system)
|
|
private func toggleSystemPin(bundleID: String, appName: String) {
|
|
Haptics.light()
|
|
if let index = pinnedSystemApps.firstIndex(of: bundleID) {
|
|
pinnedSystemApps.remove(at: index)
|
|
pinnedSystemAppNames.removeValue(forKey: bundleID)
|
|
} else {
|
|
pinnedSystemApps.removeAll { $0 == bundleID }
|
|
pinnedSystemApps.insert(bundleID, at: 0)
|
|
pinnedSystemAppNames[bundleID] = appName
|
|
let maxPins = 8
|
|
if pinnedSystemApps.count > maxPins {
|
|
let surplus = Array(pinnedSystemApps.suffix(from: maxPins))
|
|
for id in surplus { pinnedSystemAppNames.removeValue(forKey: id) }
|
|
pinnedSystemApps = Array(pinnedSystemApps.prefix(maxPins))
|
|
}
|
|
}
|
|
persistIfChanged()
|
|
}
|
|
|
|
// Fallback readable name from bundle identifier
|
|
private func fallbackReadableName(from bundleID: String) -> String {
|
|
let components = bundleID.split(separator: ".")
|
|
if let last = components.last {
|
|
let cleaned = last.replacingOccurrences(of: "_", with: " ")
|
|
let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty { return trimmed.capitalized }
|
|
}
|
|
return bundleID
|
|
}
|
|
}
|
|
|
|
// MARK: - App Button Row
|
|
|
|
struct AppButton: View {
|
|
let bundleID: String
|
|
let appName: String
|
|
|
|
@Binding var recentApps: [String]
|
|
@Binding var favoriteApps: [String]
|
|
|
|
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
|
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
|
|
|
|
var onSelectApp: (String, String) -> Void
|
|
let sharedDefaults: UserDefaults
|
|
let performanceMode: Bool
|
|
|
|
@State private var showScriptPicker = false
|
|
@State private var assignedScriptName: String?
|
|
@StateObject private var iconLoader: IconLoader
|
|
|
|
init(
|
|
bundleID: String,
|
|
appName: String,
|
|
recentApps: Binding<[String]>,
|
|
favoriteApps: Binding<[String]>,
|
|
onSelectApp: @escaping (String, String) -> Void,
|
|
sharedDefaults: UserDefaults,
|
|
performanceMode: Bool
|
|
) {
|
|
self.bundleID = bundleID
|
|
self.appName = appName
|
|
self._recentApps = recentApps
|
|
self._favoriteApps = favoriteApps
|
|
self.onSelectApp = onSelectApp
|
|
self.sharedDefaults = sharedDefaults
|
|
self.performanceMode = performanceMode
|
|
_iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID))
|
|
_assignedScriptName = State(initialValue: AppButton.currentAssignment(for: bundleID))
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: selectApp) {
|
|
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
|
|
iconView
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(appName)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
|
|
Text(bundleID)
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.textSelection(.enabled)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if favoriteApps.contains(bundleID) {
|
|
Image(systemName: "star.fill")
|
|
.imageScale(.medium)
|
|
.foregroundStyle(.yellow)
|
|
.accessibilityHidden(true)
|
|
}
|
|
}
|
|
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.contextMenu {
|
|
Button(action: toggleFavorite) {
|
|
Label(
|
|
favoriteApps.contains(bundleID) ? "Remove Favorite" : "Add to Favorites",
|
|
systemImage: favoriteApps.contains(bundleID) ? "star.slash" : "star"
|
|
)
|
|
.disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4)
|
|
}
|
|
Button {
|
|
copyBundleID()
|
|
} label: {
|
|
Label("Copy Bundle ID", systemImage: "doc.on.doc")
|
|
}
|
|
if enableAdvancedOptions {
|
|
Button { showScriptPicker = true } label: {
|
|
Label("Assign Script", systemImage: "chevron.left.slash.chevron.right")
|
|
}
|
|
if assignedScriptName != nil {
|
|
Button {
|
|
resetScriptAssignment()
|
|
} label: {
|
|
Label("Reset Script", systemImage: "arrow.uturn.left")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button {
|
|
toggleFavorite()
|
|
} label: {
|
|
Label(favoriteApps.contains(bundleID) ? "Unfavorite" : "Favorite", systemImage: "star")
|
|
}
|
|
.tint(.yellow)
|
|
|
|
Button {
|
|
copyBundleID()
|
|
} label: {
|
|
Label("Copy ID", systemImage: "doc.on.doc")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showScriptPicker) {
|
|
ScriptListView { url in
|
|
assignScript(url)
|
|
showScriptPicker = false
|
|
}
|
|
}
|
|
.onAppear {
|
|
if loadAppIconsOnJIT {
|
|
iconLoader.beginLoading()
|
|
}
|
|
}
|
|
.onChange(of: loadAppIconsOnJIT) { _, newValue in
|
|
if newValue {
|
|
iconLoader.beginLoading()
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(String(format: "Enable JIT for %@".localized, appName))
|
|
.accessibilityValue(accessibilityValue)
|
|
.accessibilityHint("Double-tap to open the app and enable JIT. Use the actions rotor for favorites or bundle ID.".localized)
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityRemoveTraits(.isStaticText)
|
|
.accessibilityAction(named: Text(favoriteAccessibilityActionLabel)) {
|
|
toggleFavorite()
|
|
}
|
|
.accessibilityAction(named: Text("Copy Bundle ID".localized)) {
|
|
copyBundleID()
|
|
}
|
|
}
|
|
|
|
private var accessibilityValue: String {
|
|
var parts = [String(format: "Bundle ID %@".localized, bundleID)]
|
|
if favoriteApps.contains(bundleID) {
|
|
parts.append("Favorite".localized)
|
|
}
|
|
if let assignedScriptName {
|
|
parts.append(String(format: "Assigned script %@".localized, assignedScriptName))
|
|
}
|
|
return parts.joined(separator: ", ")
|
|
}
|
|
|
|
private var favoriteAccessibilityActionLabel: String {
|
|
favoriteApps.contains(bundleID)
|
|
? "Remove from Favorites".localized
|
|
: "Add to Favorites".localized
|
|
}
|
|
|
|
// MARK: Icon
|
|
|
|
private var iconView: some View {
|
|
Group {
|
|
if loadAppIconsOnJIT, let image = iconLoader.image {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 54, height: 54)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5)
|
|
.transition(.opacity.combined(with: .scale))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color(UIColor.systemGray5))
|
|
.frame(width: 54, height: 54)
|
|
.overlay(
|
|
Image(systemName: "app")
|
|
.font(.system(size: 24, weight: .regular))
|
|
.foregroundStyle(.gray)
|
|
)
|
|
}
|
|
}
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
// MARK: Actions
|
|
|
|
private func selectApp() {
|
|
Haptics.selection()
|
|
recentApps.removeAll { $0 == bundleID }
|
|
recentApps.insert(bundleID, at: 0)
|
|
if recentApps.count > 3 {
|
|
recentApps = Array(recentApps.prefix(3))
|
|
}
|
|
persistIfChanged()
|
|
onSelectApp(bundleID, appName)
|
|
}
|
|
|
|
private func toggleFavorite() {
|
|
Haptics.light()
|
|
let wasFavorite = favoriteApps.contains(bundleID)
|
|
if wasFavorite {
|
|
favoriteApps.removeAll { $0 == bundleID }
|
|
} else if favoriteApps.count < 4 {
|
|
favoriteApps.insert(bundleID, at: 0)
|
|
recentApps.removeAll { $0 == bundleID }
|
|
} else {
|
|
AccessibilityAnnouncer.announce("Favorites are full".localized)
|
|
return
|
|
}
|
|
persistIfChanged()
|
|
AccessibilityAnnouncer.announce(wasFavorite ? "Removed from Favorites".localized : "Added to Favorites".localized)
|
|
}
|
|
|
|
private func copyBundleID() {
|
|
UIPasteboard.general.string = bundleID
|
|
Haptics.light()
|
|
AccessibilityAnnouncer.announce("Bundle ID copied".localized)
|
|
}
|
|
|
|
private func assignScript(_ url: URL?) {
|
|
if let url {
|
|
let filename = url.lastPathComponent
|
|
ScriptStore.updateAssignedScriptName(filename, for: bundleID)
|
|
assignedScriptName = filename
|
|
} else {
|
|
ScriptStore.updateAssignedScriptName(nil, for: bundleID)
|
|
assignedScriptName = nil
|
|
}
|
|
Haptics.light()
|
|
}
|
|
|
|
private func resetScriptAssignment() {
|
|
assignScript(nil)
|
|
}
|
|
|
|
private static func currentAssignment(for bundleID: String) -> String? {
|
|
ScriptStore.assignedScriptName(for: bundleID)
|
|
}
|
|
|
|
private func persistIfChanged() {
|
|
var touched = false
|
|
let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? []
|
|
let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? []
|
|
|
|
if prevR != recentApps {
|
|
sharedDefaults.set(recentApps, forKey: "recentApps")
|
|
touched = true
|
|
}
|
|
if prevF != favoriteApps {
|
|
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
|
touched = true
|
|
}
|
|
if touched { WidgetCenter.shared.reloadAllTimelines() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Launch Row
|
|
|
|
struct LaunchAppRow: View {
|
|
let bundleID: String
|
|
let appName: String
|
|
let isLaunching: Bool
|
|
|
|
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
|
|
|
let performanceMode: Bool
|
|
var launchAction: () -> Void
|
|
|
|
@StateObject private var iconLoader: IconLoader
|
|
|
|
init(
|
|
bundleID: String,
|
|
appName: String,
|
|
isLaunching: Bool,
|
|
performanceMode: Bool,
|
|
launchAction: @escaping () -> Void
|
|
) {
|
|
self.bundleID = bundleID
|
|
self.appName = appName
|
|
self.isLaunching = isLaunching
|
|
self.performanceMode = performanceMode
|
|
self.launchAction = launchAction
|
|
_iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID))
|
|
}
|
|
|
|
var body: some View {
|
|
Button {
|
|
guard !isLaunching else { return }
|
|
launchAction()
|
|
} label: {
|
|
HStack(spacing: loadAppIconsOnJIT ? 16 : 12) {
|
|
iconView
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(appName)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
|
|
Text(bundleID)
|
|
.font(.system(size: 14))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.textSelection(.enabled)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if isLaunching {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Text("Launch".localized)
|
|
.font(.footnote.weight(.semibold))
|
|
.padding(.horizontal, 12).padding(.vertical, 6)
|
|
.background(Capsule().fill(Color.accentColor.opacity(0.18)))
|
|
.foregroundStyle(Color.accentColor)
|
|
}
|
|
}
|
|
.padding(.vertical, loadAppIconsOnJIT ? 4 : 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isLaunching)
|
|
.onAppear {
|
|
if loadAppIconsOnJIT {
|
|
iconLoader.beginLoading()
|
|
}
|
|
}
|
|
.onChange(of: loadAppIconsOnJIT) { _, newValue in
|
|
if newValue {
|
|
iconLoader.beginLoading()
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(String(format: "Launch %@".localized, appName))
|
|
.accessibilityValue(accessibilityValue)
|
|
.accessibilityHint(isLaunching
|
|
? "Launch request in progress.".localized
|
|
: "Double-tap to launch this app without enabling JIT.".localized)
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityRemoveTraits(.isStaticText)
|
|
.accessibilityAction(named: Text("Launch App".localized)) {
|
|
guard !isLaunching else { return }
|
|
launchAction()
|
|
}
|
|
}
|
|
|
|
private var accessibilityValue: String {
|
|
let state = isLaunching ? "Launching".localized : "Ready".localized
|
|
return "\(state), \(String(format: "Bundle ID %@".localized, bundleID))"
|
|
}
|
|
|
|
private var iconView: some View {
|
|
Group {
|
|
if loadAppIconsOnJIT, let image = iconLoader.image {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 54, height: 54)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5)
|
|
.transition(.opacity.combined(with: .scale))
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color(UIColor.systemGray5))
|
|
.frame(width: 54, height: 54)
|
|
.overlay(
|
|
Image(systemName: "app")
|
|
.font(.system(size: 24, weight: .regular))
|
|
.foregroundStyle(.gray)
|
|
)
|
|
}
|
|
}
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
}
|
|
|
|
private actor IconFetchRegistry {
|
|
private var tasks: [String: Task<UIImage?, Never>] = [:]
|
|
|
|
func task(for bundleID: String, create: () -> Task<UIImage?, Never>) -> Task<UIImage?, Never> {
|
|
if let existing = tasks[bundleID] {
|
|
return existing
|
|
}
|
|
let task = create()
|
|
tasks[bundleID] = task
|
|
return task
|
|
}
|
|
|
|
func clear(bundleID: String) {
|
|
tasks[bundleID] = nil
|
|
}
|
|
}
|
|
|
|
private actor AsyncSemaphore {
|
|
private var permits: Int
|
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
init(permits: Int) {
|
|
self.permits = permits
|
|
}
|
|
|
|
func acquire() async {
|
|
if permits > 0 {
|
|
permits -= 1
|
|
return
|
|
}
|
|
await withCheckedContinuation { continuation in
|
|
waiters.append(continuation)
|
|
}
|
|
}
|
|
|
|
func release() {
|
|
if let next = waiters.first {
|
|
waiters.removeFirst()
|
|
next.resume()
|
|
} else {
|
|
permits += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AppIconRepository {
|
|
private static let memory: NSCache<NSString, UIImage> = {
|
|
let cache = NSCache<NSString, UIImage>()
|
|
cache.countLimit = 2000
|
|
cache.totalCostLimit = 64 * 1024 * 1024
|
|
return cache
|
|
}()
|
|
|
|
private static let diskQueue = DispatchQueue(label: "com.stik.iconcache.disk", qos: .utility)
|
|
private static let fetchSemaphore = AsyncSemaphore(permits: 4)
|
|
private static let registry = IconFetchRegistry()
|
|
private static let appGroupIdentifier = "group.com.stik.sj"
|
|
|
|
static func cachedImage(for bundleID: String) -> UIImage? {
|
|
memory.object(forKey: bundleID as NSString)
|
|
}
|
|
|
|
static func image(for bundleID: String) async -> UIImage? {
|
|
if let mem = cachedImage(for: bundleID) {
|
|
return mem
|
|
}
|
|
|
|
if let disk = await loadFromDisk(bundleID: bundleID) {
|
|
storeInMemory(disk, for: bundleID)
|
|
return disk
|
|
}
|
|
|
|
return await fetchAndStore(bundleID: bundleID)
|
|
}
|
|
|
|
static func prefetch(bundleIDs: [String]) {
|
|
let unique = Set(bundleIDs)
|
|
for bundleID in unique {
|
|
Task.detached(priority: .utility) {
|
|
_ = await image(for: bundleID)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func removeFromCache(bundleIDs: [String]) {
|
|
guard !bundleIDs.isEmpty else { return }
|
|
for id in bundleIDs {
|
|
memory.removeObject(forKey: id as NSString)
|
|
}
|
|
diskQueue.async {
|
|
for id in bundleIDs {
|
|
guard let url = iconURL(for: id) else { continue }
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func fetchAndStore(bundleID: String) async -> UIImage? {
|
|
let task = await registry.task(for: bundleID) {
|
|
Task.detached(priority: .utility) {
|
|
await fetchSemaphore.acquire()
|
|
|
|
let result: UIImage?
|
|
if let fetched = await fetchFromSource(bundleID: bundleID) {
|
|
let prepared = prepareForDisplay(fetched)
|
|
store(prepared, for: bundleID)
|
|
result = prepared
|
|
} else {
|
|
result = nil
|
|
}
|
|
|
|
await fetchSemaphore.release()
|
|
await registry.clear(bundleID: bundleID)
|
|
return result
|
|
}
|
|
}
|
|
return await task.value
|
|
}
|
|
|
|
private static func fetchFromSource(bundleID: String) async -> UIImage? {
|
|
await withCheckedContinuation { continuation in
|
|
AppStoreIconFetcher.getIcon(for: bundleID) { image in
|
|
continuation.resume(returning: image)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func loadFromDisk(bundleID: String) async -> UIImage? {
|
|
let imageScale = await MainActor.run { UIScreen.main.scale }
|
|
return await withCheckedContinuation { (continuation: CheckedContinuation<UIImage?, Never>) in
|
|
diskQueue.async {
|
|
guard let url = iconURL(for: bundleID),
|
|
FileManager.default.fileExists(atPath: url.path) else {
|
|
continuation.resume(returning: nil)
|
|
return
|
|
}
|
|
guard let data = try? Data(contentsOf: url) else {
|
|
try? FileManager.default.removeItem(at: url)
|
|
continuation.resume(returning: nil)
|
|
return
|
|
}
|
|
guard let image = UIImage(data: data, scale: imageScale) else {
|
|
try? FileManager.default.removeItem(at: url)
|
|
continuation.resume(returning: nil)
|
|
return
|
|
}
|
|
continuation.resume(returning: prepareForDisplay(image))
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func store(_ image: UIImage, for bundleID: String) {
|
|
storeInMemory(image, for: bundleID)
|
|
storeOnDisk(image, bundleID: bundleID)
|
|
}
|
|
|
|
private static func storeInMemory(_ image: UIImage, for bundleID: String) {
|
|
memory.setObject(image, forKey: bundleID as NSString, cost: memoryCost(for: image))
|
|
}
|
|
|
|
private static func storeOnDisk(_ image: UIImage, bundleID: String) {
|
|
diskQueue.async {
|
|
guard let url = iconURL(for: bundleID),
|
|
let data = image.pngData() else { return }
|
|
do {
|
|
try data.write(to: url, options: .atomic)
|
|
} catch {
|
|
// Best-effort cache write.
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func iconURL(for bundleID: String) -> URL? {
|
|
guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else {
|
|
return nil
|
|
}
|
|
guard let fileName = cacheFileName(for: bundleID) else {
|
|
return nil
|
|
}
|
|
let directory = container.appendingPathComponent("icons", isDirectory: true)
|
|
do {
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
} catch {
|
|
return nil
|
|
}
|
|
return directory.appendingPathComponent(fileName)
|
|
}
|
|
|
|
private static func cacheFileName(for bundleID: String) -> String? {
|
|
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_"))
|
|
let sanitized = String(bundleID.unicodeScalars.map { scalar in
|
|
allowed.contains(scalar) ? Character(scalar) : "_"
|
|
})
|
|
guard !sanitized.isEmpty else { return nil }
|
|
return "\(sanitized).png"
|
|
}
|
|
|
|
private static func memoryCost(for image: UIImage) -> Int {
|
|
let width = Int(image.size.width * image.scale)
|
|
let height = Int(image.size.height * image.scale)
|
|
return max(width * height * 4, 1)
|
|
}
|
|
|
|
private static func prepareForDisplay(_ image: UIImage) -> UIImage {
|
|
if #available(iOS 15.0, *) {
|
|
return image.preparingForDisplay() ?? image
|
|
}
|
|
return image
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class IconLoader: ObservableObject {
|
|
@Published private(set) var image: UIImage?
|
|
|
|
private let bundleID: String
|
|
private var didStart = false
|
|
|
|
init(bundleID: String) {
|
|
self.bundleID = bundleID
|
|
if let cached = AppIconRepository.cachedImage(for: bundleID) {
|
|
image = cached
|
|
didStart = true
|
|
}
|
|
}
|
|
|
|
func beginLoading() {
|
|
if image != nil {
|
|
didStart = true
|
|
return
|
|
}
|
|
guard !didStart else { return }
|
|
didStart = true
|
|
|
|
let targetID = bundleID
|
|
Task { [weak self] in
|
|
if let resolved = await AppIconRepository.image(for: targetID) {
|
|
guard let self else { return }
|
|
withAnimation(.linear(duration: 0.12)) {
|
|
self.image = resolved
|
|
}
|
|
} else {
|
|
self?.didStart = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Haptics {
|
|
static func light() { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
|
|
static func selection() { UISelectionFeedbackGenerator().selectionChanged() }
|
|
}
|
|
|
|
// MARK: - Utilities
|
|
|
|
extension Array: @retroactive RawRepresentable where Element: Codable {
|
|
public init?(rawValue: String) {
|
|
guard let data = rawValue.data(using: .utf8),
|
|
let result = try? JSONDecoder().decode([Element].self, from: data)
|
|
else { return nil }
|
|
self = result
|
|
}
|
|
public var rawValue: String {
|
|
guard let data = try? JSONEncoder().encode(self),
|
|
let result = String(data: data, encoding: .utf8)
|
|
else { return "[]" }
|
|
return result
|
|
}
|
|
}
|
|
|
|
extension Dictionary: @retroactive RawRepresentable where Key: Codable, Value: Codable {
|
|
public init?(rawValue: String) {
|
|
guard let data = rawValue.data(using: .utf8),
|
|
let result = try? JSONDecoder().decode([Key: Value].self, from: data)
|
|
else { return nil }
|
|
self = result
|
|
}
|
|
public var rawValue: String {
|
|
guard let data = try? JSONEncoder().encode(self),
|
|
let result = String(data: data, encoding: .utf8)
|
|
else { return "{}" }
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
struct InstalledAppsListView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
InstalledAppsListView { _, _ in }
|
|
.environment(\.colorScheme, .dark)
|
|
}
|
|
}
|
|
|
|
|
|
class InstalledAppsViewModel: ObservableObject {
|
|
@Published var debuggableApps: [String: String] = [:]
|
|
@Published var nonDebuggableApps: [String: String] = [:]
|
|
@Published var systemApps: [String: String] = [:]
|
|
@Published private(set) var debuggableItems: [InstalledAppListItem] = []
|
|
@Published private(set) var launchItems: [InstalledAppListItem] = []
|
|
@Published var isLoading = false
|
|
@Published var lastError: String? = nil
|
|
|
|
private let workQueue = DispatchQueue(label: "com.stik.installedApps", qos: .userInitiated)
|
|
private let cache = UserDefaults(suiteName: "group.com.stik.sj") ?? .standard
|
|
private let cacheKeyDebuggable = "cachedDebuggableApps"
|
|
private let cacheKeyNonDebuggable = "cachedNonDebuggableApps"
|
|
private let cacheKeySystem = "cachedSystemApps"
|
|
|
|
init() {
|
|
loadCachedApps()
|
|
refreshAppLists()
|
|
}
|
|
|
|
func refreshAppLists() {
|
|
isLoading = true
|
|
lastError = nil
|
|
|
|
workQueue.async { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let debuggable = try JITEnableContext.shared.getAppList()
|
|
let allApps = try JITEnableContext.shared.getAllApps()
|
|
let hiddenSystem = (try? JITEnableContext.shared.getHiddenSystemApps()) ?? [:]
|
|
|
|
let nonDebuggableSequence = allApps.filter { debuggable[$0.key] == nil }
|
|
var nonDebuggable: [String: String] = [:]
|
|
var system: [String: String] = [:]
|
|
|
|
for (bundle, name) in nonDebuggableSequence {
|
|
if let hiddenName = hiddenSystem[bundle] {
|
|
system[bundle] = hiddenName
|
|
} else {
|
|
nonDebuggable[bundle] = name
|
|
}
|
|
}
|
|
|
|
for (bundle, name) in hiddenSystem where system[bundle] == nil && debuggable[bundle] == nil {
|
|
system[bundle] = name
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self.apply(debuggable: debuggable, nonDebuggable: nonDebuggable, system: system)
|
|
self.isLoading = false
|
|
self.cacheApps(debuggable: debuggable, nonDebuggable: nonDebuggable, system: system)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
self.isLoading = false
|
|
self.lastError = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadCachedApps() {
|
|
func decode(_ key: String) -> [String: String] {
|
|
guard let data = cache.data(forKey: key),
|
|
let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
|
|
return decoded
|
|
}
|
|
|
|
let cachedDebuggable = decode(cacheKeyDebuggable)
|
|
let cachedNonDebuggable = decode(cacheKeyNonDebuggable)
|
|
let cachedSystem = decode(cacheKeySystem)
|
|
|
|
if !cachedDebuggable.isEmpty || !cachedNonDebuggable.isEmpty || !cachedSystem.isEmpty {
|
|
apply(debuggable: cachedDebuggable, nonDebuggable: cachedNonDebuggable, system: cachedSystem)
|
|
}
|
|
}
|
|
|
|
private func apply(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) {
|
|
debuggableApps = debuggable
|
|
nonDebuggableApps = nonDebuggable
|
|
systemApps = system
|
|
debuggableItems = InstalledAppListItem.sorted(from: debuggable)
|
|
launchItems = InstalledAppListItem.sorted(from: Self.launchApps(nonDebuggable: nonDebuggable, system: system))
|
|
}
|
|
|
|
private static func launchApps(nonDebuggable: [String: String], system: [String: String]) -> [String: String] {
|
|
var combined = nonDebuggable
|
|
for (bundleID, name) in system {
|
|
combined[bundleID] = name
|
|
}
|
|
return combined
|
|
}
|
|
|
|
func displayName(for bundleID: String) -> String? {
|
|
debuggableApps[bundleID] ?? systemApps[bundleID] ?? nonDebuggableApps[bundleID]
|
|
}
|
|
|
|
private func cacheApps(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) {
|
|
func encode(_ value: [String: String]) -> Data? {
|
|
try? JSONEncoder().encode(value)
|
|
}
|
|
|
|
cache.set(encode(debuggable), forKey: cacheKeyDebuggable)
|
|
cache.set(encode(nonDebuggable), forKey: cacheKeyNonDebuggable)
|
|
cache.set(encode(system), forKey: cacheKeySystem)
|
|
}
|
|
|
|
func launchWithoutDebug(bundleID: String, completion: @escaping (Bool) -> Void) {
|
|
workQueue.async {
|
|
let success = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil)
|
|
DispatchQueue.main.async {
|
|
completion(success)
|
|
}
|
|
}
|
|
}
|
|
}
|