mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
Test
This commit is contained in:
parent
c84b6b4219
commit
fdbcec2908
13 changed files with 919 additions and 497 deletions
138
StikJIT/Utilities/AppFeature.swift
Normal file
138
StikJIT/Utilities/AppFeature.swift
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
//
|
||||
// AppFeature.swift
|
||||
// StikJIT
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum AppFeature: String, CaseIterable, Identifiable {
|
||||
case home
|
||||
case scripts
|
||||
case tools
|
||||
case news
|
||||
case console
|
||||
case deviceInfo = "deviceinfo"
|
||||
case profiles
|
||||
case processes
|
||||
case location
|
||||
case settings
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "Apps"
|
||||
case .scripts:
|
||||
return "Scripts"
|
||||
case .tools:
|
||||
return "Tools"
|
||||
case .news:
|
||||
return "News"
|
||||
case .console:
|
||||
return "Console"
|
||||
case .deviceInfo:
|
||||
return "Device Info"
|
||||
case .profiles:
|
||||
return "App Expiry"
|
||||
case .processes:
|
||||
return "Processes"
|
||||
case .location:
|
||||
return "Location"
|
||||
case .settings:
|
||||
return "Settings"
|
||||
}
|
||||
}
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "Manage installed apps"
|
||||
case .scripts:
|
||||
return "Manage and run JS scripts"
|
||||
case .tools:
|
||||
return "Access additional tools"
|
||||
case .news:
|
||||
return "Latest StikDebug updates"
|
||||
case .console:
|
||||
return "Live device logs"
|
||||
case .deviceInfo:
|
||||
return "View detailed device metadata"
|
||||
case .profiles:
|
||||
return "Check app expiration dates"
|
||||
case .processes:
|
||||
return "Inspect running apps"
|
||||
case .location:
|
||||
return "Simulate GPS location"
|
||||
case .settings:
|
||||
return "Configure StikDebug"
|
||||
}
|
||||
}
|
||||
|
||||
var toolTitle: String {
|
||||
switch self {
|
||||
case .location:
|
||||
return "Location Simulation"
|
||||
default:
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .home:
|
||||
return "square.grid.2x2"
|
||||
case .scripts:
|
||||
return "scroll"
|
||||
case .tools:
|
||||
return "wrench.and.screwdriver"
|
||||
case .news:
|
||||
return "newspaper"
|
||||
case .console:
|
||||
return "terminal"
|
||||
case .deviceInfo:
|
||||
return "iphone.and.arrow.forward"
|
||||
case .profiles:
|
||||
return "calendar.badge.clock"
|
||||
case .processes:
|
||||
return "rectangle.stack.person.crop"
|
||||
case .location:
|
||||
return "location"
|
||||
case .settings:
|
||||
return "gearshape.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var destination: some View {
|
||||
switch self {
|
||||
case .home:
|
||||
HomeView()
|
||||
case .scripts:
|
||||
ScriptListView()
|
||||
case .tools:
|
||||
ToolsView()
|
||||
case .news:
|
||||
NewsView()
|
||||
case .console:
|
||||
ConsoleLogsView()
|
||||
case .deviceInfo:
|
||||
DeviceInfoView()
|
||||
case .profiles:
|
||||
ProfileView()
|
||||
case .processes:
|
||||
ProcessInspectorView()
|
||||
case .location:
|
||||
LocationSimulationView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppFeature {
|
||||
static let mainTabs: [AppFeature] = [.home, .tools, .news, .settings]
|
||||
static let toolList: [AppFeature] = [.scripts, .console, .deviceInfo, .profiles, .processes, .location]
|
||||
}
|
||||
50
StikJIT/Utilities/AutoScriptAssignments.swift
Normal file
50
StikJIT/Utilities/AutoScriptAssignments.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// AutoScriptAssignments.swift
|
||||
// StikJIT
|
||||
//
|
||||
|
||||
private struct AutoScriptAssignment {
|
||||
let appNames: Set<String>
|
||||
let resource: ScriptResource
|
||||
}
|
||||
|
||||
extension ScriptStore {
|
||||
private static let autoScriptAssignments: [AutoScriptAssignment] = [
|
||||
AutoScriptAssignment(
|
||||
appNames: [
|
||||
"Amethyst",
|
||||
"MeloNX",
|
||||
"Melo",
|
||||
"XeniOS",
|
||||
"MeloCafe",
|
||||
"Manic EMU",
|
||||
"Manic",
|
||||
"Geode",
|
||||
"DukeX",
|
||||
"Duke X",
|
||||
"Duke"
|
||||
],
|
||||
resource: ScriptResource(resourceName: "universal", fileName: "universal.js")
|
||||
),
|
||||
AutoScriptAssignment(
|
||||
appNames: [
|
||||
"UTM",
|
||||
"DolphiniOS",
|
||||
"Flycast"
|
||||
],
|
||||
resource: ScriptResource(resourceName: "UTM-Dolphin", fileName: "UTM-Dolphin.js")
|
||||
),
|
||||
AutoScriptAssignment(
|
||||
appNames: [
|
||||
"maciOS"
|
||||
],
|
||||
resource: ScriptResource(resourceName: "maciOS", fileName: "maciOS.js")
|
||||
)
|
||||
]
|
||||
|
||||
static func autoScriptResource(for appName: String) -> ScriptResource? {
|
||||
autoScriptAssignments.first { assignment in
|
||||
assignment.appNames.contains(appName)
|
||||
}?.resource
|
||||
}
|
||||
}
|
||||
|
|
@ -158,19 +158,6 @@ enum ScriptStore {
|
|||
return names?[bundleID]
|
||||
}
|
||||
|
||||
static func autoScriptResource(for appName: String) -> ScriptResource? {
|
||||
switch appName {
|
||||
case "Amethyst", "MeloNX", "Melo", "XeniOS", "MeloCafe", "Manic EMU", "Manic", "Geode", "DukeX", "Duke X", "Duke":
|
||||
return ScriptResource(resourceName: "universal", fileName: "universal.js")
|
||||
case "UTM", "DolphiniOS", "Flycast":
|
||||
return ScriptResource(resourceName: "UTM-Dolphin", fileName: "UTM-Dolphin.js")
|
||||
case "maciOS":
|
||||
return ScriptResource(resourceName: "maciOS", fileName: "maciOS.js")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureBundledScripts(in directory: URL, fileManager: FileManager) throws {
|
||||
for resource in bundledResources {
|
||||
guard let bundleURL = Bundle.main.url(forResource: resource.resourceName, withExtension: "js") else {
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
import Foundation
|
||||
|
||||
enum TabConfiguration {
|
||||
static let storageKey = "enabledTabIdentifiers"
|
||||
static let maxSelectableTabs = 12
|
||||
private static let coreIDs: [String] = ["home", "scripts", "tools", "deviceinfo"]
|
||||
static var allowedIDs: [String] {
|
||||
var ids = coreIDs
|
||||
ids.append("profiles")
|
||||
ids.append("processes")
|
||||
ids.append("location")
|
||||
return ids
|
||||
}
|
||||
static let defaultIDs: [String] = ["home", "scripts", "tools", "deviceinfo", "profiles", "processes", "location"]
|
||||
static let defaultRawValue = serialize(defaultIDs)
|
||||
|
||||
static func sanitize(raw: String) -> [String] {
|
||||
let ids = raw
|
||||
.split(separator: ",")
|
||||
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
return sanitize(ids: ids)
|
||||
}
|
||||
|
||||
static func sanitize(ids: [String]) -> [String] {
|
||||
var result: [String] = []
|
||||
for id in ids {
|
||||
guard allowedIDs.contains(id) else { continue }
|
||||
if !result.contains(id) {
|
||||
result.append(id)
|
||||
}
|
||||
if result.count == maxSelectableTabs {
|
||||
break
|
||||
}
|
||||
}
|
||||
if result.isEmpty {
|
||||
result = defaultIDs
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
static func serialize(_ ids: [String]) -> String {
|
||||
sanitize(ids: ids).joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
|
@ -149,14 +149,8 @@ struct ConsoleLogsView: View {
|
|||
.padding(.horizontal, 4)
|
||||
|
||||
ForEach(logManager.logs) { logEntry in
|
||||
Text(AttributedString(createLogAttributedString(logEntry)))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 4)
|
||||
AppLogRow(logEntry: logEntry, colorScheme: colorScheme)
|
||||
.equatable()
|
||||
.id(logEntry.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -219,13 +213,8 @@ struct ConsoleLogsView: View {
|
|||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(filteredSyslogEntries) { entry in
|
||||
Text(AttributedString(createSyslogAttributedString(entry)))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 4)
|
||||
SyslogRow(entry: entry, colorScheme: colorScheme)
|
||||
.equatable()
|
||||
.id(entry.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -292,77 +281,9 @@ struct ConsoleLogsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func createLogAttributedString(_ logEntry: LogManager.LogEntry) -> NSAttributedString {
|
||||
let fullString = NSMutableAttributedString()
|
||||
|
||||
let timestampString = "[\(formatTime(date: logEntry.timestamp))]"
|
||||
let timestampAttr = NSAttributedString(
|
||||
string: timestampString,
|
||||
attributes: [.foregroundColor: colorScheme == .dark ? UIColor.gray : UIColor.darkGray]
|
||||
)
|
||||
fullString.append(timestampAttr)
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
|
||||
let typeString = "[\(logEntry.type.rawValue)]"
|
||||
let typeColor = UIColor(colorForLogType(logEntry.type))
|
||||
let typeAttr = NSAttributedString(
|
||||
string: typeString,
|
||||
attributes: [.foregroundColor: typeColor]
|
||||
)
|
||||
fullString.append(typeAttr)
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
|
||||
let messageAttr = NSAttributedString(
|
||||
string: logEntry.message,
|
||||
attributes: [.foregroundColor: colorScheme == .dark ? UIColor.white : UIColor.black]
|
||||
)
|
||||
fullString.append(messageAttr)
|
||||
|
||||
return fullString
|
||||
}
|
||||
|
||||
private func createSyslogAttributedString(_ entry: SystemLogStream.Entry) -> NSAttributedString {
|
||||
let type = Self.logType(for: entry.raw)
|
||||
let fullString = NSMutableAttributedString()
|
||||
|
||||
let timestampString = "[\(DateFormatter.consoleLogsFormatter.string(from: entry.timestamp))]"
|
||||
fullString.append(NSAttributedString(
|
||||
string: timestampString,
|
||||
attributes: [.foregroundColor: colorScheme == .dark ? UIColor.gray : UIColor.darkGray]
|
||||
))
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
|
||||
let typeAttr = NSAttributedString(
|
||||
string: "[\(type.rawValue)]",
|
||||
attributes: [.foregroundColor: UIColor(colorForLogType(type))]
|
||||
)
|
||||
fullString.append(typeAttr)
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
|
||||
let messageAttr = NSAttributedString(
|
||||
string: entry.raw,
|
||||
attributes: [.foregroundColor: colorScheme == .dark ? UIColor.white : UIColor.black]
|
||||
)
|
||||
fullString.append(messageAttr)
|
||||
|
||||
return fullString
|
||||
}
|
||||
private func formatTime(date: Date) -> String {
|
||||
DateFormatter.consoleLogsFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private func colorForLogType(_ type: LogManager.LogEntry.LogType) -> Color {
|
||||
switch type {
|
||||
case .info:
|
||||
return .green
|
||||
case .error:
|
||||
return .red
|
||||
case .debug:
|
||||
return .blue
|
||||
case .warning:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func logType(for line: String) -> LogManager.LogEntry.LogType {
|
||||
let lowercase = line.lowercased()
|
||||
|
|
@ -377,12 +298,6 @@ struct ConsoleLogsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var syslogErrorCount: Int {
|
||||
systemLogStream.entries.reduce(0) { count, entry in
|
||||
count + (Self.logType(for: entry.raw) == .error ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func intervalLabel(for value: Double) -> String {
|
||||
if value <= 0 {
|
||||
return "Live"
|
||||
|
|
@ -566,6 +481,117 @@ struct ConsoleLogsView_Previews: PreviewProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private struct AppLogRow: View, Equatable {
|
||||
let logEntry: LogManager.LogEntry
|
||||
let colorScheme: ColorScheme
|
||||
|
||||
static func == (lhs: AppLogRow, rhs: AppLogRow) -> Bool {
|
||||
lhs.logEntry.id == rhs.logEntry.id && lhs.colorScheme == rhs.colorScheme
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(AttributedString(attributedString))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(nil)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var attributedString: NSAttributedString {
|
||||
let fullString = NSMutableAttributedString()
|
||||
|
||||
fullString.append(NSAttributedString(
|
||||
string: "[\(DateFormatter.consoleLogsFormatter.string(from: logEntry.timestamp))]",
|
||||
attributes: [.foregroundColor: secondaryTextColor]
|
||||
))
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
fullString.append(NSAttributedString(
|
||||
string: "[\(logEntry.type.rawValue)]",
|
||||
attributes: [.foregroundColor: UIColor(logEntry.type.consoleColor)]
|
||||
))
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
fullString.append(NSAttributedString(
|
||||
string: logEntry.message,
|
||||
attributes: [.foregroundColor: primaryTextColor]
|
||||
))
|
||||
|
||||
return fullString
|
||||
}
|
||||
|
||||
private var primaryTextColor: UIColor {
|
||||
colorScheme == .dark ? .white : .black
|
||||
}
|
||||
|
||||
private var secondaryTextColor: UIColor {
|
||||
colorScheme == .dark ? .gray : .darkGray
|
||||
}
|
||||
}
|
||||
|
||||
private struct SyslogRow: View, Equatable {
|
||||
let entry: SystemLogStream.Entry
|
||||
let colorScheme: ColorScheme
|
||||
|
||||
static func == (lhs: SyslogRow, rhs: SyslogRow) -> Bool {
|
||||
lhs.entry.id == rhs.entry.id && lhs.colorScheme == rhs.colorScheme
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(AttributedString(attributedString))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var attributedString: NSAttributedString {
|
||||
let type = logType(for: entry.raw)
|
||||
let fullString = NSMutableAttributedString()
|
||||
|
||||
fullString.append(NSAttributedString(
|
||||
string: "[\(DateFormatter.consoleLogsFormatter.string(from: entry.timestamp))]",
|
||||
attributes: [.foregroundColor: secondaryTextColor]
|
||||
))
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
fullString.append(NSAttributedString(
|
||||
string: "[\(type.rawValue)]",
|
||||
attributes: [.foregroundColor: UIColor(type.consoleColor)]
|
||||
))
|
||||
fullString.append(NSAttributedString(string: " "))
|
||||
fullString.append(NSAttributedString(
|
||||
string: entry.raw,
|
||||
attributes: [.foregroundColor: primaryTextColor]
|
||||
))
|
||||
|
||||
return fullString
|
||||
}
|
||||
|
||||
private var primaryTextColor: UIColor {
|
||||
colorScheme == .dark ? .white : .black
|
||||
}
|
||||
|
||||
private var secondaryTextColor: UIColor {
|
||||
colorScheme == .dark ? .gray : .darkGray
|
||||
}
|
||||
|
||||
private func logType(for line: String) -> LogManager.LogEntry.LogType {
|
||||
let lowercase = line.lowercased()
|
||||
if lowercase.contains("error") {
|
||||
return .error
|
||||
} else if lowercase.contains("warning") {
|
||||
return .warning
|
||||
} else if lowercase.contains("debug") {
|
||||
return .debug
|
||||
} else {
|
||||
return .info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ConsoleTab: Hashable {
|
||||
case idevice
|
||||
case syslog
|
||||
|
|
@ -585,3 +611,18 @@ private extension DateFormatter {
|
|||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
private extension LogManager.LogEntry.LogType {
|
||||
var consoleColor: Color {
|
||||
switch self {
|
||||
case .info:
|
||||
return .green
|
||||
case .error:
|
||||
return .red
|
||||
case .debug:
|
||||
return .blue
|
||||
case .warning:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,45 @@ import Combine
|
|||
|
||||
// MARK: - Installed Apps List
|
||||
|
||||
struct InstalledAppListItem: Identifiable, Equatable {
|
||||
let bundleID: String
|
||||
let name: String
|
||||
private let normalizedBundleID: String
|
||||
private let normalizedName: String
|
||||
|
||||
var id: String {
|
||||
bundleID
|
||||
}
|
||||
|
||||
init(bundleID: String, name: String) {
|
||||
self.bundleID = bundleID
|
||||
self.name = name
|
||||
self.normalizedBundleID = Self.normalized(bundleID)
|
||||
self.normalizedName = Self.normalized(name)
|
||||
}
|
||||
|
||||
func matches(_ query: String) -> Bool {
|
||||
query.isEmpty || normalizedBundleID.contains(query) || normalizedName.contains(query)
|
||||
}
|
||||
|
||||
static func sorted(from apps: [String: String]) -> [InstalledAppListItem] {
|
||||
apps.map { InstalledAppListItem(bundleID: $0.key, name: $0.value) }
|
||||
.sorted { lhs, rhs in
|
||||
let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name)
|
||||
if comparison == .orderedSame {
|
||||
return lhs.bundleID < rhs.bundleID
|
||||
}
|
||||
return comparison == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
static func normalized(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
struct InstalledAppsListView: View {
|
||||
@StateObject private var viewModel = InstalledAppsViewModel()
|
||||
|
||||
|
|
@ -55,28 +94,17 @@ struct InstalledAppsListView: View {
|
|||
}
|
||||
|
||||
private var debuggableSearchIsActive: Bool {
|
||||
!debuggableSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
!InstalledAppListItem.normalized(debuggableSearchText).isEmpty
|
||||
}
|
||||
|
||||
private var debuggableSortedApps: [(key: String, value: String)] {
|
||||
viewModel.debuggableApps.sorted { lhs, rhs in
|
||||
let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value)
|
||||
if comparison == .orderedSame {
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
return comparison == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredDebuggableApps: [(key: String, value: String)] {
|
||||
guard debuggableSearchIsActive else { return debuggableSortedApps }
|
||||
let query = normalizedSearchString(debuggableSearchText)
|
||||
guard !query.isEmpty else { return debuggableSortedApps }
|
||||
return debuggableSortedApps.filter { matches(query, bundleID: $0.key, name: $0.value) }
|
||||
private var filteredDebuggableApps: [InstalledAppListItem] {
|
||||
let query = InstalledAppListItem.normalized(debuggableSearchText)
|
||||
guard !query.isEmpty else { return viewModel.debuggableItems }
|
||||
return viewModel.debuggableItems.filter { $0.matches(query) }
|
||||
}
|
||||
|
||||
private var filteredDebuggableSet: Set<String> {
|
||||
Set(filteredDebuggableApps.map { $0.key })
|
||||
Set(filteredDebuggableApps.map(\.bundleID))
|
||||
}
|
||||
|
||||
private var filteredFavoriteBundles: [String] {
|
||||
|
|
@ -87,36 +115,14 @@ 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
|
||||
!InstalledAppListItem.normalized(launchSearchText).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 var filteredLaunchApps: [InstalledAppListItem] {
|
||||
let query = InstalledAppListItem.normalized(launchSearchText)
|
||||
guard !query.isEmpty else { return viewModel.launchItems }
|
||||
return viewModel.launchItems.filter { $0.matches(query) }
|
||||
}
|
||||
|
||||
private enum AppListTab: Int, CaseIterable, Identifiable {
|
||||
|
|
@ -139,23 +145,10 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
let success: Bool
|
||||
}
|
||||
|
||||
private func normalizedSearchString(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
private func matches(_ normalizedQuery: String, bundleID: String, name: String) -> Bool {
|
||||
guard !normalizedQuery.isEmpty else { return true }
|
||||
let identifier = normalizedSearchString(bundleID)
|
||||
let displayName = normalizedSearchString(name)
|
||||
return identifier.contains(normalizedQuery) || displayName.contains(normalizedQuery)
|
||||
}
|
||||
|
||||
private func isEmpty(for tab: AppListTab) -> Bool {
|
||||
switch tab {
|
||||
case .debuggable:
|
||||
return viewModel.debuggableApps.isEmpty
|
||||
return viewModel.debuggableItems.isEmpty
|
||||
case .launch:
|
||||
return filteredLaunchApps.isEmpty
|
||||
}
|
||||
|
|
@ -267,8 +260,8 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
appendUnique(favoriteApps)
|
||||
appendUnique(recentApps)
|
||||
appendUnique(pinnedSystemApps)
|
||||
appendUnique(debuggableSortedApps.map { $0.key })
|
||||
appendUnique(sortedLaunchApps.map { $0.key })
|
||||
appendUnique(viewModel.debuggableItems.map(\.bundleID))
|
||||
appendUnique(viewModel.launchItems.map(\.bundleID))
|
||||
|
||||
let toPrefetch = priorityIDs.filter { !prefetchedBundleIDs.contains($0) }
|
||||
guard !toPrefetch.isEmpty else { return }
|
||||
|
|
@ -309,7 +302,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
ForEach(filteredFavoriteBundles, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID),
|
||||
appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID),
|
||||
recentApps: $recentApps, favoriteApps: $favoriteApps,
|
||||
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
|
||||
)
|
||||
|
|
@ -321,7 +314,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
ForEach(filteredRecentBundles, id: \.self) { bundleID in
|
||||
AppButton(
|
||||
bundleID: bundleID,
|
||||
appName: viewModel.debuggableApps[bundleID] ?? fallbackReadableName(from: bundleID),
|
||||
appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID),
|
||||
recentApps: $recentApps, favoriteApps: $favoriteApps,
|
||||
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
|
||||
)
|
||||
|
|
@ -329,9 +322,9 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
}
|
||||
}
|
||||
Section("Apps with get-task-allow".localized) {
|
||||
ForEach(filteredDebuggableApps, id: \.key) { bundleID, appName in
|
||||
ForEach(filteredDebuggableApps) { app in
|
||||
AppButton(
|
||||
bundleID: bundleID, appName: appName,
|
||||
bundleID: app.bundleID, appName: app.name,
|
||||
recentApps: $recentApps, favoriteApps: $favoriteApps,
|
||||
onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode
|
||||
)
|
||||
|
|
@ -366,13 +359,13 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
}
|
||||
} else {
|
||||
Section("All Apps".localized) {
|
||||
ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in
|
||||
let isPinned = pinnedSystemApps.contains(bundleID)
|
||||
ForEach(filteredLaunchApps) { app in
|
||||
let isPinned = pinnedSystemApps.contains(app.bundleID)
|
||||
LaunchAppRow(
|
||||
bundleID: bundleID, appName: appName,
|
||||
isLaunching: launchingBundles.contains(bundleID),
|
||||
bundleID: app.bundleID, appName: app.name,
|
||||
isLaunching: launchingBundles.contains(app.bundleID),
|
||||
performanceMode: performanceMode
|
||||
) { startLaunching(bundleID: bundleID, appName: appName) }
|
||||
) { startLaunching(bundleID: app.bundleID, appName: app.name) }
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if isPinned {
|
||||
Image(systemName: "star.fill")
|
||||
|
|
@ -384,16 +377,16 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
.contextMenu {
|
||||
Button((isPinned ? "Remove from Home" : "Add to Home").localized,
|
||||
systemImage: isPinned ? "star.slash" : "star") {
|
||||
toggleSystemPin(bundleID: bundleID, appName: appName)
|
||||
toggleSystemPin(bundleID: app.bundleID, appName: app.name)
|
||||
}
|
||||
Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") {
|
||||
UIPasteboard.general.string = bundleID
|
||||
UIPasteboard.general.string = app.bundleID
|
||||
Haptics.light()
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
toggleSystemPin(bundleID: bundleID, appName: appName)
|
||||
toggleSystemPin(bundleID: app.bundleID, appName: app.name)
|
||||
} label: {
|
||||
Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star")
|
||||
}
|
||||
|
|
@ -436,8 +429,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
|||
|
||||
// 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]
|
||||
let name = viewModel.displayName(for: id)
|
||||
?? fallbackReadableName(from: id)
|
||||
return (id, name)
|
||||
})
|
||||
|
|
@ -1020,7 +1012,8 @@ enum AppIconRepository {
|
|||
}
|
||||
|
||||
private static func loadFromDisk(bundleID: String) async -> UIImage? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let imageScale = await MainActor.run { UIScreen.main.scale }
|
||||
return await withCheckedContinuation { (continuation: CheckedContinuation<UIImage?, Never>) in
|
||||
diskQueue.async {
|
||||
guard let url = iconURL(for: bundleID),
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
|
|
@ -1032,7 +1025,7 @@ enum AppIconRepository {
|
|||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
guard let image = UIImage(data: data, scale: UIScreen.main.scale) else {
|
||||
guard let image = UIImage(data: data, scale: imageScale) else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
|
|
@ -1178,6 +1171,8 @@ class InstalledAppsViewModel: ObservableObject {
|
|||
@Published var debuggableApps: [String: String] = [:]
|
||||
@Published var nonDebuggableApps: [String: String] = [:]
|
||||
@Published var systemApps: [String: String] = [:]
|
||||
@Published private(set) var debuggableItems: [InstalledAppListItem] = []
|
||||
@Published private(set) var launchItems: [InstalledAppListItem] = []
|
||||
@Published var isLoading = false
|
||||
@Published var lastError: String? = nil
|
||||
|
||||
|
|
@ -1220,9 +1215,7 @@ class InstalledAppsViewModel: ObservableObject {
|
|||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.debuggableApps = debuggable
|
||||
self.nonDebuggableApps = nonDebuggable
|
||||
self.systemApps = system
|
||||
self.apply(debuggable: debuggable, nonDebuggable: nonDebuggable, system: system)
|
||||
self.isLoading = false
|
||||
self.cacheApps(debuggable: debuggable, nonDebuggable: nonDebuggable, system: system)
|
||||
}
|
||||
|
|
@ -1247,12 +1240,30 @@ class InstalledAppsViewModel: ObservableObject {
|
|||
let cachedSystem = decode(cacheKeySystem)
|
||||
|
||||
if !cachedDebuggable.isEmpty || !cachedNonDebuggable.isEmpty || !cachedSystem.isEmpty {
|
||||
debuggableApps = cachedDebuggable
|
||||
nonDebuggableApps = cachedNonDebuggable
|
||||
systemApps = cachedSystem
|
||||
apply(debuggable: cachedDebuggable, nonDebuggable: cachedNonDebuggable, system: cachedSystem)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) {
|
||||
debuggableApps = debuggable
|
||||
nonDebuggableApps = nonDebuggable
|
||||
systemApps = system
|
||||
debuggableItems = InstalledAppListItem.sorted(from: debuggable)
|
||||
launchItems = InstalledAppListItem.sorted(from: Self.launchApps(nonDebuggable: nonDebuggable, system: system))
|
||||
}
|
||||
|
||||
private static func launchApps(nonDebuggable: [String: String], system: [String: String]) -> [String: String] {
|
||||
var combined = nonDebuggable
|
||||
for (bundleID, name) in system {
|
||||
combined[bundleID] = name
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
func displayName(for bundleID: String) -> String? {
|
||||
debuggableApps[bundleID] ?? systemApps[bundleID] ?? nonDebuggableApps[bundleID]
|
||||
}
|
||||
|
||||
private func cacheApps(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) {
|
||||
func encode(_ value: [String: String]) -> Data? {
|
||||
try? JSONEncoder().encode(value)
|
||||
|
|
|
|||
|
|
@ -8,58 +8,69 @@
|
|||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
private struct TabDescriptor: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let builder: () -> AnyView
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let switchToTab = Notification.Name("MainTabSwitchNotification")
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
@AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers: String = TabConfiguration.defaultRawValue
|
||||
@AppStorage("primaryTabSelection") private var selection: String = TabConfiguration.defaultIDs.first ?? "home"
|
||||
@AppStorage("primaryTabSelection") private var selection: String = AppFeature.home.id
|
||||
@State private var switchObserver: Any?
|
||||
@State private var detachedTab: TabDescriptor?
|
||||
@State private var detachedFeature: AppFeature?
|
||||
@State private var didSetInitialHome = false
|
||||
|
||||
private var configurableTabs: [TabDescriptor] {
|
||||
let tabs: [TabDescriptor] = [
|
||||
TabDescriptor(id: "home", title: "Apps", systemImage: "square.grid.2x2") { AnyView(HomeView()) },
|
||||
TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) },
|
||||
TabDescriptor(id: "tools", title: "Tools", systemImage: "wrench.and.screwdriver") { AnyView(ToolsView()) },
|
||||
TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) },
|
||||
TabDescriptor(id: "profiles", title: "App Expiry", systemImage: "calendar.badge.clock") { AnyView(ProfileView()) },
|
||||
TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) },
|
||||
TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) }
|
||||
]
|
||||
return tabs
|
||||
}
|
||||
|
||||
private var availableTabs: [TabDescriptor] {
|
||||
configurableTabs
|
||||
}
|
||||
|
||||
private let settingsTab = TabDescriptor(id: "settings", title: "Settings", systemImage: "gearshape.fill") {
|
||||
AnyView(SettingsView())
|
||||
}
|
||||
|
||||
private var selectedTabDescriptors: [TabDescriptor] {
|
||||
let ids = TabConfiguration.sanitize(raw: enabledTabIdentifiers)
|
||||
return ids.compactMap { id in
|
||||
availableTabs.first(where: { $0.id == id })
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.clear.ignoresSafeArea()
|
||||
|
||||
TabView(selection: $selection) {
|
||||
ForEach(AppFeature.mainTabs) { feature in
|
||||
feature.destination
|
||||
.tabItem { Label(feature.title, systemImage: feature.systemImage) }
|
||||
.tag(feature.id)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
ensureSelectionIsValid()
|
||||
if !didSetInitialHome {
|
||||
selection = AppFeature.home.id
|
||||
didSetInitialHome = true
|
||||
}
|
||||
guard switchObserver == nil else { return }
|
||||
switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in
|
||||
guard let id = note.object as? String else { return }
|
||||
openFeature(id: id)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer = switchObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
switchObserver = nil
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
}
|
||||
.sheet(item: $detachedFeature) { feature in
|
||||
NavigationStack {
|
||||
feature.destination
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
detachedFeature = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func ensureSelectionIsValid() {
|
||||
let ids = displayTabs.map { $0.id }
|
||||
let ids = AppFeature.mainTabs.map { $0.id }
|
||||
if ids.contains(selection) {
|
||||
return
|
||||
}
|
||||
selection = ids.first ?? settingsTab.id
|
||||
selection = AppFeature.home.id
|
||||
}
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
|
|
@ -70,7 +81,7 @@ struct MainTabView: View {
|
|||
simulateLocation(from: url)
|
||||
case "location", "location-simulation":
|
||||
if coordinate(from: url) == nil {
|
||||
openTab(id: "location")
|
||||
openFeature(id: AppFeature.location.id)
|
||||
} else {
|
||||
simulateLocation(from: url)
|
||||
}
|
||||
|
|
@ -81,11 +92,15 @@ struct MainTabView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func openTab(id: String) {
|
||||
if displayTabs.contains(where: { $0.id == id }) {
|
||||
selection = id
|
||||
} else if let descriptor = availableTabs.first(where: { $0.id == id }) {
|
||||
detachedTab = descriptor
|
||||
private func openFeature(id: String) {
|
||||
guard let feature = AppFeature(rawValue: id) else {
|
||||
return
|
||||
}
|
||||
|
||||
if AppFeature.mainTabs.contains(feature) {
|
||||
selection = feature.id
|
||||
} else {
|
||||
detachedFeature = feature
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -202,71 +217,6 @@ struct MainTabView: View {
|
|||
return Double(text[matchRange])
|
||||
}
|
||||
}
|
||||
|
||||
private var displayTabs: [TabDescriptor] {
|
||||
var tabs = ["home", "tools"].compactMap { id in
|
||||
configurableTabs.first(where: { $0.id == id })
|
||||
}
|
||||
tabs.insert(settingsTab, at: min(2, tabs.count))
|
||||
return tabs
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Allow global themed background to show
|
||||
Color.clear.ignoresSafeArea()
|
||||
|
||||
// Main tabs
|
||||
TabView(selection: $selection) {
|
||||
ForEach(displayTabs) { descriptor in
|
||||
descriptor.builder()
|
||||
.tabItem { Label(descriptor.title, systemImage: descriptor.systemImage) }
|
||||
.tag(descriptor.id)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
enabledTabIdentifiers = TabConfiguration.serialize(TabConfiguration.sanitize(raw: enabledTabIdentifiers))
|
||||
ensureSelectionIsValid()
|
||||
if !didSetInitialHome {
|
||||
if selectedTabDescriptors.contains(where: { $0.id == "home" }) {
|
||||
selection = "home"
|
||||
} else if let descriptor = availableTabs.first(where: { $0.id == "home" }) {
|
||||
detachedTab = descriptor
|
||||
}
|
||||
didSetInitialHome = true
|
||||
}
|
||||
switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in
|
||||
guard let id = note.object as? String else { return }
|
||||
openTab(id: id)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if let observer = switchObserver {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
switchObserver = nil
|
||||
}
|
||||
}
|
||||
.onChange(of: enabledTabIdentifiers) { _, _ in
|
||||
ensureSelectionIsValid()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
}
|
||||
.sheet(item: $detachedTab) { descriptor in
|
||||
NavigationStack {
|
||||
descriptor.builder()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
detachedTab = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct MainTabView_Previews: PreviewProvider {
|
||||
|
|
|
|||
|
|
@ -747,11 +747,20 @@ struct LocationSimulationView: View {
|
|||
@State private var routeStartSelection: RouteSearchSelection?
|
||||
@State private var routeEndSelection: RouteSearchSelection?
|
||||
@State private var routePlan: RouteSimulationPlan?
|
||||
@State private var routePolyline: MKPolyline?
|
||||
@State private var routePlaybackSamples: [RoutePlaybackSample] = []
|
||||
@State private var routePlaybackCoordinate: CLLocationCoordinate2D?
|
||||
@State private var simulatedCoordinate: CLLocationCoordinate2D?
|
||||
@State private var routeRequestID = UUID()
|
||||
|
||||
private static let routeDurationFormatter: DateComponentsFormatter = {
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute]
|
||||
formatter.unitsStyle = .abbreviated
|
||||
formatter.zeroFormattingBehavior = .dropAll
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// Bookmarks
|
||||
@State private var bookmarks: [LocationBookmark] = []
|
||||
@State private var showBookmarks = false
|
||||
|
|
@ -771,14 +780,6 @@ struct LocationSimulationView: View {
|
|||
return stored.isEmpty ? "10.7.0.1" : stored
|
||||
}
|
||||
|
||||
private var routePolyline: MKPolyline? {
|
||||
guard let routePlan, routePlan.displayCoordinates.count > 1 else { return nil }
|
||||
return routePlan.displayCoordinates.withUnsafeBufferPointer { buffer in
|
||||
guard let baseAddress = buffer.baseAddress else { return nil }
|
||||
return MKPolyline(coordinates: baseAddress, count: buffer.count)
|
||||
}
|
||||
}
|
||||
|
||||
private var routeStartCoordinate: CLLocationCoordinate2D? {
|
||||
routeStartSelection?.coordinate
|
||||
}
|
||||
|
|
@ -810,11 +811,7 @@ struct LocationSimulationView: View {
|
|||
value: routePlan.distance / 1000,
|
||||
unit: UnitLength.kilometers
|
||||
).formatted(.measurement(width: .abbreviated, usage: .road))
|
||||
let formatter = DateComponentsFormatter()
|
||||
formatter.allowedUnits = [.hour, .minute]
|
||||
formatter.unitsStyle = .abbreviated
|
||||
formatter.zeroFormattingBehavior = .dropAll
|
||||
let durationText = formatter.string(from: routePlan.expectedTravelTime)
|
||||
let durationText = Self.routeDurationFormatter.string(from: routePlan.expectedTravelTime)
|
||||
if let durationText, !durationText.isEmpty {
|
||||
return "\(distanceText) • ETA \(durationText)"
|
||||
}
|
||||
|
|
@ -1071,6 +1068,19 @@ struct LocationSimulationView: View {
|
|||
newBookmarkName = ""
|
||||
}
|
||||
|
||||
private func setRoutePlan(_ plan: RouteSimulationPlan?) {
|
||||
routePlan = plan
|
||||
routePolyline = plan.flatMap { makeRoutePolyline(for: $0.displayCoordinates) }
|
||||
}
|
||||
|
||||
private func makeRoutePolyline(for coordinates: [CLLocationCoordinate2D]) -> MKPolyline? {
|
||||
guard coordinates.count > 1 else { return nil }
|
||||
return coordinates.withUnsafeBufferPointer { buffer in
|
||||
guard let baseAddress = buffer.baseAddress else { return nil }
|
||||
return MKPolyline(coordinates: baseAddress, count: buffer.count)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location
|
||||
|
||||
private func selectSearchResult(_ result: MKLocalSearchCompletion) {
|
||||
|
|
@ -1148,7 +1158,7 @@ struct LocationSimulationView: View {
|
|||
routeSpeedPrefetchTask?.cancel()
|
||||
routeSpeedPrefetchTask = nil
|
||||
routeRequestID = UUID()
|
||||
routePlan = nil
|
||||
setRoutePlan(nil)
|
||||
routePlaybackSamples = []
|
||||
routePlaybackCoordinate = nil
|
||||
isLoadingRoute = false
|
||||
|
|
@ -1170,11 +1180,11 @@ struct LocationSimulationView: View {
|
|||
let fallbackSpeed = RouteSimulationDefaults.importedRouteFallbackSpeedMetersPerSecond
|
||||
routeStartSelection = RouteSearchSelection(title: "\(sourceName) Start", coordinate: firstCoordinate)
|
||||
routeEndSelection = RouteSearchSelection(title: "\(sourceName) End", coordinate: lastCoordinate)
|
||||
routePlan = RouteSimulationPlan(
|
||||
setRoutePlan(RouteSimulationPlan(
|
||||
displayCoordinates: displayCoordinates,
|
||||
distance: distance,
|
||||
expectedTravelTime: distance / fallbackSpeed
|
||||
)
|
||||
))
|
||||
|
||||
if let routePolyline {
|
||||
position = .rect(routePolyline.boundingMapRect)
|
||||
|
|
@ -1407,7 +1417,7 @@ struct LocationSimulationView: View {
|
|||
routeSpeedPrefetchTask?.cancel()
|
||||
routeSpeedPrefetchTask = nil
|
||||
routeRequestID = UUID()
|
||||
routePlan = nil
|
||||
setRoutePlan(nil)
|
||||
routeStartSelection = nil
|
||||
routeEndSelection = nil
|
||||
routePlaybackSamples = []
|
||||
|
|
@ -1419,7 +1429,7 @@ struct LocationSimulationView: View {
|
|||
private func refreshRoute() {
|
||||
routeLoadTask?.cancel()
|
||||
routeSpeedPrefetchTask?.cancel()
|
||||
routePlan = nil
|
||||
setRoutePlan(nil)
|
||||
routePlaybackSamples = []
|
||||
|
||||
guard let routeStart = routeStartSelection?.coordinate,
|
||||
|
|
@ -1464,7 +1474,7 @@ struct LocationSimulationView: View {
|
|||
|
||||
await MainActor.run {
|
||||
guard routeRequestID == requestID else { return }
|
||||
self.routePlan = routePlan
|
||||
self.setRoutePlan(routePlan)
|
||||
isLoadingRoute = false
|
||||
isPrefetchingRouteSpeeds = true
|
||||
if let routePolyline {
|
||||
|
|
|
|||
379
StikJIT/Views/NewsView.swift
Normal file
379
StikJIT/Views/NewsView.swift
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
//
|
||||
// NewsView.swift
|
||||
// StikJIT
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct NewsView: View {
|
||||
@State private var items: [NewsItem] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private let feedURL = NewsFeedEndpoint.url
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if items.isEmpty {
|
||||
statusContent
|
||||
} else {
|
||||
if let errorMessage {
|
||||
NewsBanner(message: errorMessage)
|
||||
}
|
||||
|
||||
ForEach(items) { item in
|
||||
NewsCard(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.navigationTitle("News")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
Task { await loadNews() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadNews()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
guard items.isEmpty, errorMessage == nil else { return }
|
||||
await loadNews()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusContent: some View {
|
||||
if isLoading {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Loading News")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 220)
|
||||
} else if let errorMessage {
|
||||
ContentUnavailableView {
|
||||
Label("Could Not Load News", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(errorMessage)
|
||||
} actions: {
|
||||
Button("Try Again") {
|
||||
Task { await loadNews() }
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 280)
|
||||
} else {
|
||||
ContentUnavailableView("No News", systemImage: "newspaper")
|
||||
.frame(minHeight: 280)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadNews() async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let feed = try await NewsFeedClient.fetch(from: feedURL)
|
||||
items = feed.items
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private enum NewsFeedEndpoint {
|
||||
static let url = URL(string: "https://raw.githubusercontent.com/StephenDev0/StikDebug/main/news.json")!
|
||||
}
|
||||
|
||||
private enum NewsFeedClient {
|
||||
static func fetch(from url: URL) async throws -> NewsFeed {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 15
|
||||
request.cachePolicy = .reloadRevalidatingCacheData
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw NewsFeedError.invalidResponse
|
||||
}
|
||||
|
||||
guard (200..<300).contains(httpResponse.statusCode) else {
|
||||
throw NewsFeedError.badStatus(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(NewsFeed.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private enum NewsFeedError: LocalizedError {
|
||||
case invalidResponse
|
||||
case badStatus(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidResponse:
|
||||
return "GitHub returned an invalid news response."
|
||||
case .badStatus(let statusCode):
|
||||
return "GitHub returned status \(statusCode)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsFeed: Decodable {
|
||||
let items: [NewsItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
if let container = try? decoder.singleValueContainer(),
|
||||
let items = try? container.decode([NewsItem].self) {
|
||||
self.items = items
|
||||
return
|
||||
}
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
items = try container.decode([NewsItem].self, forKey: .items)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsItem: Decodable, Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let body: String
|
||||
let date: String
|
||||
let category: String
|
||||
let tint: String
|
||||
let symbol: String
|
||||
let imageURL: URL?
|
||||
let url: URL?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case body
|
||||
case summary
|
||||
case description
|
||||
case date
|
||||
case publishedAt
|
||||
case category
|
||||
case tint
|
||||
case color
|
||||
case symbol
|
||||
case image
|
||||
case imageURL
|
||||
case imageUrl
|
||||
case image_url
|
||||
case link
|
||||
case url
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = try container.decode(String.self, forKey: .title)
|
||||
body = try container.decodeFirstString(for: [.body, .summary, .description])
|
||||
date = (try? container.decodeFirstString(for: [.date, .publishedAt])) ?? ""
|
||||
category = (try? container.decode(String.self, forKey: .category)) ?? "Update"
|
||||
tint = (try? container.decodeFirstString(for: [.tint, .color])) ?? "blue"
|
||||
symbol = (try? container.decode(String.self, forKey: .symbol)) ?? "sparkles"
|
||||
imageURL = container.decodeFirstURL(for: [.imageURL, .imageUrl, .image_url, .image])
|
||||
url = container.decodeFirstURL(for: [.url, .link])
|
||||
id = (try? container.decode(String.self, forKey: .id)) ?? Self.generatedID(title: title, date: date)
|
||||
}
|
||||
|
||||
private static func generatedID(title: String, date: String) -> String {
|
||||
"\(date)-\(title)"
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer where Key == NewsItem.CodingKeys {
|
||||
func decodeFirstString(for keys: [Key]) throws -> String {
|
||||
for key in keys {
|
||||
if let value = try decodeIfPresent(String.self, forKey: key), !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
throw DecodingError.keyNotFound(
|
||||
keys.first ?? NewsItem.CodingKeys.body,
|
||||
DecodingError.Context(codingPath: codingPath, debugDescription: "Expected one of \(keys.map { $0.stringValue }.joined(separator: ", ")).")
|
||||
)
|
||||
}
|
||||
|
||||
func decodeFirstURL(for keys: [Key]) -> URL? {
|
||||
for key in keys {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key),
|
||||
!value.isEmpty,
|
||||
let url = URL(string: value) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsCard: View {
|
||||
let item: NewsItem
|
||||
|
||||
private var accentColor: Color {
|
||||
NewsTint.color(for: item.tint)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
NewsMedia(item: item, accentColor: accentColor)
|
||||
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(item.category.uppercased())
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(accentColor)
|
||||
Spacer(minLength: 12)
|
||||
if !item.date.isEmpty {
|
||||
Text(item.date)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text(item.title)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(item.body)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if let url = item.url {
|
||||
Link(destination: url) {
|
||||
Label("Read More", systemImage: "arrow.up.right")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(Color(uiColor: .secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.stroke(Color(uiColor: .separator).opacity(0.18), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsMedia: View {
|
||||
let item: NewsItem
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncImage(url: imageURL, transaction: Transaction(animation: .easeInOut)) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
ZStack {
|
||||
mediaBackground
|
||||
ProgressView()
|
||||
}
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
case .failure:
|
||||
symbolHeader
|
||||
@unknown default:
|
||||
symbolHeader
|
||||
}
|
||||
}
|
||||
} else {
|
||||
symbolHeader
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.aspectRatio(16 / 9, contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
|
||||
private var symbolHeader: some View {
|
||||
ZStack {
|
||||
mediaBackground
|
||||
Image(systemName: item.symbol)
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaBackground: some View {
|
||||
Rectangle()
|
||||
.fill(accentColor.opacity(0.14))
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(accentColor)
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewsBanner: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private enum NewsTint {
|
||||
static func color(for value: String) -> Color {
|
||||
switch value.lowercased() {
|
||||
case "green":
|
||||
return .green
|
||||
case "mint":
|
||||
return .mint
|
||||
case "orange":
|
||||
return .orange
|
||||
case "pink":
|
||||
return .pink
|
||||
case "purple":
|
||||
return .purple
|
||||
case "red":
|
||||
return .red
|
||||
case "teal":
|
||||
return .teal
|
||||
case "yellow":
|
||||
return .yellow
|
||||
default:
|
||||
return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -323,6 +323,13 @@ final class ProcessInspectorViewModel: ObservableObject {
|
|||
private var refreshTask: Task<Void, Never>?
|
||||
private var controlTimeoutTask: Task<Void, Never>?
|
||||
@Published private(set) var lastUpdated: Date?
|
||||
|
||||
private static let relativeDateFormatter: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var filteredProcesses: [ProcessInfoEntry] {
|
||||
guard !searchText.isEmpty else { return processes }
|
||||
return processes.filter {
|
||||
|
|
@ -335,9 +342,7 @@ final class ProcessInspectorViewModel: ObservableObject {
|
|||
|
||||
var lastUpdatedText: String {
|
||||
guard let date = lastUpdated else { return "—" }
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
return Self.relativeDateFormatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
|
||||
func startAutoRefresh() async {
|
||||
|
|
|
|||
|
|
@ -6,19 +6,19 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SettingsView: View {
|
||||
private enum SettingsLinks {
|
||||
static let githubStars = URL(string: "https://github.com/StephenDev0/StikDebug/stargazers")!
|
||||
static let pairingFileGuide = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")!
|
||||
static let localDevVPN = URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!
|
||||
static let discord = URL(string: "https://discord.gg/qahjXNTDwS")!
|
||||
}
|
||||
|
||||
@AppStorage("selectedAppIcon") private var selectedAppIcon: String = "AppIcon"
|
||||
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
|
||||
@AppStorage("enableAdvancedBetaOptions") private var enableAdvancedBetaOptions = false
|
||||
@AppStorage("enableTesting") private var enableTesting = false
|
||||
struct SettingsView: View {
|
||||
@AppStorage(UserDefaults.Keys.txmOverride) private var overrideTXMDetection = false
|
||||
@AppStorage("keepAliveAudio") private var keepAliveAudio = true
|
||||
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
|
||||
@AppStorage("customTargetIP") private var customTargetIP = ""
|
||||
@AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers = TabConfiguration.defaultRawValue
|
||||
@AppStorage("primaryTabSelection") private var tabSelection = TabConfiguration.defaultIDs.first ?? "home"
|
||||
|
||||
|
||||
@State private var isShowingPairingFilePicker = false
|
||||
@State private var isImportingFile = false
|
||||
@State private var pairingImportMessage: (text: String, isError: Bool)?
|
||||
|
|
@ -28,37 +28,14 @@ struct SettingsView: View {
|
|||
@State private var ddiStatusMessage: String = ""
|
||||
@State private var ddiResultMessage: (text: String, isError: Bool)?
|
||||
|
||||
|
||||
private var appVersion: String {
|
||||
let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
||||
return marketingVersion
|
||||
}
|
||||
|
||||
struct TabOption: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let icon: String
|
||||
let isBeta: Bool
|
||||
}
|
||||
|
||||
private var tabOptions: [TabOption] {
|
||||
var options: [TabOption] = [
|
||||
TabOption(id: "home", title: "Home", detail: "Dashboard overview", icon: "house", isBeta: false),
|
||||
TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false),
|
||||
TabOption(id: "tools", title: "Tools", detail: "Access additional tools", icon: "wrench.and.screwdriver", isBeta: false)
|
||||
]
|
||||
options.append(TabOption(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", icon: "iphone.and.arrow.forward", isBeta: false))
|
||||
options.append(TabOption(id: "profiles", title: "App Expiry", detail: "Check app expiration date, install/remove profiles", icon: "calendar.badge.clock", isBeta: false))
|
||||
options.append(TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: false))
|
||||
options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: false))
|
||||
return options
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// 1) App Header
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
|
@ -75,14 +52,12 @@ struct SettingsView: View {
|
|||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// 2) GitHub
|
||||
Section {
|
||||
Link(destination: URL(string: "https://github.com/StephenDev0/StikDebug/stargazers")!) {
|
||||
Link(destination: SettingsLinks.githubStars) {
|
||||
Label("Star on GitHub", systemImage: "star")
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Pairing File
|
||||
Section("Pairing File") {
|
||||
Button {
|
||||
isShowingPairingFilePicker = true
|
||||
|
|
@ -109,7 +84,6 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// 5) Background Keep-Alive
|
||||
Section {
|
||||
Toggle(isOn: $keepAliveAudio) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
|
|
@ -138,7 +112,6 @@ struct SettingsView: View {
|
|||
Text("Background Keep-Alive")
|
||||
}
|
||||
|
||||
// 6) Behavior
|
||||
Section("Behavior") {
|
||||
Toggle(isOn: $overrideTXMDetection) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
|
|
@ -149,15 +122,14 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// 7) Advanced
|
||||
Section("Advanced") {
|
||||
HStack {
|
||||
Text("Target Device IP")
|
||||
Spacer()
|
||||
TextField("10.7.0.1", text: $customTargetIP)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
.submitLabel(.done)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.keyboardType(.numbersAndPunctuation)
|
||||
.submitLabel(.done)
|
||||
}
|
||||
Button { openAppFolder() } label: {
|
||||
Label("App Folder", systemImage: "folder")
|
||||
|
|
@ -175,20 +147,18 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// 7) Help
|
||||
Section("Help") {
|
||||
Link(destination: URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")!) {
|
||||
Link(destination: SettingsLinks.pairingFileGuide) {
|
||||
Label("Pairing File Guide", systemImage: "questionmark.circle")
|
||||
}
|
||||
Link(destination: URL(string: "https://apps.apple.com/us/app/localdevvpn/id6755608044")!) {
|
||||
Link(destination: SettingsLinks.localDevVPN) {
|
||||
Label("Download LocalDevVPN", systemImage: "arrow.down.circle")
|
||||
}
|
||||
Link(destination: URL(string: "https://discord.gg/qahjXNTDwS")!) {
|
||||
Link(destination: SettingsLinks.discord) {
|
||||
Label("Discord Support", systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
}
|
||||
|
||||
// 8) Version footer
|
||||
Section {
|
||||
Text(versionFooter)
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
|
|
@ -198,7 +168,7 @@ struct SettingsView: View {
|
|||
}
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
.fileImporter(
|
||||
.fileImporter(
|
||||
isPresented: $isShowingPairingFilePicker,
|
||||
allowedContentTypes: PairingFileStore.supportedContentTypes,
|
||||
allowsMultipleSelection: false
|
||||
|
|
@ -248,7 +218,7 @@ struct SettingsView: View {
|
|||
}
|
||||
return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)"
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Business Logic
|
||||
|
||||
private func openAppFolder() {
|
||||
|
|
@ -299,7 +269,7 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func scheduleDDIStatusDismiss() {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
|
|
@ -311,72 +281,3 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tab Customization
|
||||
|
||||
struct TabCustomizationView: View {
|
||||
let tabOptions: [SettingsView.TabOption]
|
||||
@Binding var enabledTabIdentifiers: String
|
||||
@Binding var tabSelection: String
|
||||
|
||||
private var selectedIDs: [String] {
|
||||
TabConfiguration.sanitize(raw: enabledTabIdentifiers)
|
||||
}
|
||||
|
||||
private var pinnedOptions: [SettingsView.TabOption] {
|
||||
selectedIDs.compactMap { id in tabOptions.first(where: { $0.id == id }) }
|
||||
}
|
||||
|
||||
private var availableOptions: [SettingsView.TabOption] {
|
||||
tabOptions.filter { !selectedIDs.contains($0.id) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(pinnedOptions) { option in
|
||||
HStack {
|
||||
Label(option.title, systemImage: option.icon)
|
||||
}
|
||||
}
|
||||
.onMove { indices, newOffset in
|
||||
var ids = selectedIDs
|
||||
ids.move(fromOffsets: indices, toOffset: newOffset)
|
||||
enabledTabIdentifiers = TabConfiguration.serialize(ids)
|
||||
}
|
||||
} header: {
|
||||
Text("Pinned")
|
||||
} footer: {
|
||||
Text("Settings is fixed as the 4th tab.")
|
||||
}
|
||||
|
||||
if !availableOptions.isEmpty {
|
||||
Section("Available") {
|
||||
ForEach(availableOptions) { option in
|
||||
Button {
|
||||
var ids = selectedIDs
|
||||
guard ids.count < TabConfiguration.maxSelectableTabs else { return }
|
||||
ids.append(option.id)
|
||||
enabledTabIdentifiers = TabConfiguration.serialize(ids)
|
||||
} label: {
|
||||
HStack {
|
||||
Label(option.title, systemImage: option.icon)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Tab Bar")
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConsoleLogsView_Preview: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ConsoleLogsView()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,34 +8,15 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ToolsView: View {
|
||||
private struct ToolItem: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let destination: AnyView
|
||||
}
|
||||
|
||||
private var tools: [ToolItem] {
|
||||
[
|
||||
ToolItem(id: "scripts", title: "Scripts", detail: "Manage and run JS scripts", systemImage: "scroll", destination: AnyView(ScriptListView())),
|
||||
ToolItem(id: "console", title: "Console", detail: "Live device logs", systemImage: "terminal", destination: AnyView(ConsoleLogsView())),
|
||||
ToolItem(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", systemImage: "iphone.and.arrow.forward", destination: AnyView(DeviceInfoView())),
|
||||
ToolItem(id: "profiles", title: "App Expiry", detail: "Check app expiration dates", systemImage: "calendar.badge.clock", destination: AnyView(ProfileView())),
|
||||
ToolItem(id: "processes", title: "Processes", detail: "Inspect running apps", systemImage: "rectangle.stack.person.crop", destination: AnyView(ProcessInspectorView())),
|
||||
ToolItem(id: "location", title: "Location Simulation", detail: "Simulate GPS location", systemImage: "location", destination: AnyView(LocationSimulationView()))
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(tools) { tool in
|
||||
List(AppFeature.toolList) { tool in
|
||||
NavigationLink {
|
||||
tool.destination
|
||||
} label: {
|
||||
Label {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tool.title)
|
||||
Text(tool.toolTitle)
|
||||
Text(tool.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
|
|
|||
13
news.json
Normal file
13
news.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "news-tab-added",
|
||||
"title": "News tab added",
|
||||
"body": "Added a simple News tab for StikDebug updates.",
|
||||
"date": "2026-05-18",
|
||||
"category": "Update",
|
||||
"symbol": "newspaper.fill",
|
||||
"tint": "blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue