mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
1232 lines
44 KiB
Swift
1232 lines
44 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()
|
|
|
|
@State private var appIcons: [String: UIImage] = [:]
|
|
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("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 otherSearchText: 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) }
|
|
}
|
|
|
|
private enum AppListTab: String, CaseIterable, Identifiable {
|
|
case debuggable
|
|
case other
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .debuggable: return "Debuggable"
|
|
case .other: return "Other"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LaunchFeedback: Identifiable {
|
|
let id = UUID()
|
|
let message: String
|
|
let success: Bool
|
|
}
|
|
|
|
private var sortedOtherApps: [(key: String, value: String)] {
|
|
viewModel.nonDebuggableApps.sorted { lhs, rhs in
|
|
let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value)
|
|
if comparison == .orderedSame {
|
|
return lhs.key < rhs.key
|
|
}
|
|
return comparison == .orderedAscending
|
|
}
|
|
}
|
|
|
|
private var otherSearchIsActive: Bool {
|
|
!otherSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private var filteredOtherApps: [(key: String, value: String)] {
|
|
let base = sortedOtherApps
|
|
guard otherSearchIsActive else { return base }
|
|
let query = normalizedSearchString(otherSearchText)
|
|
guard !query.isEmpty else { return base }
|
|
return base.filter { matches(query, bundleID: $0.key, name: $0.value) }
|
|
}
|
|
|
|
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 .other:
|
|
return filteredOtherApps.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)
|
|
}
|
|
|
|
// 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 .other:
|
|
Text("Nothing To Show".localized)
|
|
.font(.title2.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
|
|
Text("""
|
|
Once your device pairing file is imported and CoreDevice is connected, every installed app will appear here for quick access.
|
|
""".localized)
|
|
.font(.body)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.glassCard(cornerRadius: 24, material: .thinMaterial, strokeOpacity: 0.12)
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel("Empty list. Import pairing file or sign app appropriately.".localized)
|
|
}
|
|
|
|
// 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,
|
|
appIcons: $appIcons,
|
|
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,
|
|
appIcons: $appIcons,
|
|
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,
|
|
appIcons: $appIcons,
|
|
onSelectApp: onSelectApp,
|
|
sharedDefaults: sharedDefaults,
|
|
performanceMode: performanceMode
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func otherSections(apps: [(key: String, value: String)]) -> some View {
|
|
VStack(spacing: 18) {
|
|
glassSection(title: "How It Works".localized) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Launch apps without get-task-allow by tunneling through CoreDevice. This only starts the app; attach debugging from Recents once it exposes get-task-allow.".localized)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
glassSection(title: "All Installed Apps".localized) {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(apps, id: \.key) { bundleID, appName in
|
|
LaunchAppRow(
|
|
bundleID: bundleID,
|
|
appName: appName,
|
|
isLaunching: launchingBundles.contains(bundleID),
|
|
appIcons: $appIcons,
|
|
performanceMode: performanceMode
|
|
) {
|
|
startLaunching(bundleID: bundleID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var tabbedContent: some View {
|
|
tabContent(for: .debuggable)
|
|
}
|
|
|
|
@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 .other:
|
|
otherTabContent(apps: filteredOtherApps)
|
|
}
|
|
}
|
|
|
|
private func otherTabContent(apps: [(key: String, value: String)]) -> some View {
|
|
ScrollView {
|
|
VStack(spacing: 18) {
|
|
searchBar
|
|
|
|
if let error = viewModel.lastError {
|
|
errorBanner(error)
|
|
}
|
|
|
|
if apps.isEmpty {
|
|
if otherSearchIsActive {
|
|
otherSearchEmptyState
|
|
.transition(.opacity.combined(with: .scale))
|
|
} else {
|
|
emptyState(for: .other)
|
|
.transition(.opacity.combined(with: .scale))
|
|
}
|
|
} else {
|
|
otherSections(apps: apps)
|
|
}
|
|
}
|
|
.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 otherSearchEmptyState: 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 var searchBar: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundStyle(.secondary)
|
|
|
|
TextField("Search".localized, text: $otherSearchText)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled(true)
|
|
|
|
if !otherSearchText.isEmpty {
|
|
Button {
|
|
otherSearchText = ""
|
|
} 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 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]) ?? []
|
|
|
|
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() }
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - App Button Row
|
|
|
|
struct AppButton: View {
|
|
let bundleID: String
|
|
let appName: String
|
|
|
|
@Binding var recentApps: [String]
|
|
@Binding var favoriteApps: [String]
|
|
@Binding var appIcons: [String: UIImage]
|
|
|
|
@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
|
|
|
|
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
|
|
|
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")
|
|
}
|
|
}
|
|
}
|
|
.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 {
|
|
loadAppIcon(for: bundleID)
|
|
}
|
|
}
|
|
.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 = appIcons[bundleID] {
|
|
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 {
|
|
mapping[bundleID] = url.lastPathComponent
|
|
} else {
|
|
mapping.removeValue(forKey: bundleID)
|
|
}
|
|
UserDefaults.standard.set(mapping, forKey: "BundleScriptMap")
|
|
Haptics.light()
|
|
}
|
|
|
|
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() }
|
|
}
|
|
|
|
private func loadAppIcon(for bundleID: String) {
|
|
guard loadAppIconsOnJIT else { return }
|
|
IconCache.shared.fetchIcon(for: bundleID) { image in
|
|
guard let image else { return }
|
|
let shouldAnimate = appIcons[bundleID] == nil
|
|
if shouldAnimate {
|
|
withAnimation(.linear(duration: 0.12)) {
|
|
appIcons[bundleID] = image
|
|
}
|
|
} else {
|
|
appIcons[bundleID] = image
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - IconCache
|
|
|
|
struct LaunchAppRow: View {
|
|
let bundleID: String
|
|
let appName: String
|
|
let isLaunching: Bool
|
|
|
|
@Binding var appIcons: [String: UIImage]
|
|
|
|
@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
|
|
|
|
private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
|
|
|
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 {
|
|
loadAppIcon(for: bundleID)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(appName)")
|
|
.accessibilityHint("Double-tap to launch via CoreDevice.")
|
|
}
|
|
|
|
private var iconView: some View {
|
|
Group {
|
|
if loadAppIconsOnJIT, let image = appIcons[bundleID] {
|
|
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 func loadAppIcon(for bundleID: String) {
|
|
IconCache.shared.fetchIcon(for: bundleID) { image in
|
|
guard let image else { return }
|
|
let shouldAnimate = appIcons[bundleID] == nil
|
|
if shouldAnimate {
|
|
withAnimation(.linear(duration: 0.12)) {
|
|
appIcons[bundleID] = image
|
|
}
|
|
} else {
|
|
appIcons[bundleID] = image
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ThemedRowBackground: View {
|
|
var performanceMode: Bool
|
|
var style: BackgroundStyle
|
|
var cornerRadius: CGFloat
|
|
|
|
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))
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
}
|
|
|
|
final class IconCache {
|
|
static let shared = IconCache()
|
|
|
|
private let mem = NSCache<NSString, UIImage>()
|
|
private let queue = OperationQueue()
|
|
private let callbackQueue = DispatchQueue(label: "com.stik.iconcache.callbacks")
|
|
private var pendingCallbacks: [String: [(UIImage?) -> Void]] = [:]
|
|
|
|
private init() {
|
|
queue.maxConcurrentOperationCount = 4
|
|
queue.qualityOfService = .userInitiated
|
|
mem.countLimit = 1000
|
|
mem.totalCostLimit = 64 * 1024 * 1024
|
|
}
|
|
|
|
func fetchIcon(for bundleID: String, completion: @escaping (UIImage?) -> Void) {
|
|
if let memImage = mem.object(forKey: bundleID as NSString) {
|
|
DispatchQueue.main.async { completion(memImage) }
|
|
return
|
|
}
|
|
|
|
var shouldStartLoad = false
|
|
callbackQueue.sync {
|
|
if var existing = pendingCallbacks[bundleID] {
|
|
existing.append(completion)
|
|
pendingCallbacks[bundleID] = existing
|
|
} else {
|
|
pendingCallbacks[bundleID] = [completion]
|
|
shouldStartLoad = true
|
|
}
|
|
}
|
|
|
|
guard shouldStartLoad else { return }
|
|
|
|
queue.addOperation { [weak self] in
|
|
guard let self else { return }
|
|
|
|
var loadedFromDisk = false
|
|
var result = self.diskCachedIcon(for: bundleID)
|
|
if result != nil {
|
|
loadedFromDisk = true
|
|
}
|
|
|
|
if result == nil {
|
|
let semaphore = DispatchSemaphore(value: 0)
|
|
AppStoreIconFetcher.getIcon(for: bundleID) { image in
|
|
result = image
|
|
semaphore.signal()
|
|
}
|
|
_ = semaphore.wait(timeout: .now() + 10)
|
|
}
|
|
|
|
if let image = result {
|
|
let cost = Int(image.size.width * image.size.height * image.scale * image.scale)
|
|
self.mem.setObject(image, forKey: bundleID as NSString, cost: cost)
|
|
if !loadedFromDisk {
|
|
saveIconToGroup(image, bundleID: bundleID)
|
|
}
|
|
}
|
|
|
|
let callbacks = self.callbackQueue.sync { () -> [(UIImage?) -> Void] in
|
|
let handlers = self.pendingCallbacks[bundleID] ?? []
|
|
self.pendingCallbacks[bundleID] = nil
|
|
return handlers
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
callbacks.forEach { $0(result) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func diskCachedIcon(for bundleID: String) -> UIImage? {
|
|
guard let containerURL = FileManager.default
|
|
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj")
|
|
else { return nil }
|
|
|
|
let iconsDir = containerURL.appendingPathComponent("icons", isDirectory: true)
|
|
let fileURL = iconsDir.appendingPathComponent("\(bundleID).png")
|
|
guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil }
|
|
return UIImage(contentsOfFile: fileURL.path)
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
fileprivate func saveIconToGroup(_ image: UIImage, bundleID: String) {
|
|
guard let data = image.pngData(),
|
|
let container = FileManager.default
|
|
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj")
|
|
else { return }
|
|
let iconsDir = container.appendingPathComponent("icons", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: iconsDir, withIntermediateDirectories: true)
|
|
let fileURL = iconsDir.appendingPathComponent("\(bundleID).png")
|
|
try? data.write(to: fileURL)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
InstalledAppsListView { _ in }
|
|
.environment(\.colorScheme, .dark)
|
|
}
|
|
|
|
class InstalledAppsViewModel: ObservableObject {
|
|
@Published var debuggableApps: [String: String] = [:]
|
|
@Published var nonDebuggableApps: [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 nonDebuggableSequence = allApps.filter { debuggable[$0.key] == nil }
|
|
let nonDebuggable = Dictionary(uniqueKeysWithValues: nonDebuggableSequence.map { ($0.key, $0.value) })
|
|
|
|
DispatchQueue.main.async {
|
|
self.debuggableApps = debuggable
|
|
self.nonDebuggableApps = nonDebuggable
|
|
self.isLoading = false
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
self.debuggableApps = [:]
|
|
self.nonDebuggableApps = [:]
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|