diff --git a/DebugWidget/DebugWidget.swift b/DebugWidget/DebugWidget.swift index 55ae269..5f2e19b 100644 --- a/DebugWidget/DebugWidget.swift +++ b/DebugWidget/DebugWidget.swift @@ -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 +} + diff --git a/DebugWidget/DebugWidgetBundle.swift b/DebugWidget/DebugWidgetBundle.swift index 5089d30..77bf06e 100644 --- a/DebugWidget/DebugWidgetBundle.swift +++ b/DebugWidget/DebugWidgetBundle.swift @@ -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() } } diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index f56c9b7..8069eed 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -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[.. 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 - } - } -} diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 9ff6f99..df7013a 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -34,10 +34,9 @@ struct InstalledAppsListView: View { @State private var launchingBundles: Set = [] @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 = [] @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 { } } } +