This commit is contained in:
Stephen 2026-05-18 10:19:33 -04:00
parent c84b6b4219
commit fdbcec2908
13 changed files with 919 additions and 497 deletions

View 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]
}

View 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
}
}

View file

@ -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 {

View file

@ -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: ",")
}
}

View file

@ -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
}
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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 {

View 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
}
}
}

View file

@ -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 {

View file

@ -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()
}
}

View file

@ -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
View 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"
}
]
}