mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
2.3.0 RC 4 (WIP)
This commit is contained in:
parent
0bd73257c2
commit
861c51314d
4 changed files with 347 additions and 381 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue