From fdbcec290823b71bf6cf74ad79a880d95e987618 Mon Sep 17 00:00:00 2001 From: Stephen <158498287+StephenDev0@users.noreply.github.com> Date: Mon, 18 May 2026 10:19:33 -0400 Subject: [PATCH] Test --- StikJIT/Utilities/AppFeature.swift | 138 +++++++ StikJIT/Utilities/AutoScriptAssignments.swift | 50 +++ StikJIT/Utilities/Extensions.swift | 13 - StikJIT/Utilities/TabConfiguration.swift | 44 -- StikJIT/Views/ConsoleLogsView.swift | 219 ++++++---- StikJIT/Views/InstalledAppsListView.swift | 175 ++++---- StikJIT/Views/MainTabView.swift | 168 +++----- StikJIT/Views/MapSelectionView.swift | 48 ++- StikJIT/Views/NewsView.swift | 379 ++++++++++++++++++ StikJIT/Views/ProcessInspectorView.swift | 11 +- StikJIT/Views/SettingsView.swift | 135 +------ StikJIT/Views/ToolsView.swift | 23 +- news.json | 13 + 13 files changed, 919 insertions(+), 497 deletions(-) create mode 100644 StikJIT/Utilities/AppFeature.swift create mode 100644 StikJIT/Utilities/AutoScriptAssignments.swift delete mode 100644 StikJIT/Utilities/TabConfiguration.swift create mode 100644 StikJIT/Views/NewsView.swift create mode 100644 news.json diff --git a/StikJIT/Utilities/AppFeature.swift b/StikJIT/Utilities/AppFeature.swift new file mode 100644 index 0000000..4395861 --- /dev/null +++ b/StikJIT/Utilities/AppFeature.swift @@ -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] +} diff --git a/StikJIT/Utilities/AutoScriptAssignments.swift b/StikJIT/Utilities/AutoScriptAssignments.swift new file mode 100644 index 0000000..10a86f0 --- /dev/null +++ b/StikJIT/Utilities/AutoScriptAssignments.swift @@ -0,0 +1,50 @@ +// +// AutoScriptAssignments.swift +// StikJIT +// + +private struct AutoScriptAssignment { + let appNames: Set + 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 + } +} diff --git a/StikJIT/Utilities/Extensions.swift b/StikJIT/Utilities/Extensions.swift index 7c978d1..fb081f6 100644 --- a/StikJIT/Utilities/Extensions.swift +++ b/StikJIT/Utilities/Extensions.swift @@ -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 { diff --git a/StikJIT/Utilities/TabConfiguration.swift b/StikJIT/Utilities/TabConfiguration.swift deleted file mode 100644 index b8b51af..0000000 --- a/StikJIT/Utilities/TabConfiguration.swift +++ /dev/null @@ -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: ",") - } -} diff --git a/StikJIT/Views/ConsoleLogsView.swift b/StikJIT/Views/ConsoleLogsView.swift index ac592e8..952b55a 100644 --- a/StikJIT/Views/ConsoleLogsView.swift +++ b/StikJIT/Views/ConsoleLogsView.swift @@ -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 + } + } +} diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 5d7edde..6065014 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -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 { - 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) 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) diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 7b1a751..81f85e9 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -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 { diff --git a/StikJIT/Views/MapSelectionView.swift b/StikJIT/Views/MapSelectionView.swift index 13dcc17..d77a3f4 100644 --- a/StikJIT/Views/MapSelectionView.swift +++ b/StikJIT/Views/MapSelectionView.swift @@ -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 { diff --git a/StikJIT/Views/NewsView.swift b/StikJIT/Views/NewsView.swift new file mode 100644 index 0000000..06330e1 --- /dev/null +++ b/StikJIT/Views/NewsView.swift @@ -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 + } + } +} diff --git a/StikJIT/Views/ProcessInspectorView.swift b/StikJIT/Views/ProcessInspectorView.swift index fded064..8b38688 100644 --- a/StikJIT/Views/ProcessInspectorView.swift +++ b/StikJIT/Views/ProcessInspectorView.swift @@ -323,6 +323,13 @@ final class ProcessInspectorViewModel: ObservableObject { private var refreshTask: Task? private var controlTimeoutTask: Task? @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 { diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index 98fed01..41f31c8 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -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() - } -} diff --git a/StikJIT/Views/ToolsView.swift b/StikJIT/Views/ToolsView.swift index bf274bb..566d3a1 100644 --- a/StikJIT/Views/ToolsView.swift +++ b/StikJIT/Views/ToolsView.swift @@ -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) diff --git a/news.json b/news.json new file mode 100644 index 0000000..c2fa062 --- /dev/null +++ b/news.json @@ -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" + } + ] +}