mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 12:30:29 +00:00
587 lines
20 KiB
Swift
587 lines
20 KiB
Swift
//
|
|
// InstalledAppsListView.swift
|
|
// StikDebug
|
|
//
|
|
// Created by Stossy11 on 28/03/2025.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UIKit
|
|
import WidgetKit
|
|
|
|
struct InstalledAppsListView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
let onSelectApp: (String, String) -> Void
|
|
let showDoneButton: Bool
|
|
let onImportPairingFile: (() -> Void)?
|
|
|
|
private let sharedDefaults = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard
|
|
|
|
@StateObject private var viewModel = InstalledAppsViewModel()
|
|
|
|
@AppStorage("recentApps") private var recentApps: [String] = []
|
|
@AppStorage("favoriteApps") private var favoriteApps: [String] = [] {
|
|
didSet {
|
|
favoriteApps = Array(favoriteApps.prefix(Self.maxFavorites))
|
|
persistIfChanged()
|
|
}
|
|
}
|
|
@AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true
|
|
@AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = []
|
|
@AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:]
|
|
|
|
@State private var launchingBundles: Set<String> = []
|
|
@State private var launchFeedback: LaunchFeedback?
|
|
@State private var debuggableSearchText = ""
|
|
@State private var launchSearchText = ""
|
|
@State private var prefetchedBundleIDs: Set<String> = []
|
|
@State private var selectedTab: AppListTab = .debuggable
|
|
|
|
private static let maxFavorites = 4
|
|
private static let maxSystemPins = 8
|
|
private static let iconPrefetchLimit = 32
|
|
|
|
init(
|
|
onSelectApp: @escaping (String, String) -> Void,
|
|
showDoneButton: Bool = true,
|
|
onImportPairingFile: (() -> Void)? = nil
|
|
) {
|
|
self.onSelectApp = onSelectApp
|
|
self.showDoneButton = showDoneButton
|
|
self.onImportPairingFile = onImportPairingFile
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
tabContent(for: selectedTab)
|
|
.transition(.opacity)
|
|
.transaction { transaction in
|
|
transaction.disablesAnimations = true
|
|
}
|
|
.navigationTitle(selectedTab.navigationTitle)
|
|
.searchable(
|
|
text: currentSearchBinding,
|
|
placement: .navigationBarDrawer(displayMode: .always),
|
|
prompt: selectedTab.searchPrompt
|
|
)
|
|
.toolbar {
|
|
tabPickerToolbarItem
|
|
leadingToolbarItem
|
|
trailingToolbarItem
|
|
}
|
|
}
|
|
.overlay {
|
|
launchFeedbackOverlay
|
|
}
|
|
.onAppear(perform: refreshIconPrefetch)
|
|
.onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() }
|
|
.onChange(of: recentApps) { _, _ in prefetchPriorityIcons() }
|
|
.onChange(of: selectedTab) { _, _ in prefetchPriorityIcons() }
|
|
.onChange(of: pinnedSystemApps) { _, _ in prefetchPriorityIcons() }
|
|
.onChange(of: viewModel.isLoading) { _, isLoading in
|
|
handleLoadingChange(isLoading)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .pairingFileImported)) { _ in
|
|
if TunnelManager.shared.isConnected {
|
|
viewModel.refreshAppLists()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var currentSearchBinding: Binding<String> {
|
|
Binding(
|
|
get: { selectedTab == .debuggable ? debuggableSearchText : launchSearchText },
|
|
set: { searchText in
|
|
if selectedTab == .debuggable {
|
|
debuggableSearchText = searchText
|
|
} else {
|
|
launchSearchText = searchText
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var debuggableSnapshot: DebuggableAppListSnapshot {
|
|
let query = InstalledAppListItem.normalized(debuggableSearchText)
|
|
let filteredApps = query.isEmpty
|
|
? viewModel.debuggableItems
|
|
: viewModel.debuggableItems.filter { $0.matches(query) }
|
|
let filteredBundleIDs = Set(filteredApps.map(\.bundleID))
|
|
|
|
return DebuggableAppListSnapshot(
|
|
apps: filteredApps,
|
|
favoriteBundles: favoriteApps.filter { filteredBundleIDs.contains($0) },
|
|
recentBundles: recentApps.filter { filteredBundleIDs.contains($0) && !favoriteApps.contains($0) },
|
|
searchIsActive: !query.isEmpty
|
|
)
|
|
}
|
|
|
|
private var launchSnapshot: LaunchAppListSnapshot {
|
|
let query = InstalledAppListItem.normalized(launchSearchText)
|
|
let filteredApps = query.isEmpty
|
|
? viewModel.launchItems
|
|
: viewModel.launchItems.filter { $0.matches(query) }
|
|
|
|
return LaunchAppListSnapshot(
|
|
apps: filteredApps,
|
|
searchIsActive: !query.isEmpty
|
|
)
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var tabPickerToolbarItem: some ToolbarContent {
|
|
ToolbarItem(placement: .principal) {
|
|
Picker("", selection: $selectedTab) {
|
|
ForEach(AppListTab.allCases) { tab in
|
|
Text(tab.title.localized).tag(tab)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(width: 220)
|
|
}
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var leadingToolbarItem: some ToolbarContent {
|
|
if let onImportPairingFile {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button(action: onImportPairingFile) {
|
|
Image(systemName: "doc.badge.plus")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var trailingToolbarItem: some ToolbarContent {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
if showDoneButton {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
.fontWeight(.semibold)
|
|
} else {
|
|
Button {
|
|
viewModel.refreshAppLists()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.disabled(viewModel.isLoading)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var launchFeedbackOverlay: some View {
|
|
if let launchFeedback {
|
|
VStack {
|
|
Spacer()
|
|
Text(launchFeedback.message)
|
|
.font(.subheadline.weight(.semibold))
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Capsule().fill(.ultraThinMaterial))
|
|
.foregroundStyle(launchFeedback.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)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabContent(for tab: AppListTab) -> some View {
|
|
switch tab {
|
|
case .debuggable:
|
|
debuggableAppsList
|
|
case .launch:
|
|
launchAppsList
|
|
}
|
|
}
|
|
|
|
private var debuggableAppsList: some View {
|
|
let snapshot = debuggableSnapshot
|
|
|
|
return List {
|
|
errorSection
|
|
|
|
if snapshot.apps.isEmpty && !viewModel.isLoading {
|
|
EmptyAppListState(
|
|
systemImage: snapshot.searchIsActive ? "text.magnifyingglass" : "magnifyingglass",
|
|
title: snapshot.searchIsActive ? "No matching apps".localized : "No JIT Apps Found".localized,
|
|
message: snapshot.searchIsActive
|
|
? "Try a different name or bundle identifier.".localized
|
|
: "StikDebug can only connect to apps with the \"get-task-allow\" entitlement.".localized
|
|
)
|
|
} else {
|
|
debuggableAppSections(snapshot: snapshot)
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
}
|
|
|
|
private var launchAppsList: some View {
|
|
let snapshot = launchSnapshot
|
|
|
|
return List {
|
|
errorSection
|
|
|
|
if snapshot.apps.isEmpty {
|
|
EmptyAppListState(
|
|
systemImage: "magnifyingglass",
|
|
title: snapshot.searchIsActive ? "No matches".localized : "No Apps Found".localized,
|
|
message: snapshot.searchIsActive
|
|
? "Try another name or bundle identifier.".localized
|
|
: "Once your device pairing file is imported and CoreDevice is connected, all apps will appear here.".localized
|
|
)
|
|
} else {
|
|
launchAppSection(snapshot: snapshot)
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var errorSection: some View {
|
|
if let error = viewModel.lastError {
|
|
Section {
|
|
Text(error)
|
|
.font(.footnote)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func debuggableAppSections(snapshot: DebuggableAppListSnapshot) -> some View {
|
|
if !snapshot.favoriteBundles.isEmpty {
|
|
Section(String(format: "Favorites (%d/4)".localized, snapshot.favoriteBundles.count)) {
|
|
ForEach(snapshot.favoriteBundles, id: \.self) { bundleID in
|
|
debugAppRow(
|
|
bundleID: bundleID,
|
|
appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !snapshot.recentBundles.isEmpty {
|
|
Section("Recents".localized) {
|
|
ForEach(snapshot.recentBundles, id: \.self) { bundleID in
|
|
debugAppRow(
|
|
bundleID: bundleID,
|
|
appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Apps with get-task-allow".localized) {
|
|
ForEach(snapshot.apps) { app in
|
|
debugAppRow(bundleID: app.bundleID, appName: app.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func launchAppSection(snapshot: LaunchAppListSnapshot) -> some View {
|
|
Section("All Apps".localized) {
|
|
ForEach(snapshot.apps) { app in
|
|
let isPinned = pinnedSystemApps.contains(app.bundleID)
|
|
|
|
LaunchAppRow(
|
|
bundleID: app.bundleID,
|
|
appName: app.name,
|
|
isLaunching: launchingBundles.contains(app.bundleID)
|
|
) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func debugAppRow(bundleID: String, appName: String) -> some View {
|
|
AppButton(
|
|
bundleID: bundleID,
|
|
appName: appName,
|
|
recentApps: $recentApps,
|
|
favoriteApps: $favoriteApps,
|
|
onSelectApp: onSelectApp,
|
|
sharedDefaults: sharedDefaults
|
|
)
|
|
}
|
|
|
|
private func refreshIconPrefetch() {
|
|
prefetchedBundleIDs.removeAll()
|
|
prefetchPriorityIcons()
|
|
}
|
|
|
|
private func handleLoadingChange(_ isLoading: Bool) {
|
|
if isLoading {
|
|
prefetchedBundleIDs.removeAll()
|
|
} else {
|
|
prefetchPriorityIcons()
|
|
persistIfChanged()
|
|
}
|
|
}
|
|
|
|
private func prefetchPriorityIcons(limit: Int = Self.iconPrefetchLimit) {
|
|
guard loadAppIconsOnJIT else {
|
|
return
|
|
}
|
|
|
|
var priorityBundleIDs: [String] = []
|
|
var seenBundleIDs = Set<String>()
|
|
|
|
func appendUnique<S: Sequence>(_ bundleIDs: S) where S.Element == String {
|
|
guard priorityBundleIDs.count < limit else { return }
|
|
|
|
for bundleID in bundleIDs {
|
|
guard seenBundleIDs.insert(bundleID).inserted else { continue }
|
|
priorityBundleIDs.append(bundleID)
|
|
if priorityBundleIDs.count >= limit { break }
|
|
}
|
|
}
|
|
|
|
appendUnique(favoriteApps)
|
|
appendUnique(recentApps)
|
|
appendUnique(pinnedSystemApps)
|
|
appendUnique(viewModel.debuggableItems.map(\.bundleID))
|
|
appendUnique(viewModel.launchItems.map(\.bundleID))
|
|
|
|
let bundleIDsToPrefetch = priorityBundleIDs.filter { !prefetchedBundleIDs.contains($0) }
|
|
guard !bundleIDsToPrefetch.isEmpty else {
|
|
return
|
|
}
|
|
|
|
prefetchedBundleIDs.formUnion(bundleIDsToPrefetch)
|
|
AppIconRepository.prefetch(bundleIDs: bundleIDsToPrefetch)
|
|
}
|
|
|
|
private func persistIfChanged() {
|
|
var touched = false
|
|
let previousRecents = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? []
|
|
let previousFavorites = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? []
|
|
let previousPinned = (sharedDefaults.array(forKey: "pinnedSystemApps") as? [String]) ?? []
|
|
let previousPinnedNames = (sharedDefaults.dictionary(forKey: "pinnedSystemAppNames") as? [String: String]) ?? [:]
|
|
let previousFavoriteNames = (sharedDefaults.dictionary(forKey: ScriptStore.favoriteAppNamesKey) as? [String: String]) ?? [:]
|
|
|
|
if previousRecents != recentApps {
|
|
sharedDefaults.set(recentApps, forKey: "recentApps")
|
|
touched = true
|
|
}
|
|
if previousFavorites != favoriteApps {
|
|
sharedDefaults.set(favoriteApps, forKey: "favoriteApps")
|
|
touched = true
|
|
}
|
|
if previousPinned != pinnedSystemApps {
|
|
sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps")
|
|
touched = true
|
|
}
|
|
if previousPinnedNames != pinnedSystemAppNames {
|
|
sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames")
|
|
touched = true
|
|
}
|
|
|
|
let favoriteNames = Dictionary(uniqueKeysWithValues: favoriteApps.map { bundleID in
|
|
(bundleID, viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID))
|
|
})
|
|
|
|
if previousFavoriteNames != favoriteNames {
|
|
sharedDefaults.set(favoriteNames, 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
if pinnedSystemApps.count > Self.maxSystemPins {
|
|
let surplus = Array(pinnedSystemApps.suffix(from: Self.maxSystemPins))
|
|
for bundleID in surplus {
|
|
pinnedSystemAppNames.removeValue(forKey: bundleID)
|
|
}
|
|
pinnedSystemApps = Array(pinnedSystemApps.prefix(Self.maxSystemPins))
|
|
}
|
|
}
|
|
|
|
persistIfChanged()
|
|
}
|
|
|
|
private func fallbackReadableName(from bundleID: String) -> String {
|
|
let components = bundleID.split(separator: ".")
|
|
if let lastComponent = components.last {
|
|
let cleaned = lastComponent.replacingOccurrences(of: "_", with: " ")
|
|
let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty {
|
|
return trimmed.capitalized
|
|
}
|
|
}
|
|
|
|
return bundleID
|
|
}
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
var navigationTitle: String {
|
|
switch self {
|
|
case .debuggable:
|
|
return "Enable JIT".localized
|
|
case .launch:
|
|
return "Launch Apps".localized
|
|
}
|
|
}
|
|
|
|
var searchPrompt: String {
|
|
switch self {
|
|
case .debuggable:
|
|
return "Search apps or bundle ID".localized
|
|
case .launch:
|
|
return "Search".localized
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LaunchFeedback: Identifiable {
|
|
let id = UUID()
|
|
let message: String
|
|
let success: Bool
|
|
}
|
|
|
|
private struct DebuggableAppListSnapshot {
|
|
let apps: [InstalledAppListItem]
|
|
let favoriteBundles: [String]
|
|
let recentBundles: [String]
|
|
let searchIsActive: Bool
|
|
}
|
|
|
|
private struct LaunchAppListSnapshot {
|
|
let apps: [InstalledAppListItem]
|
|
let searchIsActive: Bool
|
|
}
|
|
|
|
private struct EmptyAppListState: View {
|
|
let systemImage: String
|
|
let title: String
|
|
let message: String
|
|
|
|
var body: some View {
|
|
Section {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 36))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
Text(message)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 16)
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct InstalledAppsListView_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
InstalledAppsListView { _, _ in }
|
|
.environment(\.colorScheme, .dark)
|
|
}
|
|
}
|