diff --git a/README.md b/README.md index 8e8be19..360119e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ Powered by [idevice](https://github.com/jkcoxson/idevice) and [Emotional Manglin All dependencies remain under their original licenses. +## Privacy Policy +No data is collected + ## TODO List - [X] Integrate `em_proxy` diff --git a/StikJIT.xcodeproj/project.pbxproj b/StikJIT.xcodeproj/project.pbxproj index 8487543..d4d4a9b 100644 --- a/StikJIT.xcodeproj/project.pbxproj +++ b/StikJIT.xcodeproj/project.pbxproj @@ -454,7 +454,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportsDocumentBrowser = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; LIBRARY_SEARCH_PATHS = ( @@ -502,7 +502,7 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportsDocumentBrowser = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; LIBRARY_SEARCH_PATHS = ( diff --git a/StikJIT/Utilities/AppStoreIconFetcher.swift b/StikJIT/Utilities/AppStoreIconFetcher.swift new file mode 100644 index 0000000..3ca012e --- /dev/null +++ b/StikJIT/Utilities/AppStoreIconFetcher.swift @@ -0,0 +1,71 @@ +// +// AppStoreIconFetcher.swift +// StikJIT +// +// Created by neoarz on 3/28/25. +// + +import UIKit + +class AppStoreIconFetcher { + static private var iconCache: [String: UIImage] = [:] + + static func getIcon(for bundleID: String, completion: @escaping (UIImage?) -> Void) { + // Check cache first + if let cachedIcon = iconCache[bundleID] { + completion(cachedIcon) + return + } + + // Hit the App Store API + let baseURLString = "https://itunes.apple.com/lookup?bundleId=" + let urlString = baseURLString + bundleID + + guard let url = URL(string: urlString) else { + DispatchQueue.main.async { completion(nil) } + return + } + + URLSession.shared.dataTask(with: url) { data, response, error in + guard let data = data, error == nil else { + DispatchQueue.main.async { completion(nil) } + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let results = json["results"] as? [[String: Any]], + !results.isEmpty, + let firstApp = results.first, + let iconURLString = firstApp["artworkUrl512"] as? String ?? firstApp["artworkUrl100"] as? String, + let iconURL = URL(string: iconURLString) { + + // Download the icon image + downloadImage(from: iconURL) { image in + if let image = image { + // Cache the icon + iconCache[bundleID] = image + } + DispatchQueue.main.async { + completion(image) + } + } + } else { + DispatchQueue.main.async { completion(nil) } + } + } catch { + DispatchQueue.main.async { completion(nil) } + } + }.resume() + } + + private static func downloadImage(from url: URL, completion: @escaping (UIImage?) -> Void) { + URLSession.shared.dataTask(with: url) { data, response, error in + if let data = data, let image = UIImage(data: data) { + completion(image) + } else { + completion(nil) + } + }.resume() + } +} \ No newline at end of file diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index 590074e..2b0d5e9 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -23,6 +23,11 @@ struct HomeView: View { @State private var isProcessing = false @State private var isShowingInstalledApps = false @State private var isShowingPairingFilePicker = false + @State private var pairingFileExists: Bool = false + @State private var showPairingFileMessage = false + @State private var pairingFileIsValid = false + @State private var isImportingFile = false + @State private var importProgress: Float = 0.0 @State private var viewDidAppeared = false @State private var pendingBundleIdToEnableJIT : String? = nil @@ -38,38 +43,98 @@ struct HomeView: View { .font(.system(.largeTitle, design: .rounded)) .fontWeight(.bold) - Text("Click enable jit to get started") + Text(pairingFileExists ? "Click enable JIT to get started" : "Pick pairing file to get started") .font(.system(.subheadline, design: .rounded)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .padding(.top, 40) + // Main action button - changes based on whether we have a pairing file Button(action: { - if !FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) { - isShowingPairingFilePicker = true - } else { + if pairingFileExists { + // Got a pairing file, show apps isShowingInstalledApps = true + } else { + // No pairing file yet, let's get one + isShowingPairingFilePicker = true } }) { - Label("Enable JIT", systemImage: "list.bullet") - .font(.system(.title3, design: .rounded)) - .fontWeight(.semibold) - .frame(maxWidth: .infinity) - .padding() - .background(Color.blue) - .foregroundColor(.white) - .cornerRadius(16) - .shadow(color: Color.blue.opacity(0.3), radius: 8, x: 0, y: 4) + HStack { + Image(systemName: pairingFileExists ? "bolt.fill" : "doc.badge.plus") + .font(.system(size: 20)) + Text(pairingFileExists ? "Enable JIT" : "Select Pairing File") + .font(.system(.title3, design: .rounded)) + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(16) + .shadow(color: Color.blue.opacity(0.3), radius: 8, x: 0, y: 4) } .padding(.horizontal, 20) + // Status message area - keeps layout consistent + ZStack { + // Progress bar for importing file + if isImportingFile { + VStack(spacing: 8) { + HStack { + Text("Processing pairing file...") + .font(.system(.caption, design: .rounded)) + .foregroundColor(.secondaryText) + Spacer() + Text("\(Int(importProgress * 100))%") + .font(.system(.caption, design: .rounded)) + .foregroundColor(.secondaryText) + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.black.opacity(0.2)) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.green) + .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) + .animation(.linear(duration: 0.3), value: importProgress) + } + } + .frame(height: 8) + } + .padding(.horizontal, 40) + } + + // Success message + if showPairingFileMessage && pairingFileIsValid { + Text("✓ Pairing file successfully imported") + .font(.system(.callout, design: .rounded)) + .foregroundColor(.green) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + .transition(.opacity) + } + + // Invisible text to reserve space - no layout jumps + Text(" ").opacity(0) + } + .frame(height: isImportingFile ? 60 : 30) // Adjust height based on what's showing + Spacer() } .padding() } + .onAppear { + checkPairingFileExists() + } .onReceive(timer) { _ in refreshBackground() + checkPairingFileExists() } .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList]) {result in switch result { @@ -86,9 +151,45 @@ struct HomeView: View { try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) print("File copied successfully!") + + // Show progress bar and initialize progress + DispatchQueue.main.async { + isImportingFile = true + importProgress = 0.0 + pairingFileExists = true + } + + // Start heartbeat in background startHeartbeatInBackground() - Thread.sleep(forTimeInterval: 5) + // Create timer to update progress instead of sleeping + let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in + DispatchQueue.main.async { + if importProgress < 1.0 { + importProgress += 0.25 + } else { + timer.invalidate() + isImportingFile = false + pairingFileIsValid = true + + // Show success message + withAnimation { + showPairingFileMessage = true + } + + // Hide message after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { + showPairingFileMessage = false + } + } + } + } + } + + // Ensure timer keeps running + RunLoop.current.add(progressTimer, forMode: .common) + } catch { print("Error copying file: \(error)") } @@ -99,8 +200,8 @@ struct HomeView: View { if accessing { url.stopAccessingSecurityScopedResource() } - case .failure(_): - print("Failed") + case .failure(let error): + print("Failed to import file: \(error)") } } .sheet(isPresented: $isShowingInstalledApps) { @@ -136,6 +237,12 @@ struct HomeView: View { } } + + + private func checkPairingFileExists() { + pairingFileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) + } + private func refreshBackground() { selectedBackgroundColor = Color(hex: customBackgroundColorHex) ?? Color.primaryBackground } diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index b80b4d7..b4b399b 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -8,63 +8,101 @@ import SwiftUI struct InstalledAppsListView: View { - @AppStorage("username") private var username = "User" - @AppStorage("customBackgroundColor") private var customBackgroundColorHex: String = Color.primaryBackground.toHex() ?? "#000000" - @State private var selectedBackgroundColor: Color = Color(hex: UserDefaults.standard.string(forKey: "customBackgroundColor") ?? "#000000") ?? Color.primaryBackground - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - @StateObject var viewModel = InstalledAppsViewModel() - @Environment(\.dismiss) var dismiss - @State private var searchText: String = "" - - var onSelect: (String) -> Void - - var filteredApps: [String] { - if searchText.isEmpty { - return Array(viewModel.apps.keys) // Use the keys (app names) - } else { - return viewModel.apps.filter { $0.value.localizedCaseInsensitiveContains(searchText) }.map { $0.key } - } - } + @StateObject private var viewModel = InstalledAppsViewModel() + @State private var appIcons: [String: UIImage] = [:] + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + var onSelectApp: (String) -> Void var body: some View { NavigationView { - ZStack { - selectedBackgroundColor.edgesIgnoringSafeArea(.all) - - List { - ForEach(filteredApps, id: \.self) { bundleId in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in Button(action: { - onSelect(bundleId) // Select using the app's bundle ID + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + onSelectApp(bundleID) + } }) { - Text(viewModel.apps[bundleId] ?? "Unknown App") - .font(.system(.body, design: .rounded)) - .padding(.vertical, 8) + HStack(spacing: 16) { + // App Icon + if let image = appIcons[bundleID] { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + .cornerRadius(12) + .shadow(color: colorScheme == .dark ? Color.black.opacity(0.2) : Color.gray.opacity(0.2), + radius: 3, x: 0, y: 1) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color(UIColor.systemGray5)) + .frame(width: 60, height: 60) + .overlay( + Image(systemName: "app") + .font(.system(size: 26)) + .foregroundColor(.gray) + ) + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) + .onAppear { + loadAppIcon(for: bundleID) + } + } + + // App Name and Bundle ID + VStack(alignment: .leading, spacing: 4) { + Text(appName) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Color.blue) + + Text(bundleID) + .font(.system(size: 15)) + .foregroundColor(Color.gray) + .lineLimit(1) + } + + Spacer() + } + .padding(.vertical, 12) + .padding(.horizontal, 20) + .contentShape(Rectangle()) } - .listRowBackground(Color(.secondarySystemGroupedBackground)) - .cornerRadius(8) - } - } - .listStyle(InsetGroupedListStyle()) - .navigationTitle("Installed Apps") - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done") { - dismiss() + .buttonStyle(PlainButtonStyle()) + + if appName != viewModel.apps.sorted(by: { $0.key < $1.key }).last?.key { + Divider() + .padding(.leading, 96) + .padding(.trailing, 20) + .opacity(0.4) } } } - .searchable(text: $searchText, prompt: "Search Apps") + .background(Color(UIColor.systemBackground)) } - .onReceive(timer) { _ in - refreshBackground() + .background(Color(UIColor.systemGroupedBackground).edgesIgnoringSafeArea(.all)) + .navigationTitle("Installed Apps") + .navigationBarItems(leading: Button("Done") { + dismiss() } - .onAppear { - print(filteredApps) + .font(.system(size: 17, weight: .regular)) + .foregroundColor(.blue)) + } + } + + // Helper method to load app icon + private func loadAppIcon(for bundleID: String) { + AppStoreIconFetcher.getIcon(for: bundleID) { image in + if let image = image { + DispatchQueue.main.async { + withAnimation(.easeIn(duration: 0.2)) { + self.appIcons[bundleID] = image + } + } } } } - - private func refreshBackground() { - selectedBackgroundColor = Color(hex: customBackgroundColorHex) ?? Color.primaryBackground - } +} + +#Preview { + InstalledAppsListView { _ in } } diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index ab1e90e..f761378 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -16,6 +16,10 @@ struct SettingsView: View { @State private var selectedBackgroundColor: Color = Color.primaryBackground @State private var showIconPopover = false + @State private var showPairingFileMessage = false + @State private var pairingFileIsValid = false + @State private var isImportingFile = false + @State private var importProgress: Float = 0.0 var body: some View { ZStack { @@ -60,6 +64,58 @@ struct SettingsView: View { Spacer() } .listRowBackground(Color.cardBackground) + + if isImportingFile { + VStack(spacing: 8) { + HStack { + Text("Processing pairing file...") + .font(.system(.caption, design: .rounded)) + .foregroundColor(.secondaryText) + Spacer() + Text("\(Int(importProgress * 100))%") + .font(.system(.caption, design: .rounded)) + .foregroundColor(.secondaryText) + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.cardBackground) + .frame(height: 8) + + RoundedRectangle(cornerRadius: 4) + .fill(Color.green) + .frame(width: geometry.size.width * CGFloat(importProgress), height: 8) + .animation(.linear(duration: 0.3), value: importProgress) + } + } + .frame(height: 8) + } + .padding(.vertical, 8) + .listRowBackground(Color.cardBackground) + } + + if showPairingFileMessage && pairingFileIsValid { + HStack { + Spacer() + Text("✓ Pairing file successfully imported") + .font(.system(.callout, design: .rounded)) + .foregroundColor(.green) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background(Color.green.opacity(0.1)) + .cornerRadius(8) + Spacer() + } + .padding(.vertical, 4) + .listRowBackground(Color.cardBackground) + .transition( + .asymmetric( + insertion: .scale(scale: 0.9).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.7)), + removal: .opacity.animation(.easeOut(duration: 0.25)) + ) + ) + } } Section(header: Text("About").font(.headline).foregroundColor(.primaryText)) { @@ -116,6 +172,20 @@ struct SettingsView: View { } } .listRowBackground(Color.cardBackground) + Button(action: { + if let url = URL(string: "https://apps.apple.com/us/app/stiknes/id6737158545") { + UIApplication.shared.open(url) + } + }) { + HStack { + Text("Like this app? Check out StikNES!") + .foregroundColor(.secondaryText) + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundColor(.primaryText) + } + } + .listRowBackground(Color.cardBackground) } } .background(selectedBackgroundColor) @@ -139,9 +209,37 @@ struct SettingsView: View { try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist")) print("File copied successfully!") + + DispatchQueue.main.async { + isImportingFile = true + importProgress = 0.0 + } + + let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in + DispatchQueue.main.async { + if importProgress < 1.0 { + importProgress += 0.25 + } else { + timer.invalidate() + isImportingFile = false + pairingFileIsValid = true + + withAnimation { + showPairingFileMessage = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { + showPairingFileMessage = false + } + } + } + } + } + startHeartbeatInBackground() - Thread.sleep(forTimeInterval: 5) + RunLoop.current.add(progressTimer, forMode: .common) } catch { print("Error copying file: \(error)") }