mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 12:30:29 +00:00
1637 lines
58 KiB
Swift
1637 lines
58 KiB
Swift
//
|
||
// InstalledAppsListView.swift
|
||
// StikJIT
|
||
//
|
||
// Created by Stossy11 on 28/03/2025.
|
||
//
|
||
|
||
import SwiftUI
|
||
import UIKit
|
||
import WidgetKit
|
||
import Combine
|
||
|
||
// MARK: - Installed Apps List
|
||
|
||
struct InstalledAppsListView: View {
|
||
@StateObject private var viewModel = InstalledAppsViewModel()
|
||
|
||
private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")!
|
||
|
||
@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
|
||
@AppStorage("performanceMode") private var performanceMode = false
|
||
@State private var showPerformanceToast = false
|
||
@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) -> Void
|
||
|
||
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
||
@Environment(\.themeExpansionManager) private var themeExpansion
|
||
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
||
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
|
||
|
||
private var debuggableSearchIsActive: Bool {
|
||
!debuggableSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
private var debuggableSortedApps: [(key: String, value: String)] {
|
||
viewModel.debuggableApps.sorted { lhs, rhs in
|
||
let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value)
|
||
if comparison == .orderedSame {
|
||
return lhs.key < rhs.key
|
||
}
|
||
return comparison == .orderedAscending
|
||
}
|
||
}
|
||
|
||
private var filteredDebuggableApps: [(key: String, value: String)] {
|
||
guard debuggableSearchIsActive else { return debuggableSortedApps }
|
||
let query = normalizedSearchString(debuggableSearchText)
|
||
guard !query.isEmpty else { return debuggableSortedApps }
|
||
return debuggableSortedApps.filter { matches(query, bundleID: $0.key, name: $0.value) }
|
||
}
|
||
|
||
private var filteredDebuggableSet: Set<String> {
|
||
Set(filteredDebuggableApps.map { $0.key })
|
||
}
|
||
|
||
private var filteredFavoriteBundles: [String] {
|
||
favoriteApps.filter { filteredDebuggableSet.contains($0) }
|
||
}
|
||
|
||
private var filteredRecentBundles: [String] {
|
||
recentApps.filter { filteredDebuggableSet.contains($0) && !favoriteApps.contains($0) }
|
||
}
|
||
|
||
// NEW: Combined Launch tab (System + Other/Non-Debuggable)
|
||
private var combinedLaunchApps: [String: String] {
|
||
// Prefer names from systemApps when duplicates exist
|
||
var combined = viewModel.nonDebuggableApps
|
||
for (k, v) in viewModel.systemApps {
|
||
combined[k] = v
|
||
}
|
||
return combined
|
||
}
|
||
|
||
private var sortedLaunchApps: [(key: String, value: String)] {
|
||
combinedLaunchApps.sorted { lhs, rhs in
|
||
let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value)
|
||
if comparison == .orderedSame {
|
||
return lhs.key < rhs.key
|
||
}
|
||
return comparison == .orderedAscending
|
||
}
|
||
}
|
||
|
||
private var launchSearchIsActive: Bool {
|
||
!launchSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
private var filteredLaunchApps: [(key: String, value: String)] {
|
||
let base = sortedLaunchApps
|
||
guard launchSearchIsActive else { return base }
|
||
let query = normalizedSearchString(launchSearchText)
|
||
guard !query.isEmpty else { return base }
|
||
return base.filter { matches(query, bundleID: $0.key, name: $0.value) }
|
||
}
|
||
|
||
private enum AppListTab: Int, CaseIterable, Identifiable {
|
||
case debuggable
|
||
case launch
|
||
|
||
var id: Int { rawValue }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .debuggable: return "Debuggable"
|
||
case .launch: return "Launch Apps"
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct LaunchFeedback: Identifiable {
|
||
let id = UUID()
|
||
let message: String
|
||
let success: Bool
|
||
}
|
||
|
||
private func normalizedSearchString(_ text: String) -> String {
|
||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||
.lowercased()
|
||
}
|
||
|
||
private func matches(_ normalizedQuery: String, bundleID: String, name: String) -> Bool {
|
||
guard !normalizedQuery.isEmpty else { return true }
|
||
let identifier = normalizedSearchString(bundleID)
|
||
let displayName = normalizedSearchString(name)
|
||
return identifier.contains(normalizedQuery) || displayName.contains(normalizedQuery)
|
||
}
|
||
|
||
private func isEmpty(for tab: AppListTab) -> Bool {
|
||
switch tab {
|
||
case .debuggable:
|
||
return viewModel.debuggableApps.isEmpty
|
||
case .launch:
|
||
return filteredLaunchApps.isEmpty
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
ZStack {
|
||
ThemedBackground(style: backgroundStyle)
|
||
.ignoresSafeArea()
|
||
|
||
if viewModel.isLoading {
|
||
ProgressView("Loading Apps…".localized)
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 20)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||
.stroke(.white.opacity(0.15), lineWidth: 1)
|
||
)
|
||
)
|
||
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4)
|
||
.transition(.opacity.combined(with: .scale))
|
||
} else {
|
||
tabbedContent
|
||
.transition(.opacity)
|
||
.transaction { t in t.disablesAnimations = true }
|
||
}
|
||
|
||
if showPerformanceToast {
|
||
VStack {
|
||
Spacer()
|
||
Text(performanceMode ? "Performance Mode On" : "Performance 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, 40)
|
||
}
|
||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPerformanceToast)
|
||
}
|
||
|
||
if let feedback = launchFeedback {
|
||
VStack {
|
||
Spacer()
|
||
Text(feedback.message)
|
||
.font(.subheadline.weight(.semibold))
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
.background(
|
||
Capsule()
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
Capsule()
|
||
.stroke(feedback.success ? Color.green.opacity(0.35) : Color.red.opacity(0.35), lineWidth: 1)
|
||
)
|
||
)
|
||
.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)
|
||
}
|
||
}
|
||
.navigationTitle("Installed Apps".localized)
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarLeading) {
|
||
Button {
|
||
performanceMode.toggle()
|
||
Haptics.selection()
|
||
showPerformanceToast = true
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||
withAnimation {
|
||
showPerformanceToast = false
|
||
}
|
||
}
|
||
} label: {
|
||
Image(systemName: performanceMode ? "bolt.fill" : "bolt.slash.fill")
|
||
.imageScale(.large)
|
||
.foregroundStyle(performanceMode ? .yellow : .secondary)
|
||
.accessibilityLabel("Toggle Performance Mode")
|
||
.accessibilityValue(performanceMode ? "On" : "Off")
|
||
}
|
||
}
|
||
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button("Done") { dismiss() }
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
}
|
||
.preferredColorScheme(preferredScheme)
|
||
.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() }
|
||
}
|
||
|
||
// MARK: Empty State
|
||
|
||
private func emptyState(for tab: AppListTab) -> some View {
|
||
VStack(spacing: 16) {
|
||
Image(systemName: "magnifyingglass")
|
||
.resizable()
|
||
.scaledToFit()
|
||
.frame(width: 60, height: 60)
|
||
.foregroundStyle(.secondary)
|
||
|
||
switch tab {
|
||
case .debuggable:
|
||
Text("No Debuggable App Found".localized)
|
||
.font(.title2.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
|
||
Text("""
|
||
StikDebug can only connect to apps with the “get-task-allow” entitlement.
|
||
Please check if the app you want to connect to is signed with a development certificate.
|
||
""".localized)
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal)
|
||
case .launch:
|
||
Text("No Launchable Apps".localized)
|
||
.font(.title2.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
|
||
Text("""
|
||
Once your device pairing file is imported and CoreDevice is connected, all non‑debuggable and hidden system apps will appear here.
|
||
""".localized)
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal)
|
||
}
|
||
}
|
||
.padding(24)
|
||
.glassCard(cornerRadius: 24, material: .thinMaterial, strokeOpacity: 0.12)
|
||
.accessibilityElement(children: .combine)
|
||
.accessibilityLabel(tab == .debuggable ? "No debuggable apps available" : "No launchable apps available")
|
||
}
|
||
|
||
// MARK: Apps List
|
||
|
||
private func debuggableSections(
|
||
apps: [(key: String, value: String)],
|
||
favorites: [String],
|
||
recents: [String]
|
||
) -> some View {
|
||
VStack(spacing: 18) {
|
||
if !favorites.isEmpty {
|
||
glassSection(
|
||
title: String(format: "Favorites (%d/4)".localized, favorites.count)
|
||
) {
|
||
LazyVStack(spacing: 12) {
|
||
ForEach(favorites, id: \.self) { bundleID in
|
||
AppButton(
|
||
bundleID: bundleID,
|
||
appName: viewModel.debuggableApps[bundleID] ?? bundleID,
|
||
recentApps: $recentApps,
|
||
favoriteApps: $favoriteApps,
|
||
onSelectApp: onSelectApp,
|
||
sharedDefaults: sharedDefaults,
|
||
performanceMode: performanceMode
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !recents.isEmpty {
|
||
glassSection(title: "Recents".localized) {
|
||
LazyVStack(spacing: 12) {
|
||
ForEach(recents, id: \.self) { bundleID in
|
||
AppButton(
|
||
bundleID: bundleID,
|
||
appName: viewModel.debuggableApps[bundleID] ?? bundleID,
|
||
recentApps: $recentApps,
|
||
favoriteApps: $favoriteApps,
|
||
onSelectApp: onSelectApp,
|
||
sharedDefaults: sharedDefaults,
|
||
performanceMode: performanceMode
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
glassSection(title: "All Applications".localized) {
|
||
LazyVStack(spacing: 12) {
|
||
ForEach(apps, id: \.key) { bundleID, appName in
|
||
AppButton(
|
||
bundleID: bundleID,
|
||
appName: appName,
|
||
recentApps: $recentApps,
|
||
favoriteApps: $favoriteApps,
|
||
onSelectApp: onSelectApp,
|
||
sharedDefaults: sharedDefaults,
|
||
performanceMode: performanceMode
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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(debuggableSortedApps.map { $0.key })
|
||
appendUnique(sortedLaunchApps.map { $0.key })
|
||
|
||
let toPrefetch = priorityIDs.filter { !prefetchedBundleIDs.contains($0) }
|
||
guard !toPrefetch.isEmpty else { return }
|
||
|
||
prefetchedBundleIDs.formUnion(toPrefetch)
|
||
AppIconRepository.prefetch(bundleIDs: toPrefetch)
|
||
}
|
||
|
||
private func launchSections(apps: [(key: String, value: String)]) -> some View {
|
||
glassSection(title: "Launchable Apps".localized) {
|
||
LazyVStack(spacing: 12) {
|
||
ForEach(apps, id: \.key) { bundleID, appName in
|
||
let isPinned = pinnedSystemApps.contains(bundleID)
|
||
LaunchAppRow(
|
||
bundleID: bundleID,
|
||
appName: appName,
|
||
isLaunching: launchingBundles.contains(bundleID),
|
||
performanceMode: performanceMode
|
||
) {
|
||
startLaunching(bundleID: bundleID)
|
||
}
|
||
.overlay(alignment: .topTrailing) {
|
||
if isPinned {
|
||
Image(systemName: "star.fill")
|
||
.font(.system(size: 12, weight: .semibold))
|
||
.foregroundStyle(.yellow)
|
||
.padding(6)
|
||
}
|
||
}
|
||
.contextMenu {
|
||
Button((isPinned ? "Remove from Home" : "Add to Home").localized, systemImage: isPinned ? "star.slash" : "star") {
|
||
toggleSystemPin(bundleID: bundleID, appName: appName)
|
||
}
|
||
Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") {
|
||
UIPasteboard.general.string = bundleID
|
||
Haptics.light()
|
||
}
|
||
}
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||
Button {
|
||
toggleSystemPin(bundleID: bundleID, appName: appName)
|
||
} label: {
|
||
Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star")
|
||
}
|
||
.tint(.yellow)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var tabbedContent: some View {
|
||
VStack(spacing: 14) {
|
||
SegmentedControl(
|
||
titles: AppListTab.allCases.map { $0.title.localized },
|
||
selection: Binding(
|
||
get: { selectedTab.rawValue },
|
||
set: { newValue in
|
||
if let tab = AppListTab(rawValue: newValue) {
|
||
selectedTab = tab
|
||
}
|
||
}
|
||
)
|
||
)
|
||
.padding(.horizontal, 12)
|
||
|
||
tabContent(for: selectedTab)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func tabContent(for tab: AppListTab) -> some View {
|
||
switch tab {
|
||
case .debuggable:
|
||
if viewModel.debuggableApps.isEmpty {
|
||
VStack {
|
||
Spacer(minLength: 0)
|
||
emptyState(for: .debuggable)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 24)
|
||
.transition(.opacity.combined(with: .scale))
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
} else {
|
||
ScrollView {
|
||
VStack(spacing: 18) {
|
||
debuggableSearchBar
|
||
|
||
if let error = viewModel.lastError {
|
||
errorBanner(error)
|
||
}
|
||
|
||
if filteredDebuggableApps.isEmpty {
|
||
debuggableSearchEmptyState
|
||
.transition(.opacity.combined(with: .scale))
|
||
} else {
|
||
debuggableSections(
|
||
apps: filteredDebuggableApps,
|
||
favorites: filteredFavoriteBundles,
|
||
recents: filteredRecentBundles
|
||
)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 24)
|
||
}
|
||
}
|
||
case .launch:
|
||
ScrollView {
|
||
VStack(spacing: 18) {
|
||
launchSearchBar
|
||
|
||
if let error = viewModel.lastError {
|
||
errorBanner(error)
|
||
}
|
||
|
||
if filteredLaunchApps.isEmpty {
|
||
if launchSearchIsActive {
|
||
launchSearchEmptyState
|
||
.transition(.opacity.combined(with: .scale))
|
||
} else {
|
||
emptyState(for: .launch)
|
||
.transition(.opacity.combined(with: .scale))
|
||
}
|
||
} else {
|
||
launchSections(apps: filteredLaunchApps)
|
||
}
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 24)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var debuggableSearchBar: some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundStyle(.secondary)
|
||
|
||
TextField("Search apps or bundle ID".localized, text: $debuggableSearchText)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
|
||
if debuggableSearchIsActive {
|
||
Button {
|
||
debuggableSearchText = ""
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.accessibilityLabel("Clear search".localized)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
private var debuggableSearchEmptyState: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "text.magnifyingglass")
|
||
.font(.system(size: 44, weight: .regular))
|
||
.foregroundStyle(.secondary)
|
||
|
||
Text("No matching apps".localized)
|
||
.font(.title3.weight(.semibold))
|
||
|
||
Text("Try a different name or bundle identifier.".localized)
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(24)
|
||
.glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12)
|
||
}
|
||
|
||
private var launchSearchBar: some View {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.foregroundStyle(.secondary)
|
||
|
||
TextField("Search".localized, text: $launchSearchText)
|
||
.textInputAutocapitalization(.never)
|
||
.autocorrectionDisabled(true)
|
||
|
||
if launchSearchIsActive {
|
||
Button {
|
||
launchSearchText = ""
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill")
|
||
.font(.system(size: 16))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.accessibilityLabel("Clear search".localized)
|
||
}
|
||
}
|
||
.padding(.horizontal, 14)
|
||
.padding(.vertical, 10)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
private var launchSearchEmptyState: some View {
|
||
VStack(spacing: 12) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.system(size: 44, weight: .regular))
|
||
.foregroundStyle(.secondary)
|
||
|
||
Text("No matches".localized)
|
||
.font(.title3.weight(.semibold))
|
||
|
||
Text("Try another name or bundle identifier.".localized)
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.padding(24)
|
||
.glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12)
|
||
}
|
||
|
||
private func errorBanner(_ message: String) -> some View {
|
||
Text(message)
|
||
.font(.footnote)
|
||
.foregroundStyle(.orange)
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.fill(.ultraThinMaterial)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||
.stroke(.white.opacity(0.12), lineWidth: 1)
|
||
)
|
||
)
|
||
}
|
||
|
||
// MARK: Section Wrapper
|
||
|
||
private func glassSection<Content: View>(
|
||
title: String,
|
||
@ViewBuilder content: () -> Content
|
||
) -> some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Text(title)
|
||
.font(.headline)
|
||
.foregroundStyle(.secondary)
|
||
.accessibilityAddTraits(.isHeader)
|
||
|
||
content()
|
||
}
|
||
.padding(20)
|
||
.glassCard(material: .thinMaterial, strokeOpacity: 0.12)
|
||
}
|
||
|
||
// 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: "favoriteAppNames") 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.debuggableApps[id]
|
||
?? combinedLaunchApps[id]
|
||
?? fallbackReadableName(from: id)
|
||
return (id, name)
|
||
})
|
||
if prevFavNames != computedFavNames {
|
||
sharedDefaults.set(computedFavNames, forKey: "favoriteAppNames")
|
||
touched = true
|
||
}
|
||
|
||
if touched { WidgetCenter.shared.reloadAllTimelines() }
|
||
}
|
||
|
||
private func startLaunching(bundleID: String) {
|
||
guard !launchingBundles.contains(bundleID) else { return }
|
||
launchingBundles.insert(bundleID)
|
||
Haptics.selection()
|
||
|
||
viewModel.launchWithoutDebug(bundleID: bundleID) { success in
|
||
launchingBundles.remove(bundleID)
|
||
|
||
let message = success ? "Launch request sent".localized : "Launch failed".localized
|
||
let feedback = LaunchFeedback(message: message, success: success)
|
||
|
||
if success {
|
||
Haptics.light()
|
||
}
|
||
|
||
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
|
||
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
||
@Environment(\.themeExpansionManager) private var themeExpansion
|
||
|
||
var onSelectApp: (String) -> Void
|
||
let sharedDefaults: UserDefaults
|
||
let performanceMode: Bool
|
||
|
||
@State private var showScriptPicker = false
|
||
@State private var assignedScriptName: String?
|
||
@StateObject private var iconLoader: IconLoader
|
||
|
||
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
||
|
||
init(
|
||
bundleID: String,
|
||
appName: String,
|
||
recentApps: Binding<[String]>,
|
||
favoriteApps: Binding<[String]>,
|
||
onSelectApp: @escaping (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 ? 8 : 12)
|
||
.padding(.horizontal, 12)
|
||
.background(rowBackground)
|
||
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
}
|
||
.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 {
|
||
UIPasteboard.general.string = bundleID
|
||
Haptics.light()
|
||
} 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 {
|
||
UIPasteboard.general.string = bundleID
|
||
Haptics.light()
|
||
} 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("\(appName)")
|
||
.accessibilityHint("Double-tap to select. Swipe for actions, long-press for options.")
|
||
}
|
||
|
||
// 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: Row Background
|
||
|
||
private var rowBackground: some View {
|
||
ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
private func toggleFavorite() {
|
||
Haptics.light()
|
||
if favoriteApps.contains(bundleID) {
|
||
favoriteApps.removeAll { $0 == bundleID }
|
||
} else if favoriteApps.count < 4 {
|
||
favoriteApps.insert(bundleID, at: 0)
|
||
recentApps.removeAll { $0 == bundleID }
|
||
}
|
||
persistIfChanged()
|
||
}
|
||
|
||
private func assignScript(_ url: URL?) {
|
||
var mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] ?? [:]
|
||
if let url {
|
||
let filename = url.lastPathComponent
|
||
mapping[bundleID] = filename
|
||
assignedScriptName = filename
|
||
} else {
|
||
mapping.removeValue(forKey: bundleID)
|
||
assignedScriptName = nil
|
||
}
|
||
UserDefaults.standard.set(mapping, forKey: "BundleScriptMap")
|
||
Haptics.light()
|
||
}
|
||
|
||
private func resetScriptAssignment() {
|
||
assignScript(nil)
|
||
}
|
||
|
||
private static func currentAssignment(for bundleID: String) -> String? {
|
||
let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String]
|
||
return mapping?[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
|
||
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
||
@Environment(\.themeExpansionManager) private var themeExpansion
|
||
|
||
let performanceMode: Bool
|
||
var launchAction: () -> Void
|
||
|
||
@StateObject private var iconLoader: IconLoader
|
||
|
||
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
||
|
||
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 ? 8 : 12)
|
||
.padding(.horizontal, 12)
|
||
.background(rowBackground)
|
||
.contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isLaunching)
|
||
.onAppear {
|
||
if loadAppIconsOnJIT {
|
||
iconLoader.beginLoading()
|
||
}
|
||
}
|
||
.onChange(of: loadAppIconsOnJIT) { _, newValue in
|
||
if newValue {
|
||
iconLoader.beginLoading()
|
||
}
|
||
}
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel("\(appName)")
|
||
.accessibilityHint("Double-tap to launch via CoreDevice.")
|
||
}
|
||
|
||
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 var rowBackground: some View {
|
||
ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16)
|
||
}
|
||
}
|
||
|
||
private struct ThemedRowBackground: View {
|
||
var performanceMode: Bool
|
||
var style: BackgroundStyle
|
||
var cornerRadius: CGFloat
|
||
@Environment(\.colorScheme) private var colorScheme
|
||
|
||
var body: some View {
|
||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
|
||
return Group {
|
||
if performanceMode {
|
||
shape
|
||
.fill(Color(.secondarySystemBackground).opacity(0.65))
|
||
.overlay(shape.stroke(Color.white.opacity(0.10), lineWidth: 1))
|
||
} else {
|
||
ZStack {
|
||
shape.fill(.ultraThinMaterial)
|
||
themedOverlay(shape: shape)
|
||
shape.stroke(Color.white.opacity(0.15), lineWidth: 1)
|
||
}
|
||
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3)
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func themedOverlay(shape: RoundedRectangle) -> some View {
|
||
switch style {
|
||
case .staticGradient(let colors), .customGradient(let colors):
|
||
shape
|
||
.fill(
|
||
LinearGradient(
|
||
gradient: Gradient(colors: normalized(colors)),
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.opacity(0.32)
|
||
case .animatedGradient(let colors, _):
|
||
shape
|
||
.fill(
|
||
LinearGradient(
|
||
gradient: Gradient(colors: normalized(colors)),
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
)
|
||
.opacity(0.38)
|
||
case .blobs(let colors, _):
|
||
shape
|
||
.fill(
|
||
LinearGradient(
|
||
gradient: Gradient(colors: normalized(colors)),
|
||
startPoint: .top,
|
||
endPoint: .bottom
|
||
)
|
||
)
|
||
.opacity(0.40)
|
||
case .particles(let particle, _):
|
||
shape.fill(particle.opacity(0.18))
|
||
case .adaptiveGradient(let light, let dark):
|
||
let palette = colorScheme == .dark ? dark : light
|
||
shape
|
||
.fill(
|
||
LinearGradient(
|
||
gradient: Gradient(colors: normalized(palette)),
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.opacity(0.32)
|
||
}
|
||
}
|
||
|
||
private func normalized(_ colors: [Color]) -> [Color] {
|
||
if colors.count >= 2 { return colors }
|
||
if let first = colors.first { return [first, first.opacity(0.6)] }
|
||
return [Color.blue, Color.purple]
|
||
}
|
||
}
|
||
|
||
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? {
|
||
await withCheckedContinuation { continuation 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: UIScreen.main.scale) 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
|
||
}
|
||
let directory = container.appendingPathComponent("icons", isDirectory: true)
|
||
do {
|
||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||
} catch {
|
||
return nil
|
||
}
|
||
return directory.appendingPathComponent("\(bundleID).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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
var strokeOpacity: Double = 0.15
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.background(
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.fill(material)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||
.stroke(.white.opacity(strokeOpacity), lineWidth: 1)
|
||
)
|
||
)
|
||
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
func glassCard(
|
||
cornerRadius: CGFloat = 20,
|
||
material: Material = .ultraThinMaterial,
|
||
strokeOpacity: Double = 0.15
|
||
) -> some View {
|
||
modifier(GlassCard(cornerRadius: cornerRadius, material: material, strokeOpacity: strokeOpacity))
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
private struct SegmentedControl: UIViewRepresentable {
|
||
let titles: [String]
|
||
@Binding var selection: Int
|
||
|
||
func makeCoordinator() -> Coordinator {
|
||
Coordinator(self)
|
||
}
|
||
|
||
func makeUIView(context: Context) -> UISegmentedControl {
|
||
let control = UISegmentedControl(items: titles)
|
||
control.selectedSegmentIndex = selection
|
||
control.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged)
|
||
return control
|
||
}
|
||
|
||
func updateUIView(_ uiView: UISegmentedControl, context: Context) {
|
||
if uiView.numberOfSegments != titles.count {
|
||
uiView.removeAllSegments()
|
||
for (index, title) in titles.enumerated() {
|
||
uiView.insertSegment(withTitle: title, at: index, animated: false)
|
||
}
|
||
}
|
||
|
||
for (index, title) in titles.enumerated() {
|
||
uiView.setTitle(title, forSegmentAt: index)
|
||
}
|
||
|
||
if uiView.selectedSegmentIndex != selection {
|
||
uiView.selectedSegmentIndex = selection
|
||
}
|
||
}
|
||
|
||
final class Coordinator: NSObject {
|
||
var parent: SegmentedControl
|
||
|
||
init(_ parent: SegmentedControl) {
|
||
self.parent = parent
|
||
}
|
||
|
||
@objc func valueChanged(_ sender: UISegmentedControl) {
|
||
parent.selection = sender.selectedSegmentIndex
|
||
}
|
||
}
|
||
}
|
||
|
||
class InstalledAppsViewModel: ObservableObject {
|
||
@Published var debuggableApps: [String: String] = [:]
|
||
@Published var nonDebuggableApps: [String: String] = [:]
|
||
@Published var systemApps: [String: String] = [:]
|
||
@Published var isLoading = false
|
||
@Published var lastError: String? = nil
|
||
|
||
private let workQueue = DispatchQueue(label: "com.stik.installedApps", qos: .userInitiated)
|
||
|
||
init() { 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.debuggableApps = debuggable
|
||
self.nonDebuggableApps = nonDebuggable
|
||
self.systemApps = system
|
||
self.isLoading = false
|
||
}
|
||
} catch {
|
||
DispatchQueue.main.async {
|
||
self.debuggableApps = [:]
|
||
self.nonDebuggableApps = [:]
|
||
self.systemApps = [:]
|
||
self.isLoading = false
|
||
self.lastError = error.localizedDescription
|
||
print("Failed to load apps: \(error)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func launchWithoutDebug(bundleID: String, completion: @escaping (Bool) -> Void) {
|
||
workQueue.async {
|
||
let success = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil)
|
||
DispatchQueue.main.async {
|
||
completion(success)
|
||
}
|
||
}
|
||
}
|
||
}
|