diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 8d70fdfc..3bc9663d 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -63,6 +63,10 @@ 1F943C6F2927F90400ABE095 /* NewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5B82927E0EE00B8D837 /* NewsView.swift */; }; 1F943C702927F90400ABE095 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5BA2927E0F800B8D837 /* BrowseView.swift */; }; 1F943C712927F90400ABE095 /* MyAppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FAFC5BD2927E10D00B8D837 /* MyAppsView.swift */; }; + 1F981B1129AA0FAE0014950E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F981B1029AA0FAE0014950E /* OnboardingView.swift */; }; + 1F981B1329AA101F0014950E /* OnboardingStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F981B1229AA101F0014950E /* OnboardingStepView.swift */; }; + 1F981B1529AA1E070014950E /* AppIconsShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F981B1429AA1E070014950E /* AppIconsShowcase.swift */; }; + 1F981B1729AA34A70014950E /* AppStoreProductView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F981B1629AA34A70014950E /* AppStoreProductView.swift */; }; 1FA1C8CA294906890083119D /* MyAppsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA1C8C9294906890083119D /* MyAppsViewModel.swift */; }; 1FA5A6CA298E8B2F007BA946 /* RefreshAttemptsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA5A6C9298E8B2F007BA946 /* RefreshAttemptsView.swift */; }; 1FA5A6CC298E8FE4007BA946 /* MailComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA5A6CB298E8FE4007BA946 /* MailComposeView.swift */; }; @@ -76,6 +80,8 @@ 1FB96FCF292BBBCA007E68D1 /* SiriShortcutSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB96FCE292BBBC9007E68D1 /* SiriShortcutSetupView.swift */; }; 1FB96FEC292C171D007E68D1 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB96FEB292C171D007E68D1 /* NotificationManager.swift */; }; 1FB96FF3292D0539007E68D1 /* PillButtonProgressViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB96FF2292D0539007E68D1 /* PillButtonProgressViewStyle.swift */; }; + 1FF8C6182A1780C60041352C /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF8C6172A1780C60041352C /* ActivityView.swift */; }; + 1FF8C61B2A1782F10041352C /* Reachability in Frameworks */ = {isa = PBXBuildFile; productRef = 1FF8C61A2A1782F10041352C /* Reachability */; }; 1FFA56C2299994390011B6F5 /* OutputCapturer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFA56C1299994390011B6F5 /* OutputCapturer.swift */; }; 1FFA56C52999978C0011B6F5 /* LocalConsole in Frameworks */ = {isa = PBXBuildFile; productRef = 1FFA56C42999978C0011B6F5 /* LocalConsole */; }; 1FFEF104298552DB0098374C /* AppVersionHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FFEF103298552DB0098374C /* AppVersionHistoryView.swift */; }; @@ -609,6 +615,10 @@ 1F943C632927EF4200ABE095 /* NewsItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsItemView.swift; sourceTree = ""; }; 1F943C652927F36600ABE095 /* NewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewsViewModel.swift; sourceTree = ""; }; 1F943C672927F39400ABE095 /* ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewModel.swift; sourceTree = ""; }; + 1F981B1029AA0FAE0014950E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; + 1F981B1229AA101F0014950E /* OnboardingStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStepView.swift; sourceTree = ""; }; + 1F981B1429AA1E070014950E /* AppIconsShowcase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconsShowcase.swift; sourceTree = ""; }; + 1F981B1629AA34A70014950E /* AppStoreProductView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreProductView.swift; sourceTree = ""; }; 1FA1C8C9294906890083119D /* MyAppsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppsViewModel.swift; sourceTree = ""; }; 1FA5A6C9298E8B2F007BA946 /* RefreshAttemptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAttemptsView.swift; sourceTree = ""; }; 1FA5A6CB298E8FE4007BA946 /* MailComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailComposeView.swift; sourceTree = ""; }; @@ -629,6 +639,7 @@ 1FB96FCE292BBBC9007E68D1 /* SiriShortcutSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriShortcutSetupView.swift; sourceTree = ""; }; 1FB96FEB292C171D007E68D1 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 1FB96FF2292D0539007E68D1 /* PillButtonProgressViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillButtonProgressViewStyle.swift; sourceTree = ""; }; + 1FF8C6172A1780C60041352C /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; 1FFA56C1299994390011B6F5 /* OutputCapturer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputCapturer.swift; sourceTree = ""; }; 1FFEF103298552DB0098374C /* AppVersionHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionHistoryView.swift; sourceTree = ""; }; 9961EC2D29BE9F2E00AF2C6F /* minimuxer-helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "minimuxer-helpers.swift"; path = "Dependencies/minimuxer/minimuxer-helpers.swift"; sourceTree = SOURCE_ROOT; }; @@ -1046,6 +1057,7 @@ D533E8B72727841800A9B5DD /* libAppleArchive.tbd in Frameworks */, B3C395F9284F362400DA9E2F /* AppCenterCrashes in Frameworks */, 9922FFEC29B501C50020F868 /* Starscream in Frameworks */, + 1FF8C61B2A1782F10041352C /* Reachability in Frameworks */, D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */, 4879A9622861049C00FC1BBD /* OpenSSL in Frameworks */, B3C395F4284F35DD00DA9E2F /* Nuke in Frameworks */, @@ -1126,15 +1138,28 @@ 1F6284D6295218980060AAD8 /* DocumentPicker.swift */, 1F545E84298D84CF00589F68 /* FilePreviewView.swift */, 1FA5A6CB298E8FE4007BA946 /* MailComposeView.swift */, + 1F981B1629AA34A70014950E /* AppStoreProductView.swift */, + 1FF8C6172A1780C60041352C /* ActivityView.swift */, ); path = "UIView Representables"; sourceTree = ""; }; + 1F981B0F29AA0F9B0014950E /* Onboarding */ = { + isa = PBXGroup; + children = ( + 1F981B1029AA0FAE0014950E /* OnboardingView.swift */, + 1F981B1229AA101F0014950E /* OnboardingStepView.swift */, + 1F981B1429AA1E070014950E /* AppIconsShowcase.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; 1FAFC5B02927E01400B8D837 /* Views */ = { isa = PBXGroup; children = ( 1FAFC5B52927E06300B8D837 /* RootView.swift */, 1FAFC5B12927E02E00B8D837 /* Authentication */, + 1F981B0F29AA0F9B0014950E /* Onboarding */, 1FAFC5B22927E03300B8D837 /* News */, 1FAFC5B32927E03D00B8D837 /* Browse */, 1FAFC5BC2927E0FD00B8D837 /* My Apps */, @@ -2312,6 +2337,7 @@ 1F07F5662955D16A00F7BE95 /* SFSafeSymbols */, 1F1295802989B51F0048FCB9 /* ExpandableText */, 1FFA56C42999978C0011B6F5 /* LocalConsole */, + 1FF8C61A2A1782F10041352C /* Reachability */, ); productName = AltStore; productReference = BFD2476A2284B9A500981D42 /* SideStore.app */; @@ -2389,6 +2415,7 @@ 1F07F5652955D16A00F7BE95 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, 1F12957F2989B51F0048FCB9 /* XCRemoteSwiftPackageReference "ExpandableText" */, 1FFA56C32999978C0011B6F5 /* XCRemoteSwiftPackageReference "LocalConsole" */, + 1FF8C6192A1782F10041352C /* XCRemoteSwiftPackageReference "Reachability" */, ); productRefGroup = BFD2476B2284B9A500981D42 /* Products */; projectDirPath = ""; @@ -2814,6 +2841,7 @@ BFC57A652416C72400EB891E /* DeactivateAppOperation.swift in Sources */, 1F943C712927F90400ABE095 /* MyAppsView.swift in Sources */, 1FA1C8CA294906890083119D /* MyAppsViewModel.swift in Sources */, + 1F981B1529AA1E070014950E /* AppIconsShowcase.swift in Sources */, BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */, 1FFEF104298552DB0098374C /* AppVersionHistoryView.swift in Sources */, BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */, @@ -2841,10 +2869,13 @@ 1FB96FEC292C171D007E68D1 /* NotificationManager.swift in Sources */, BF770E6722BD57C4002A40FE /* BackgroundTaskManager.swift in Sources */, 1F07F56B2955F11500F7BE95 /* AppScreenshotsPreview.swift in Sources */, + 1F981B1329AA101F0014950E /* OnboardingStepView.swift in Sources */, BF44EEFC246B4550002A52F2 /* RemoveAppOperation.swift in Sources */, BF3D64B022E8D4B800E9056B /* AppContentViewControllerCells.swift in Sources */, + 1F981B1729AA34A70014950E /* AppStoreProductView.swift in Sources */, BFC57A6E2416FC5D00EB891E /* InstalledAppsCollectionHeaderView.swift in Sources */, B3EE16B62925E27D00B3B1F5 /* AnisetteManager.swift in Sources */, + 1FF8C6182A1780C60041352C /* ActivityView.swift in Sources */, 1F943C6E2927F90400ABE095 /* NewsItemView.swift in Sources */, BF88F97224F8727D00BB75DF /* AppManagerErrors.swift in Sources */, B39F16152918D7DA002E9404 /* Consts+Proxy.swift in Sources */, @@ -2917,6 +2948,7 @@ BF6C8FB02429599900125131 /* TextCollectionReusableView.swift in Sources */, BF663C4F2433ED8200DAA738 /* FileManager+DirectorySize.swift in Sources */, 1F943C702927F90400ABE095 /* BrowseView.swift in Sources */, + 1F981B1129AA0FAE0014950E /* OnboardingView.swift in Sources */, 1F943C692927F8F200ABE095 /* RootView.swift in Sources */, 1FB84BA62928DE08006A5CF4 /* AppDetailView.swift in Sources */, D57DF63F271E51E400677701 /* ALTAppPatcher.m in Sources */, @@ -3772,6 +3804,14 @@ kind = branch; }; }; + 1FF8C6192A1782F10041352C /* XCRemoteSwiftPackageReference "Reachability" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ashleymills/Reachability.swift"; + requirement = { + branch = master; + kind = branch; + }; + }; 1FFA56C32999978C0011B6F5 /* XCRemoteSwiftPackageReference "LocalConsole" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/duraidabdul/LocalConsole.git"; @@ -3893,6 +3933,11 @@ package = 1F74FF1C295263510047C051 /* XCRemoteSwiftPackageReference "AsyncImage" */; productName = AsyncImage; }; + 1FF8C61A2A1782F10041352C /* Reachability */ = { + isa = XCSwiftPackageProductDependency; + package = 1FF8C6192A1782F10041352C /* XCRemoteSwiftPackageReference "Reachability" */; + productName = Reachability; + }; 1FFA56C42999978C0011B6F5 /* LocalConsole */ = { isa = XCSwiftPackageProductDependency; package = 1FFA56C32999978C0011B6F5 /* XCRemoteSwiftPackageReference "LocalConsole" */; diff --git a/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 58e6ea8b..e8ede26b 100644 --- a/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AltStore.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -90,6 +90,15 @@ "version" : "1.10.1" } }, + { + "identity" : "reachability.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ashleymills/Reachability.swift", + "state" : { + "branch" : "master", + "revision" : "a81b7367f2c46875f29577e03a60c39cdfad0c8d" + } + }, { "identity" : "semanticversion", "kind" : "remoteSourceControl", diff --git a/AltStore/LaunchViewController.swift b/AltStore/LaunchViewController.swift index fd9b22ea..692c8bbb 100644 --- a/AltStore/LaunchViewController.swift +++ b/AltStore/LaunchViewController.swift @@ -54,15 +54,29 @@ final class LaunchViewController: RSTLaunchViewController, UIDocumentPickerDeleg override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(true) #if !targetEnvironment(simulator) + if !UserDefaults.standard.onboardingComplete { + self.showOnboarding() + return + } + start_em_proxy(bind_addr: Consts.Proxy.serverURL) guard let pf = fetchPairingFile() else { - displayError("Device pairing file not found.") + self.showOnboarding(step: .pairing) return } start_minimuxer_threads(pf) #endif } + + func showOnboarding(step: OnboardingView.OnboardingStep = .welcome) { + let onboardingView = OnboardingView(onDismiss: { self.dismiss(animated: true) }, currentStep: step) + .environment(\.managedObjectContext, DatabaseManager.shared.viewContext) + let navigationController = UINavigationController(rootViewController: UIHostingController(rootView: onboardingView)) + navigationController.isNavigationBarHidden = true + navigationController.isModalInPresentation = true + self.present(navigationController, animated: true) + } func fetchPairingFile() -> String? { let filename = "ALTPairingFile.mobiledevicepairing" diff --git a/AltStore/View Extensions/UIView Representables/ActivityView.swift b/AltStore/View Extensions/UIView Representables/ActivityView.swift new file mode 100644 index 00000000..b3bf4d39 --- /dev/null +++ b/AltStore/View Extensions/UIView Representables/ActivityView.swift @@ -0,0 +1,21 @@ +// +// ActivityView.swift +// SideStore +// +// Created by Fabian Thies on 19.05.23. +// Copyright © 2023 SideStore. All rights reserved. +// + +import SwiftUI +import UIKit + + +struct ActivityView: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { + return UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext) {} +} diff --git a/AltStore/View Extensions/UIView Representables/AppStoreProductView.swift b/AltStore/View Extensions/UIView Representables/AppStoreProductView.swift new file mode 100644 index 00000000..57b67359 --- /dev/null +++ b/AltStore/View Extensions/UIView Representables/AppStoreProductView.swift @@ -0,0 +1,87 @@ +// +// AppStoreProductView.swift +// SideStore +// +// Created by Fabian Thies on 25.02.23. +// Copyright © 2023 SideStore. All rights reserved. +// + +import SwiftUI +import StoreKit + + +struct AppStoreView: UIViewControllerRepresentable { + typealias UIViewControllerType = AppStoreProductViewController + + var isVisible: Binding + let itunesItemId: Int + + func makeUIViewController(context: Context) -> AppStoreProductViewController { + AppStoreProductViewController(isVisible: self.isVisible, itunesId: self.itunesItemId) + } + + func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { + if self.isVisible.wrappedValue { + uiViewController.presentStoreProduct() + } + } +} + + +class AppStoreProductViewController: UIViewController { + + private var isVisible: Binding + private let itunesId: Int + + init(isVisible: Binding, itunesId: Int) { + self.isVisible = isVisible + self.itunesId = itunesId + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + } + + func presentStoreProduct() { + let storeProductViewController = SKStoreProductViewController() + storeProductViewController.delegate = self + + let parameters = [SKStoreProductParameterITunesItemIdentifier: self.itunesId] + storeProductViewController.loadProduct(withParameters: parameters) { (success, error) -> Void in + if let error = error { + print("Failed to load App Store product: \(error.localizedDescription)") + } + guard success else { + return + } + + self.present(storeProductViewController, animated: true, completion: nil) + } + } +} + +// MARK: - SKStoreProductViewControllerDelegate + +extension AppStoreProductViewController: SKStoreProductViewControllerDelegate { + + func productViewControllerDidFinish(_ viewController: SKStoreProductViewController) { + DispatchQueue.main.async { + self.isVisible.wrappedValue = false + } +// viewController.presentingViewController?.dismiss(animated: true, completion: nil) + } +} diff --git a/AltStore/Views/Onboarding/AppIconsShowcase.swift b/AltStore/Views/Onboarding/AppIconsShowcase.swift new file mode 100644 index 00000000..7b78a4fc --- /dev/null +++ b/AltStore/Views/Onboarding/AppIconsShowcase.swift @@ -0,0 +1,89 @@ +// +// AppIconsShowcase.swift +// SideStore +// +// Created by Fabian Thies on 25.02.23. +// Copyright © 2023 SideStore. All rights reserved. +// + +import SwiftUI + +struct AppIconsShowcase: View { + + @State var animationProgress = 0.0 + @State var animation2Progress = 0.0 + + var body: some View { + VStack { + GeometryReader { proxy in + ZStack(alignment: .bottom) { + Image(uiImage: UIImage(named: "AppIcon")!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 0.2 * proxy.size.width) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .circular)) + .offset(x: -0.3*proxy.size.width * self.animationProgress, y: -30) + .rotationEffect(.degrees(-20 * self.animationProgress)) + .shadow(radius: 8 * self.animationProgress) + + Image(uiImage: UIImage(named: "AppIcon")!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 0.25 * proxy.size.width) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .circular)) + .offset(x: -0.15*proxy.size.width * self.animationProgress, y: -10) + .rotationEffect(.degrees(-10 * self.animationProgress)) + .shadow(radius: 12 * self.animationProgress) + + Image(uiImage: UIImage(named: "AppIcon")!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 0.2 * proxy.size.width) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .circular)) + .offset(x: self.animationProgress*0.3*proxy.size.width, y: -30) + .rotationEffect(.degrees(self.animationProgress*20)) + .shadow(radius: 8 * self.animationProgress) + + Image(uiImage: UIImage(named: "AppIcon")!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 0.25 * proxy.size.width) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .circular)) + .offset(x: self.animationProgress * 0.15*proxy.size.width, y: -10) + .rotationEffect(.degrees(self.animationProgress * 10)) + .shadow(radius: 12 * self.animationProgress) + + Image(uiImage: UIImage(named: "AppIcon")!) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 0.3 * proxy.size.width) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .circular)) + .shadow(radius: 16 * self.animationProgress + 8 * self.animation2Progress) + .scaleEffect(1.0 + 0.05 * self.animation2Progress) + } + .frame(maxWidth: proxy.size.width) + } + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.spring()) { + self.animationProgress = 1.0 + self.animation2Progress = 1.0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withAnimation(.spring()) { + self.animation2Progress = 0.0 + } + } + } + } + } +} + +struct AppIconsShowcase_Previews: PreviewProvider { + static var previews: some View { + AppIconsShowcase() + .frame(height: 150) + } +} diff --git a/AltStore/Views/Onboarding/OnboardingStepView.swift b/AltStore/Views/Onboarding/OnboardingStepView.swift new file mode 100644 index 00000000..06b94018 --- /dev/null +++ b/AltStore/Views/Onboarding/OnboardingStepView.swift @@ -0,0 +1,137 @@ +// +// OnboardingStepView.swift +// SideStore +// +// Created by Fabian Thies on 25.02.23. +// Copyright © 2023 SideStore. All rights reserved. +// + +import SwiftUI + + +struct OnboardingStep { + + @ViewBuilder + var title: Title + + @ViewBuilder + var hero: Hero + + @ViewBuilder + var content: Content + + @ViewBuilder + var action: Action +} + + +struct OnboardingStepView: View { + + @ViewBuilder + var title: Title + + @ViewBuilder + var hero: Hero + + @ViewBuilder + var content: Content + + @ViewBuilder + var action: Action + + var body: some View { + VStack(spacing: 64) { + self.title + .font(.largeTitle.weight(.bold)) + .frame(maxWidth: .infinity, alignment: .leading) + + self.hero + .frame(height: 150) + + self.content + + Spacer() + + self.action + } + .frame(maxWidth: .infinity) + .padding() + } +} + +struct OnboardingStepView_Previews: PreviewProvider { + @State + static var isWireGuardAppStorePageVisible = false + + static var previews: some View { + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Welcome to") + Text("SideStore") + .foregroundColor(.accentColor) + } + }, hero: { + AppIconsShowcase() + }, content: { + VStack(spacing: 16) { + Text("Before you can start sideloading apps, there is some setup to do.") + Text("The following setup will guide you through the steps one by one.") + Text("You will need a computer (Windows, macOS, Linux) and your Apple ID.") + } + }, action: { + SwiftUI.Button("Continue") { + + } + .buttonStyle(FilledButtonStyle()) + }) + + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Pair your Device") + } + }, hero: { + Image(systemSymbol: .link) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .shadow(color: .accentColor.opacity(0.8), radius: 12) + }, content: { + VStack { + Text("Before you can start sideloading apps, there is some setup to do.") + Text("The following setup will guide you through the steps one by one.") + } + }, action: { + SwiftUI.Button("Continue") { + + } + .buttonStyle(FilledButtonStyle()) + }) + + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Download WireGuard") + } + }, hero: { + Image(systemSymbol: .icloudAndArrowDown) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .shadow(color: .accentColor.opacity(0.8), radius: 12) + }, content: { + VStack { + Text("Before you can start sideloading apps, there is some setup to do.") + Text("The following setup will guide you through the steps one by one.") + } + }, action: { + SwiftUI.Button("Show in App Store") { + + } + .buttonStyle(FilledButtonStyle()) + + AppStoreView(isVisible: self.$isWireGuardAppStorePageVisible, itunesItemId: 1441195209) + .frame(width: .zero, height: .zero) + }) + + } +} + diff --git a/AltStore/Views/Onboarding/OnboardingView.swift b/AltStore/Views/Onboarding/OnboardingView.swift new file mode 100644 index 00000000..e55b7d94 --- /dev/null +++ b/AltStore/Views/Onboarding/OnboardingView.swift @@ -0,0 +1,444 @@ +// +// OnboardingView.swift +// SideStore +// +// Created by Fabian Thies on 25.02.23. +// Copyright © 2023 SideStore. All rights reserved. +// + +import SwiftUI +import CoreData +import AltStoreCore +import minimuxer +import Reachability +import UniformTypeIdentifiers + + +struct OnboardingView: View { + enum OnboardingStep: Int { + case welcome, pairing, wireguard, wireguardConfig, addSources, finish + } + + @Environment(\.dismiss) var dismiss + + // Temporary workaround for UIKit compatibility + var onDismiss: (() -> Void)? = nil + + @State var currentStep: OnboardingStep = .wireguard //.welcome + @State private var pairingFileURL: URL? = nil + @State private var isWireGuardAppStorePageVisible: Bool = false + @State private var isDownloadingWireGuardProfile: Bool = false + @State private var wireGuardProfileFileURL: URL? = nil + @State private var reachabilityNotifier: Reachability? = nil + @State private var isWireGuardTunnelReachable: Bool = false + @State private var areTrustedSourcesEnabled: Bool = false + @State private var isLoadingTrustedSources: Bool = false + + let pairingFileTypes = UTType.types(tag: "plist", tagClass: UTTagClass.filenameExtension, conformingTo: nil) + UTType.types(tag: "mobiledevicepairing", tagClass: UTTagClass.filenameExtension, conformingTo: UTType.data) + [.xml] + + var body: some View { + TabView(selection: self.$currentStep) { + welcomeStep + .tag(OnboardingStep.welcome) + .highPriorityGesture(DragGesture()) + + pairingView + .tag(OnboardingStep.pairing) + .highPriorityGesture(DragGesture()) + + wireguardView + .tag(OnboardingStep.wireguard) + .highPriorityGesture(DragGesture()) + + wireguardConfigView + .tag(OnboardingStep.wireguardConfig) + .highPriorityGesture(DragGesture()) + + addSourcesView + .tag(OnboardingStep.addSources) + .highPriorityGesture(DragGesture()) + + finishView + .tag(OnboardingStep.finish) + .highPriorityGesture(DragGesture()) + + } + .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .edgesIgnoringSafeArea(.bottom) + .background(Color.accentColor.opacity(0.1).edgesIgnoringSafeArea(.all)) + .onChange(of: self.currentStep) { step in + switch step { + case .wireguardConfig: + self.startPingingWireGuardTunnel() + default: + self.stopPingingWireGuardTunnel() + } + } + } + + func showNextStep() { + withAnimation { + self.currentStep = OnboardingStep(rawValue: self.currentStep.rawValue + 1) ?? self.currentStep + } + } + + var welcomeStep: some View { + OnboardingStepView { + VStack(alignment: .leading) { + Text("Welcome to") + Text("SideStore") + .foregroundColor(.accentColor) + } + } hero: { + AppIconsShowcase() + } content: { + VStack(alignment: .leading, spacing: 16) { + Text("Before you can start sideloading apps, there is some setup to do.") + Text("The following setup will guide you through the steps one by one.") + Text("You will need a computer (Windows, macOS, Linux) and your Apple ID.") + } + } action: { + SwiftUI.Button("Continue") { + self.showNextStep() + } + .buttonStyle(FilledButtonStyle()) + } + } + + var pairingView: some View { + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Pair your Device") + } + }, hero: { + Image(systemSymbol: .link) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .shadow(color: .accentColor.opacity(0.8), radius: 12) + }, content: { + VStack(alignment: .leading, spacing: 16) { + Text("SideStore supports sideloading even on non-jailbroken devices.") + Text("For it to work, you have to generate a pairing file as described [here in our documentation](https://wiki.sidestore.io/guides/install#pairing-process).") + Text("Once you have the `.mobiledevicepairing`, import it using the button below.") + } + }, action: { + ModalNavigationLink("Select Pairing File") { + DocumentPicker(selectedUrl: self.$pairingFileURL, + supportedTypes: self.pairingFileTypes.map { $0.identifier }) + } + .buttonStyle(FilledButtonStyle()) + .onChange(of: self.pairingFileURL) { newValue in + guard let url = newValue else { + return + } + + self.importPairingFile(url: url) + } + }) + } + + var wireguardView: some View { + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Download WireGuard") + } + }, hero: { + Image(systemSymbol: .icloudAndArrowDown) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .shadow(color: .accentColor.opacity(0.8), radius: 12) + }, content: { + VStack(alignment: .leading, spacing: 16) { + Text("To sideload and sign app on-device without the need of a computer program like SideServer, a local WireGuard connection is required.") + Text("This connection is strictly local-only and does not connect to a server on the internet.") + Text("First, download WireGuard from the App Store (free).") + } + }, action: { + AppStoreView(isVisible: self.$isWireGuardAppStorePageVisible, itunesItemId: 1441195209) + .frame(width: .zero, height: .zero) + + VStack { + SwiftUI.Button("Show in App Store") { + self.isWireGuardAppStorePageVisible = true + } + .buttonStyle(FilledButtonStyle()) + + SwiftUI.Button("Continue") { + self.showNextStep() + } + .buttonStyle(FilledButtonStyle()) + } + }) + + } + + var wireguardConfigView: some View { + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Enable the WireGuard Tunnel") + } + }, hero: { + Image(systemSymbol: .network) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .shadow(color: .accentColor.opacity(0.8), radius: 12) + }, content: { + VStack(alignment: .leading, spacing: 16) { + Text("Once WireGuard is installed, a configuration file has to be installed in the WireGuard app.") + Text("Tap the button below and open the downloaded file in the WireGuard app.") + Text("Then, activate the VPN tunnel to continue.") + } + }, action: { + VStack { + SwiftUI.Button("Download and Install Configuration File") { + self.downloadWireGuardProfile() + } + .buttonStyle(FilledButtonStyle(isLoading: self.isDownloadingWireGuardProfile)) + .sheet(item: self.$wireGuardProfileFileURL) { fileURL in + ActivityView(items: [fileURL]) + } + + SwiftUI.Button(self.isWireGuardTunnelReachable ? "Continue" : "Waiting for connection...", + action: self.showNextStep) + .buttonStyle(FilledButtonStyle()) + .disabled(!self.isWireGuardTunnelReachable) + } + }) + } + + var addSourcesView: some View { + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Add Sources") + } + }, hero: { + Image(systemSymbol: .booksVertical) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .shadow(color: .accentColor.opacity(0.8), radius: 12) + }, content: { + VStack(alignment: .leading, spacing: 16) { + Text("All apps are provided through sources, which anyone can create and share with the world.") + Text("We have compiled a list of trusted sources for SideStore which you can enable to start sideloading your favorite apps.") + Text("By default, only the source containing SideStore itself is enabled.") + + Toggle("Enable Trusted Sources", isOn: $areTrustedSourcesEnabled) + } + }, action: { + SwiftUI.Button("Continue") { + self.setupTrustedSources() + } + .buttonStyle(FilledButtonStyle(isLoading: self.isLoadingTrustedSources)) + .disabled(self.isLoadingTrustedSources) + }) + } + + var finishView: some View { + OnboardingStepView(title: { + VStack(alignment: .leading) { + Text("Setup Completed") + } + }, hero: { + Image(systemSymbol: .checkmark) + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.accentColor) + .shadow(color: .accentColor.opacity(0.8), radius: 12) + }, content: { + VStack(alignment: .leading, spacing: 16) { + Text("Congratulations, you did it! 🎉") + Text("You can start your sideloading journey.") + } + }, action: { + SwiftUI.Button("Let's Go") { + self.finishOnboarding() + } + .buttonStyle(FilledButtonStyle()) + }) + } +} + +extension OnboardingView { + func importPairingFile(url: URL) { + let isSecuredURL = url.startAccessingSecurityScopedResource() == true + + do { + // Read to a string + let data = try Data(contentsOf: url) + let pairing_string = String(bytes: data, encoding: .utf8) + if pairing_string == nil { + // TODO: Show error message + debugPrint("Unable to read pairing file") + // displayError("Unable to read pairing file") + } + + // Save to a file for next launch + let filename = "ALTPairingFile.mobiledevicepairing" + let fm = FileManager.default + let documentsPath = fm.documentsDirectory.appendingPathComponent("/\(filename)") + try pairing_string?.write(to: documentsPath, atomically: true, encoding: String.Encoding.utf8) + + // Start minimuxer now that we have a file + start_minimuxer_threads(pairing_string!) + + // Show the next onboarding step + self.showNextStep() + + } catch { + NotificationManager.shared.reportError(error: error) + } + + if (isSecuredURL) { + url.stopAccessingSecurityScopedResource() + } + } + + func start_minimuxer_threads(_ pairing_file: String) { + set_usbmuxd_socket() + #if false // Retries + var res = start_minimuxer(pairing_file: pairing_file) + var attempts = 10 + while (attempts != 0 && res != 0) { + print("start_minimuxer `res` != 0, retry #\(attempts)") + res = start_minimuxer(pairing_file: pairing_file) + attempts -= 1 + } + #else + let res = start_minimuxer(pairing_file: pairing_file) + #endif + if res != 0 { + // TODO: Show error message + debugPrint("minimuxer failed to start. Incorrect arguments were passed.") + // displayError("minimuxer failed to start. Incorrect arguments were passed.") + } + auto_mount_dev_image() + } +} + +extension OnboardingView { + func downloadWireGuardProfile() { + let profileDownloadUrl = "https://github.com/SideStore/SideStore/releases/download/0.3.1/SideStore.conf" + let destinationUrl = FileManager.default.temporaryDirectory.appendingPathComponent("SideStore.conf") + + self.isDownloadingWireGuardProfile = true + URLSession.shared.dataTask(with: URLRequest(url: URL(string: profileDownloadUrl)!)) { data, response, error in + + defer { self.isDownloadingWireGuardProfile = false } + + if let error { + NotificationManager.shared.reportError(error: error) + return + } + + guard let response = response as? HTTPURLResponse, 200..<300 ~= response.statusCode, let data else { + // TODO: Show error message + return + } + + do { + try data.write(to: destinationUrl) + self.wireGuardProfileFileURL = destinationUrl + } catch { + NotificationManager.shared.reportError(error: error) + return + } + }.resume() + } + + func startPingingWireGuardTunnel() { + do { + self.reachabilityNotifier = try Reachability(hostname: "10.7.0.1") + self.reachabilityNotifier?.whenReachable = { _ in + self.isWireGuardTunnelReachable = true + } + self.reachabilityNotifier?.whenUnreachable = { _ in + self.isWireGuardTunnelReachable = false + } + + try self.reachabilityNotifier?.startNotifier() + } catch { + // TODO: Show error message + debugPrint(error) + NotificationManager.shared.reportError(error: error) + } + } + + func stopPingingWireGuardTunnel() { + self.reachabilityNotifier?.stopNotifier() + } +} + +extension OnboardingView { + func setupTrustedSources() { + self.isLoadingTrustedSources = true + + AppManager.shared.fetchTrustedSources { result in + + switch result { + case .success(let trustedSources): + // Cache trusted source IDs. + UserDefaults.shared.trustedSourceIDs = trustedSources.map { $0.identifier } + + // Don't show sources without a sourceURL. + let featuredSourceURLs = trustedSources.compactMap { $0.sourceURL } + + // This context is never saved, but keeps the managed sources alive. + let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext() + + let dispatchGroup = DispatchGroup() + for sourceURL in featuredSourceURLs { + dispatchGroup.enter() + + AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in + dispatchGroup.leave() + } + } + + dispatchGroup.notify(queue: .main) { + self.isLoadingTrustedSources = false + + // Save the fetched trusted sources + do { + try context.save() + } catch { + NotificationManager.shared.reportError(error: error) + } + + self.showNextStep() + } + case .failure(let error): + NotificationManager.shared.reportError(error: error) + self.isLoadingTrustedSources = false + } + } + + } +} + +extension OnboardingView { + func finishOnboarding() { + // Set the onboarding complete flag + UserDefaults.standard.onboardingComplete = true + + if let onDismiss { + onDismiss() + } else { + self.dismiss() + } + } +} + + +struct OnboardingView_Previews: PreviewProvider { + static var previews: some View { + Color.red + .ignoresSafeArea() + .sheet(isPresented: .constant(true)) { + OnboardingView() + } + } +} diff --git a/AltStoreCore/Extensions/UserDefaults+AltStore.swift b/AltStoreCore/Extensions/UserDefaults+AltStore.swift index e72d48dc..db1dab6c 100644 --- a/AltStoreCore/Extensions/UserDefaults+AltStore.swift +++ b/AltStoreCore/Extensions/UserDefaults+AltStore.swift @@ -20,6 +20,7 @@ public extension UserDefaults }() @NSManaged var firstLaunch: Date? + @NSManaged var onboardingComplete: Bool @NSManaged var requiresAppGroupMigration: Bool @NSManaged var textServer: Bool @NSManaged var textInputAnisetteURL: String? @@ -71,6 +72,7 @@ public extension UserDefaults let localServerSupportsRefreshing = !ProcessInfo.processInfo.isOperatingSystemAtLeast(ios14) let defaults = [ + #keyPath(UserDefaults.onboardingComplete): false, #keyPath(UserDefaults.isBackgroundRefreshEnabled): true, #keyPath(UserDefaults.isLegacyDeactivationSupported): isLegacyDeactivationSupported, #keyPath(UserDefaults.activeAppLimitIncludesExtensions): activeAppLimitIncludesExtensions,