2.3.0 RC 4 (WIP)

This commit is contained in:
Stephen B 2025-09-23 18:02:47 -04:00
parent 0bd73257c2
commit 861c51314d
4 changed files with 347 additions and 381 deletions

View file

@ -11,16 +11,22 @@ import UIKit
// MARK: - Favorites Widget ----------------------------------------------------
struct FavoriteSnapshot: Identifiable {
let bundleID: String
let displayName: String
var id: String { bundleID }
}
struct FavoritesEntry: TimelineEntry {
let date: Date
let bundleIDs: [String]
let items: [FavoriteSnapshot]
}
struct FavoritesProvider: TimelineProvider {
private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")
func placeholder(in context: Context) -> FavoritesEntry {
FavoritesEntry(date: .now, bundleIDs: [])
FavoritesEntry(date: .now, items: [])
}
func getSnapshot(in context: Context, completion: @escaping (FavoritesEntry) -> Void) {
@ -34,7 +40,12 @@ struct FavoritesProvider: TimelineProvider {
private func makeEntry() -> FavoritesEntry {
let favorites = sharedDefaults?.stringArray(forKey: "favoriteApps") ?? []
return FavoritesEntry(date: .now, bundleIDs: Array(favorites.prefix(4)))
let names = sharedDefaults?.dictionary(forKey: "favoriteAppNames") as? [String: String] ?? [:]
let items = favorites.prefix(4).map { bundleID -> FavoriteSnapshot in
let display = names[bundleID] ?? friendlyNameFromBundleID(bundleID)
return FavoriteSnapshot(bundleID: bundleID, displayName: display)
}
return FavoritesEntry(date: .now, items: items)
}
}
@ -44,10 +55,11 @@ struct FavoritesWidgetEntryView: View {
var body: some View {
HStack(spacing: 8) {
ForEach(0..<4, id: \.self) { idx in
if idx < entry.bundleIDs.count {
iconCell(bundleID: entry.bundleIDs[idx])
if idx < entry.items.count {
labeledIconCell(item: entry.items[idx])
} else {
placeholderCell()
.frame(maxWidth: .infinity)
}
}
}
@ -56,29 +68,43 @@ struct FavoritesWidgetEntryView: View {
}
@ViewBuilder
private func iconCell(bundleID: String) -> some View {
if let img = loadIcon(for: bundleID) {
Link(destination: URL(string: "stikjit://enable-jit?bundle-id=\(bundleID)")!) {
Image(uiImage: img)
.resizable()
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
private func labeledIconCell(item: FavoriteSnapshot) -> some View {
if let img = loadIcon(for: item.bundleID) {
Link(destination: URL(string: "stikjit://enable-jit?bundle-id=\(item.bundleID)")!) {
VStack(spacing: 6) {
Image(uiImage: img)
.resizable()
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
Text(item.displayName)
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.65)
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
}
} else {
placeholderCell()
.frame(maxWidth: .infinity)
}
}
@ViewBuilder
private func placeholderCell() -> some View {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(UIColor.systemGray5))
Image(systemName: "plus")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.gray)
VStack(spacing: 6) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(UIColor.systemGray5))
Image(systemName: "plus")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.gray)
}
.aspectRatio(1, contentMode: .fit)
Text(" ")
.font(.caption2)
.opacity(0) // keep height consistent
}
.aspectRatio(1, contentMode: .fit)
}
}
@ -95,7 +121,7 @@ struct FavoritesWidget: Widget {
}
}
// MARK: - System Apps Widget -------------------------------------------------
// MARK: - Launch Shortcuts Widget (formerly System Apps) ---------------------
struct SystemAppSnapshot: Identifiable {
let bundleID: String
@ -125,6 +151,7 @@ struct SystemAppsProvider: TimelineProvider {
}
private func makeEntry() -> SystemAppsEntry {
// This list now represents "pinned launch apps" (system or other)
let pinned = sharedDefaults?.stringArray(forKey: "pinnedSystemApps") ?? []
let names = sharedDefaults?.dictionary(forKey: "pinnedSystemAppNames") as? [String: String] ?? [:]
let snapshots = pinned.prefix(4).map { bundleID -> SystemAppSnapshot in
@ -149,59 +176,58 @@ struct SystemAppsWidgetEntryView: View {
let entry: SystemAppsEntry
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(entry.items) { item in
Link(destination: URL(string: "stikjit://enable-jit?bundle-id=\(item.bundleID)")!) {
HStack(spacing: 10) {
icon(for: item.bundleID)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 2) {
Text(item.displayName)
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(1)
Text(item.bundleID)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(UIColor.secondarySystemBackground).opacity(0.35))
)
HStack(spacing: 8) {
ForEach(0..<4, id: \.self) { idx in
if idx < entry.items.count {
labeledIconCell(item: entry.items[idx])
} else {
placeholderCell()
.frame(maxWidth: .infinity)
}
}
if entry.items.isEmpty {
Text("Pin system apps from StikDebug to see them here.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
.padding(12)
.padding(8)
.containerBackground(Color(UIColor.systemBackground), for: .widget)
}
@ViewBuilder
private func icon(for bundleID: String) -> some View {
if let image = loadIcon(for: bundleID) {
Image(uiImage: image)
.resizable()
.aspectRatio(1, contentMode: .fill)
.cornerRadius(10)
private func labeledIconCell(item: SystemAppSnapshot) -> some View {
if let img = loadIcon(for: item.bundleID) {
// Use launch-app to mirror non-debug launch behavior
Link(destination: URL(string: "stikjit://launch-app?bundle-id=\(item.bundleID)")!) {
VStack(spacing: 6) {
Image(uiImage: img)
.resizable()
.aspectRatio(1, contentMode: .fit)
.cornerRadius(12)
Text(item.displayName)
.font(.caption2)
.lineLimit(1)
.minimumScaleFactor(0.65)
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
}
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color(UIColor.systemGray5))
.overlay(
Image(systemName: "app")
.font(.system(size: 20, weight: .regular))
.foregroundColor(.gray)
)
placeholderCell()
.frame(maxWidth: .infinity)
}
}
@ViewBuilder
private func placeholderCell() -> some View {
VStack(spacing: 6) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(UIColor.systemGray5))
Image(systemName: "plus")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.gray)
}
.aspectRatio(1, contentMode: .fit)
Text(" ")
.font(.caption2)
.opacity(0) // keep height consistent
}
}
}
@ -213,8 +239,8 @@ struct SystemAppsWidget: Widget {
StaticConfiguration(kind: kind, provider: SystemAppsProvider()) { entry in
SystemAppsWidgetEntryView(entry: entry)
}
.configurationDisplayName("Hidden System Apps")
.description("Launch your pinned hidden system apps directly from the widget.")
.configurationDisplayName("Launch Shortcuts")
.description("Pin any app to launch directly from the widget.")
.supportedFamilies([.systemMedium])
}
}
@ -230,3 +256,15 @@ private func loadIcon(for bundleID: String) -> UIImage? {
.appendingPathComponent("\(bundleID).png")
return UIImage(contentsOfFile: url.path)
}
/// Fallback readable name from a bundle identifier
private func friendlyNameFromBundleID(_ 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
}

View file

@ -11,7 +11,9 @@ import SwiftUI
@main
struct StikDebugWidgetBundle: WidgetBundle {
var body: some Widget {
// Both widgets enabled: Favorites uses enable-jit URL scheme (with PiP/script handled in-app),
// System Apps uses launch-app URL scheme for non-debug launch behavior.
FavoritesWidget()
// SystemAppsWidget() // Temporarily disabled
SystemAppsWidget()
}
}

View file

@ -101,8 +101,8 @@ struct HomeView: View {
readinessCard
quickConnectCard
if !systemPinnedItems.isEmpty {
systemAppsCard
if !pinnedLaunchItems.isEmpty {
launchShortcutsCard
}
toolsCard
tipsCard
@ -229,10 +229,37 @@ struct HomeView: View {
var autoScriptData: Data? = nil
var autoScriptName: String? = nil
if let auto = autoScriptForBundleIfNeeded(bundleID: selectedBundle) {
autoScriptData = auto.data
autoScriptName = auto.name
let appName: String? = (try? JITEnableContext.shared.getAppList()[selectedBundle])
if #available(iOS 26, *) {
if ProcessInfo.processInfo.hasTXM, let appName {
if appName == "maciOS" {
if let url = Bundle.main.url(forResource: "script1", withExtension: "js"),
let data = try? Data(contentsOf: url) {
autoScriptData = data
autoScriptName = "script1.js"
}
} else if appName == "Amethyst" {
if let url = Bundle.main.url(forResource: "script2", withExtension: "js"),
let data = try? Data(contentsOf: url) {
autoScriptData = data
autoScriptName = "script2.js"
}
} else if appName == "MeloNX" {
if let url = Bundle.main.url(forResource: "melo", withExtension: "js"),
let data = try? Data(contentsOf: url) {
autoScriptData = data
autoScriptName = "melo.js"
}
} else if appName == "UTM" {
if let url = Bundle.main.url(forResource: "utmjit", withExtension: "js"),
let data = try? Data(contentsOf: url) {
autoScriptData = data
autoScriptName = "utmjit.js"
}
}
}
}
startJITInBackground(bundleID: selectedBundle,
@ -273,37 +300,48 @@ struct HomeView: View {
)
}
.onOpenURL { url in
guard url.host == "enable-jit" else { return }
var config = JITEnableConfiguration()
guard let host = url.host else { return }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if let pidStr = components?.queryItems?.first(where: { $0.name == "pid" })?.value, let pid = Int(pidStr) {
config.pid = pid
}
if let bundleId = components?.queryItems?.first(where: { $0.name == "bundle-id" })?.value {
config.bundleID = bundleId
}
if let scriptBase64URL = components?.queryItems?.first(where: { $0.name == "script-data" })?.value?.removingPercentEncoding {
let base64 = base64URLToBase64(scriptBase64URL)
if let scriptData = Data(base64Encoded: base64) {
config.scriptData = scriptData
switch host {
case "enable-jit":
var config = JITEnableConfiguration()
if let pidStr = components?.queryItems?.first(where: { $0.name == "pid" })?.value, let pid = Int(pidStr) {
config.pid = pid
}
}
if let scriptName = components?.queryItems?.first(where: { $0.name == "script-name" })?.value {
config.scriptName = scriptName
}
// If no explicit script was provided via URL, try to attach the same
// auto-selected script we use from the Installed Apps flow.
if config.scriptData == nil, let bundle = config.bundleID,
let auto = autoScriptForBundleIfNeeded(bundleID: bundle) {
config.scriptData = auto.data
config.scriptName = auto.name
}
if viewDidAppeared {
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
} else {
pendingJITEnableConfiguration = config
if let bundleId = components?.queryItems?.first(where: { $0.name == "bundle-id" })?.value {
config.bundleID = bundleId
}
if let scriptBase64URL = components?.queryItems?.first(where: { $0.name == "script-data" })?.value?.removingPercentEncoding {
let base64 = base64URLToBase64(scriptBase64URL)
if let scriptData = Data(base64Encoded: base64) {
config.scriptData = scriptData
}
}
if let scriptName = components?.queryItems?.first(where: { $0.name == "script-name" })?.value {
config.scriptName = scriptName
}
if viewDidAppeared {
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
} else {
pendingJITEnableConfiguration = config
}
case "launch-app":
if let bundleId = components?.queryItems?.first(where: { $0.name == "bundle-id" })?.value {
HapticFeedbackHelper.trigger()
DispatchQueue.global(qos: .userInitiated).async {
let success = JITEnableContext.shared.launchAppWithoutDebug(bundleId, logger: nil)
DispatchQueue.main.async {
let nameRaw = pinnedSystemAppNames[bundleId] ?? friendlyName(for: bundleId)
let name = shortDisplayName(from: nameRaw)
systemLaunchMessage = success
? String(format: "Launch requested: %@".localized, name)
: String(format: "Failed to launch %@".localized, name)
scheduleSystemToastDismiss()
}
}
}
default:
break
}
}
.onAppear {
@ -425,7 +463,7 @@ struct HomeView: View {
}
return .init(
title: "Ready when you are",
subtitle: "Everything is ready. Select an app to enable debugging or JIT in seconds.",
subtitle: "Everything is ready. Select an app to start debugging in seconds.",
icon: "bolt.horizontal.circle.fill",
tint: .green
)
@ -611,7 +649,7 @@ struct HomeView: View {
}
}
Text("Favorites and recents stay within reach so you can enable JIT without leaving Home.")
Text("Favorites and recents stay within reach so you can enable debug with ease.")
.font(.footnote)
.foregroundStyle(.secondary)
@ -657,19 +695,19 @@ struct HomeView: View {
}
}
private var systemAppsCard: some View {
private var launchShortcutsCard: some View {
homeCard {
VStack(alignment: .leading, spacing: 14) {
Text("System Apps".localized)
Text("Launch Shortcuts".localized)
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
Text("Launch hidden system bundles without leaving Home.".localized)
Text("Pin any app from Installed Apps and launch it here with ease.".localized)
.font(.footnote)
.foregroundStyle(.secondary)
VStack(spacing: 10) {
ForEach(systemPinnedItems) { item in
ForEach(pinnedLaunchItems) { item in
SystemPinnedRow(
item: item,
accentColor: accentColor,
@ -694,15 +732,19 @@ struct HomeView: View {
return ordered
}
private var systemPinnedItems: [SystemPinnedItem] {
private var pinnedLaunchItems: [SystemPinnedItem] {
pinnedSystemApps.compactMap { bundleID in
let displayName = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID)
let raw = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID)
let displayName = shortDisplayName(from: raw)
return SystemPinnedItem(bundleID: bundleID, displayName: displayName)
}
}
// Prefer CoreDevice-reported app name, trimmed to a Home Screen–style label; else fall back to bundle ID last component.
private func friendlyName(for bundleID: String) -> String {
if let cached = cachedAppNames[bundleID], !cached.isEmpty { return cached }
if let cached = cachedAppNames[bundleID], !cached.isEmpty {
return shortDisplayName(from: cached)
}
let components = bundleID.split(separator: ".")
if let last = components.last {
let cleaned = last.replacingOccurrences(of: "_", with: " ")
@ -712,6 +754,34 @@ struct HomeView: View {
return bundleID
}
// Heuristic “Home Screen” shortener for long marketing names.
private func shortDisplayName(from name: String) -> String {
var s = name
// Keep only the part before common separators/subtitles.
let separators = [" — ", " – ", " - ", ":", "|", "·", "•"]
for sep in separators {
if let r = s.range(of: sep) {
s = String(s[..<r.lowerBound])
break
}
}
// Drop common suffixes like "for iPad", "for iOS"
let suffixes = [
" for iPhone", " for iPad", " for iOS", " for iPadOS",
" iPhone", " iPad", " iOS", " iPadOS"
]
for suf in suffixes {
if s.localizedCaseInsensitiveContains(suf) {
s = s.replacingOccurrences(of: suf, with: "", options: [.caseInsensitive])
}
}
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? name : s
}
private func loadAppListIfNeeded(force: Bool = false) {
guard pairingFileExists else {
cachedAppNames = [:]
@ -1198,6 +1268,7 @@ private struct QuickConnectRow: View {
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(item.bundleID)
.font(.caption)
@ -1246,6 +1317,7 @@ private struct SystemPinnedRow: View {
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(item.bundleID)
.font(.caption)
@ -1595,38 +1667,3 @@ public extension ProcessInfo {
}
}
// MARK: - Auto script selection for URL and in-app flows
private extension HomeView {
struct AutoScript {
let data: Data
let name: String
}
func autoScriptForBundleIfNeeded(bundleID: String) -> AutoScript? {
guard #available(iOS 26, *), ProcessInfo.processInfo.hasTXM else { return nil }
let appName: String? = {
let list = try? JITEnableContext.shared.getAppList()
return list?[bundleID]
}()
func load(resource: String, as name: String) -> AutoScript? {
guard let url = Bundle.main.url(forResource: resource, withExtension: "js"),
let data = try? Data(contentsOf: url) else { return nil }
return AutoScript(data: data, name: name)
}
switch appName {
case "maciOS":
return load(resource: "script1", as: "script1.js")
case "Amethyst":
return load(resource: "script2", as: "script2.js")
case "MeloNX":
return load(resource: "melo", as: "melo.js")
case "UTM":
return load(resource: "utmjit", as: "utmjit.js")
default:
return nil
}
}
}

View file

@ -34,10 +34,9 @@ struct InstalledAppsListView: View {
@State private var launchingBundles: Set<String> = []
@State private var launchFeedback: LaunchFeedback? = nil
@State private var debuggableSearchText: String = ""
@State private var otherSearchText: String = ""
@State private var launchSearchText: String = ""
@State private var prefetchedBundleIDs: Set<String> = []
@State private var selectedTab: AppListTab = .debuggable
@State private var systemSearchText: String = ""
@AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = []
@AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:]
@ -82,18 +81,48 @@ struct InstalledAppsListView: View {
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: String, CaseIterable, Identifiable {
case debuggable
case system
case other
case launch
var id: String { rawValue }
var title: String {
switch self {
case .debuggable: return "Debuggable"
case .system: return "System Apps"
case .other: return "Other"
case .launch: return "Launch"
}
}
}
@ -104,50 +133,6 @@ struct InstalledAppsListView: View {
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 sortedSystemApps: [(key: String, value: String)] {
viewModel.systemApps.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 var systemSearchIsActive: Bool {
!systemSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var filteredSystemApps: [(key: String, value: String)] {
let base = sortedSystemApps
guard systemSearchIsActive else { return base }
let query = normalizedSearchString(systemSearchText)
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)
@ -165,10 +150,8 @@ struct InstalledAppsListView: View {
switch tab {
case .debuggable:
return viewModel.debuggableApps.isEmpty
case .other:
return filteredOtherApps.isEmpty
case .system:
return filteredSystemApps.isEmpty
case .launch:
return filteredLaunchApps.isEmpty
}
}
@ -275,6 +258,8 @@ struct InstalledAppsListView: View {
prefetchedBundleIDs.removeAll()
} else {
prefetchPriorityIcons()
// Ensure names for existing favorites are persisted once app list is ready
persistIfChanged()
}
}
.onChange(of: selectedTab) { _, _ in prefetchPriorityIcons() }
@ -305,25 +290,13 @@ struct InstalledAppsListView: View {
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
case .system:
Text("No Hidden System Apps".localized)
case .launch:
Text("No Launchable Apps".localized)
.font(.title2.weight(.semibold))
.foregroundStyle(.primary)
Text("""
Hidden system apps appear here once CoreDevice exposes them. Try launching a bundle from the Other tab to surface background services.
""".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.
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)
@ -334,18 +307,7 @@ struct InstalledAppsListView: View {
.padding(24)
.glassCard(cornerRadius: 24, material: .thinMaterial, strokeOpacity: 0.12)
.accessibilityElement(children: .combine)
.accessibilityLabel(emptyStateAccessibilityLabel(for: tab).localized)
}
private func emptyStateAccessibilityLabel(for tab: AppListTab) -> String {
switch tab {
case .debuggable:
return "No debuggable apps available"
case .system:
return "No hidden system apps available"
case .other:
return "No other apps available"
}
.accessibilityLabel(tab == .debuggable ? "No debuggable apps available" : "No launchable apps available")
}
// MARK: Apps List
@ -434,8 +396,7 @@ struct InstalledAppsListView: View {
appendUnique(recentApps)
appendUnique(pinnedSystemApps)
appendUnique(debuggableSortedApps.map { $0.key })
appendUnique(sortedSystemApps.map { $0.key })
appendUnique(sortedOtherApps.map { $0.key })
appendUnique(sortedLaunchApps.map { $0.key })
let toPrefetch = priorityIDs.filter { !prefetchedBundleIDs.contains($0) }
guard !toPrefetch.isEmpty else { return }
@ -444,26 +405,8 @@ struct InstalledAppsListView: View {
IconCache.shared.prefetchIcons(for: toPrefetch)
}
private func otherSections(apps: [(key: String, value: String)]) -> some View {
glassSection(title: "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 func systemSections(apps: [(key: String, value: String)]) -> some View {
glassSection(title: "Hidden System Apps".localized) {
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)
@ -560,62 +503,30 @@ struct InstalledAppsListView: View {
.padding(.vertical, 24)
}
}
case .system:
systemTabContent(apps: filteredSystemApps)
case .other:
otherTabContent(apps: filteredOtherApps)
}
}
case .launch:
ScrollView {
VStack(spacing: 18) {
launchSearchBar
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))
if let error = viewModel.lastError {
errorBanner(error)
}
} else {
otherSections(apps: apps)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
}
private func systemTabContent(apps: [(key: String, value: String)]) -> some View {
ScrollView {
VStack(spacing: 18) {
systemSearchBar
if let error = viewModel.lastError {
errorBanner(error)
}
if apps.isEmpty {
if systemSearchIsActive {
systemSearchEmptyState
.transition(.opacity.combined(with: .scale))
if filteredLaunchApps.isEmpty {
if launchSearchIsActive {
launchSearchEmptyState
.transition(.opacity.combined(with: .scale))
} else {
emptyState(for: .launch)
.transition(.opacity.combined(with: .scale))
}
} else {
emptyState(for: .system)
.transition(.opacity.combined(with: .scale))
launchSections(apps: filteredLaunchApps)
}
} else {
systemSections(apps: apps)
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
.padding(.horizontal, 20)
.padding(.vertical, 24)
}
}
@ -668,7 +579,39 @@ struct InstalledAppsListView: View {
.glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12)
}
private var otherSearchEmptyState: some View {
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))
@ -685,87 +628,6 @@ struct InstalledAppsListView: View {
.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 var systemSearchBar: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField("Search system apps".localized, text: $systemSearchText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
if !systemSearchText.isEmpty {
Button {
systemSearchText = ""
} 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 systemSearchEmptyState: some View {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 44, weight: .regular))
.foregroundStyle(.secondary)
Text("No hidden matches".localized)
.font(.title3.weight(.semibold))
Text("Try another bundle identifier or internal process name.".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)
@ -808,6 +670,7 @@ struct InstalledAppsListView: View {
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")
@ -825,6 +688,19 @@ struct InstalledAppsListView: View {
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() }
}
@ -857,6 +733,7 @@ struct InstalledAppsListView: View {
}
}
// 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) {
@ -875,6 +752,17 @@ struct InstalledAppsListView: View {
}
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
@ -1559,3 +1447,4 @@ class InstalledAppsViewModel: ObservableObject {
}
}
}