Reorganize AltStore project into UIKit and SwiftUI folders

This commit is contained in:
naturecodevoid
2023-05-20 12:35:53 -07:00
parent e06cca8224
commit 2db073d2c5
115 changed files with 41 additions and 25 deletions

View File

@@ -0,0 +1,454 @@
//
// AppDetailView.swift
// SideStore
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AsyncImage
import ExpandableText
import SFSafeSymbols
import AltStoreCore
struct AppDetailView: View {
let storeApp: StoreApp
let byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
return formatter
}()
@State var scrollOffset: CGFloat = .zero
let maxContentCornerRadius: CGFloat = 24
let headerViewHeight: CGFloat = 140
let permissionColumns = 4
var headerBlurRadius: CGFloat {
min(20, max(0, 20 - (scrollOffset / -150) * 20))
}
var isHeaderViewVisible: Bool {
scrollOffset < headerViewHeight + 12
}
var contentCornerRadius: CGFloat {
max(CGFloat.zero, min(maxContentCornerRadius, maxContentCornerRadius * (1 - self.scrollOffset / self.headerViewHeight)))
}
var canRateApp: Bool {
self.storeApp.installedApp != nil
}
var body: some View {
ObservableScrollView(scrollOffset: $scrollOffset) { proxy in
LazyVStack {
headerView
.frame(height: headerViewHeight)
contentView
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
AppPillButton(app: storeApp)
.disabled(isHeaderViewVisible)
.offset(y: isHeaderViewVisible ? 12 : 0)
.opacity(isHeaderViewVisible ? 0 : 1)
.animation(.easeInOut(duration: 0.2), value: isHeaderViewVisible)
}
ToolbarItemGroup(placement: .principal) {
HStack {
Spacer()
AppIconView(iconUrl: storeApp.iconURL, isSideStore: storeApp.isSideStore, size: 24)
Text(storeApp.name)
.bold()
Spacer()
}
.offset(y: isHeaderViewVisible ? 12 : 0)
.opacity(isHeaderViewVisible ? 0 : 1)
.animation(.easeInOut(duration: 0.2), value: isHeaderViewVisible)
}
}
}
var headerView: some View {
ZStack(alignment: .center) {
GeometryReader { proxy in
AppIconView(iconUrl: storeApp.iconURL, isSideStore: storeApp.isSideStore, size: proxy.frame(in: .global).width)
.blur(radius: headerBlurRadius)
.offset(y: min(0, scrollOffset))
}
.padding()
AppRowView(app: storeApp)
.padding(.horizontal)
}
}
var contentView: some View {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 32) {
if storeApp.isFromOfficialSource {
officialAppBadge
} else if storeApp.isFromTrustedSource {
trustedAppBadge
}
if let subtitle = storeApp.subtitle {
VStack {
if #available(iOS 15.0, *) {
Image(systemSymbol: .quoteOpening)
.foregroundColor(.secondary.opacity(0.5))
.imageScale(.large)
.transformEffect(CGAffineTransform(a: 1, b: 0, c: -0.3, d: 1, tx: 0, ty: 0))
.frame(maxWidth: .infinity, alignment: .leading)
.offset(x: 30)
}
Text(subtitle)
.bold()
.italic()
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
if #available(iOS 15.0, *) {
Image(systemSymbol: .quoteClosing)
.foregroundColor(.secondary.opacity(0.5))
.imageScale(.large)
.transformEffect(CGAffineTransform(a: 1, b: 0, c: -0.3, d: 1, tx: 0, ty: 0))
.frame(maxWidth: .infinity, alignment: .trailing)
.offset(x: -30)
}
}
.padding(.horizontal)
}
if !storeApp.screenshotURLs.isEmpty {
// Equatable: Only reload the view if the screenshots change.
// This prevents unnecessary redraws on scroll.
AppScreenshotsScrollView(urls: storeApp.screenshotURLs)
.equatable()
} else {
VStack() {
Text(L10n.AppDetailView.noScreenshots)
.italic()
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.horizontal)
}
ExpandableText(text: storeApp.localizedDescription)
.lineLimit(6)
.expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor))
.padding(.horizontal)
}
VStack(spacing: 24) {
Divider()
currentVersionView
Divider()
ratingsView
Divider()
permissionsView
Divider()
informationView
if !(storeApp.isFromOfficialSource || storeApp.isFromTrustedSource) {
Divider()
reportButton
}
}
.padding(.horizontal)
}
.padding(.vertical)
.background(
RoundedRectangle(cornerRadius: contentCornerRadius)
.foregroundColor(Color(UIColor.systemBackground))
.shadow(radius: isHeaderViewVisible ? 12 : 0)
)
}
var officialAppBadge: some View {
HintView(backgroundColor: Color(UIColor.secondarySystemBackground)) {
HStack {
Spacer()
Image(systemSymbol: .checkmarkSealFill)
Text(L10n.AppDetailView.Badge.official)
Spacer()
}
.foregroundColor(.accentColor)
}
.padding(.horizontal)
}
var trustedAppBadge: some View {
HintView(backgroundColor: Color(UIColor.secondarySystemBackground)) {
HStack {
Spacer()
Image(systemSymbol: .shieldLefthalfFill)
Text(L10n.AppDetailView.Badge.trusted)
Spacer()
}
.foregroundColor(.accentColor)
}
.padding(.horizontal)
}
var currentVersionView: some View {
VStack(alignment: .leading, spacing: 8) {
VStack {
HStack(alignment: .firstTextBaseline) {
Text(L10n.AppDetailView.whatsNew)
.bold()
.font(.title3)
Spacer()
NavigationLink {
AppVersionHistoryView(storeApp: self.storeApp)
} label: {
Text(L10n.AppDetailView.WhatsNew.versionHistory)
}
}
if let latestVersion = storeApp.latestVersion {
HStack {
Text(L10n.AppDetailView.version(latestVersion.version))
Spacer()
Text(DateFormatterHelper.string(forRelativeDate: latestVersion.date))
}
.font(.callout)
.foregroundColor(.secondary)
}
}
if let versionDescription = storeApp.versionDescription {
ExpandableText(text: versionDescription)
.lineLimit(5)
.expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor))
} else {
Text(L10n.AppDetailView.noVersionInformation)
.foregroundColor(.secondary)
}
if true {
SwiftUI.Button {
UIApplication.shared.open(URL(string: "https://github.com/SideStore/SideStore")!) { _ in }
} label: {
HStack {
Text(L10n.AppDetailView.WhatsNew.showOnGithub)
Image(systemSymbol: .arrowUpForwardSquare)
}
}
}
}
}
var ratingsView: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
Text(L10n.AppDetailView.whatsNew)
.bold()
.font(.title3)
Spacer()
NavigationLink {
AppVersionHistoryView(storeApp: self.storeApp)
} label: {
Text(L10n.AppDetailView.Reviews.seeAll)
}
}
HStack(spacing: 40) {
VStack {
Text("3.0")
.font(.system(size: 48, weight: .bold, design: .rounded))
.opacity(0.8)
Text(L10n.AppDetailView.Reviews.outOf(5))
.bold()
.font(.callout)
.foregroundColor(.secondary)
}
VStack(alignment: .trailing) {
LazyVGrid(columns: [GridItem(.fixed(48), alignment: .trailing), GridItem(.flexible())], spacing: 2) {
ForEach(Array(1...5).reversed(), id: \.self) { rating in
HStack(spacing: 2) {
ForEach(0..<rating) { _ in
Image(systemSymbol: .starFill)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 8)
}
}
ProgressView(value: 0.5)
.frame(maxWidth: .infinity)
.progressViewStyle(LinearProgressViewStyle(tint: .secondary))
}
}
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
Text(L10n.AppDetailView.Reviews.ratings(5))
.font(.callout)
.foregroundColor(.secondary)
}
}
TabView {
ForEach(0..<5) { i in
HintView(backgroundColor: Color(UIColor.secondarySystemBackground)) {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Review \(i + 1)")
.bold()
.lineLimit(1)
Spacer()
Text(DateFormatterHelper.string(forRelativeDate: Date().addingTimeInterval(-60*60)))
.foregroundColor(.secondary)
}
RatingStars(rating: 5 - i)
.frame(height: 12)
.foregroundColor(.yellow)
}
ExpandableText(text: "Long review text content here.\nMultiple lines.\nAt least three are shown.\nBut are there more?")
.lineLimit(3)
.expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor))
}
.frame(maxWidth: .infinity)
}
.tag(i)
.padding(.horizontal, 16)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(height: 150)
.padding(.horizontal, -16)
if self.canRateApp {
ModalNavigationLink {
NavigationView {
WriteAppReviewView(storeApp: self.storeApp)
}
} label: {
Label("Write a Review", systemSymbol: .squareAndPencil)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
var permissionsView: some View {
VStack(alignment: .leading, spacing: 8) {
Text(L10n.AppDetailView.permissions)
.bold()
.font(.title3)
if storeApp.permissions.isEmpty {
Text(L10n.AppDetailView.noPermissions)
.font(.callout)
.foregroundColor(.secondary)
} else {
AppPermissionsGrid(permissions: storeApp.permissions)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
var informationData: [(title: String, content: String)] {
var data: [(title: String, content: String)] = [
(L10n.AppDetailView.Information.source, self.storeApp.source?.name ?? ""),
(L10n.AppDetailView.Information.developer, self.storeApp.developerName),
// ("Category", self.storeApp.category),
]
if let latestVersion = self.storeApp.latestVersion {
data += [
(L10n.AppDetailView.Information.size, self.byteCountFormatter.string(fromByteCount: latestVersion.size)),
(L10n.AppDetailView.Information.latestVersion, self.storeApp.latestVersion?.version ?? ""),
]
let iOSVersion = ProcessInfo.processInfo.operatingSystemVersion
let hasCompatibilityInfo = [latestVersion.minOSVersion, latestVersion.maxOSVersion].compactMap({ $0 }).isEmpty == false
var compatibility: String = hasCompatibilityInfo ?
L10n.AppDetailView.Information.compatibilityCompatible :
L10n.AppDetailView.Information.compatibilityUnknown
if let minOSVersion = latestVersion.minOSVersion, ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) == false {
compatibility = L10n.AppDetailView.Information.compatibilityAtLeast(minOSVersion.stringValue)
}
if let maxOSVersion = latestVersion.maxOSVersion,
(!ProcessInfo.processInfo.isOperatingSystemAtLeast(maxOSVersion) || maxOSVersion.stringValue.compare(iOSVersion.stringValue, options: .numeric) == .orderedSame) {
compatibility = L10n.AppDetailView.Information.compatibilityOrLower(maxOSVersion.stringValue)
}
data.append((L10n.AppDetailView.Information.compatibility, compatibility))
}
return data
}
var informationView: some View {
VStack(alignment: .leading) {
Text(L10n.AppDetailView.information)
.bold()
.font(.title3)
LazyVGrid(columns: [GridItem(.flexible(), alignment: .leading), GridItem(.flexible(), alignment: .trailing)], spacing: 8) {
ForEach(informationData, id: \.title) { title, content in
Text(title)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(content)
.multilineTextAlignment(.trailing)
}
}
}
}
var reportButton: some View {
SwiftUI.Button {
} label: {
Label("Report this App", systemSymbol: .exclamationmarkBubble)
}
}
}
struct AppDetailView_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
NavigationView {
AppDetailView(storeApp: app)
}
}
}

View File

@@ -0,0 +1,56 @@
//
// AppPermissionsGrid.swift
// SideStore
//
// Created by Fabian Thies on 27.11.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
import AltStoreCore
struct AppPermissionsGrid: View {
let permissions: [AppPermission]
let columns = Array(repeating: GridItem(.flexible()), count: 3)
var body: some View {
LazyVGrid(columns: columns) {
ForEach(permissions, id: \.type) { permission in
AppPermissionGridItemView(permission: permission)
}
}
}
}
struct AppPermissionGridItemView: View {
let permission: AppPermission
@State var isPopoverPresented = false
var body: some View {
SwiftUI.Button {
self.isPopoverPresented = true
} label: {
VStack {
Image(uiImage: permission.type.icon?.withRenderingMode(.alwaysTemplate) ?? UIImage(systemSymbol: .questionmark))
.foregroundColor(.primary)
.padding()
.background(Circle().foregroundColor(Color(.secondarySystemBackground)))
Text(permission.type.localizedShortName ?? permission.type.localizedName ?? "")
}
.foregroundColor(.primary)
}
.alert(isPresented: self.$isPopoverPresented) {
Alert(title: Text(L10n.AppPermissionGrid.usageDescription), message: Text(permission.usageDescription))
}
}
}
//struct AppPermissionsGrid_Previews: PreviewProvider {
// static var previews: some View {
// AppPermissionsGrid()
// }
//}

View File

@@ -0,0 +1,71 @@
//
// AppScreenshotsPreview.swift
// SideStore
//
// Created by Fabian Thies on 23.12.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
import AsyncImage
import AltStoreCore
struct AppScreenshotsPreview: View {
@Environment(\.dismiss)
private var dismiss
let urls: [URL]
let aspectRatio: CGFloat
@State var index: Int
init(urls: [URL], aspectRatio: CGFloat = 9/16, initialIndex: Int = 0) {
self.urls = urls
self.aspectRatio = aspectRatio
self._index = State(initialValue: initialIndex)
}
var body: some View {
TabView(selection: $index) {
ForEach(Array(urls.enumerated()), id: \.offset) { (i, url) in
AppScreenshot(url: url, aspectRatio: aspectRatio)
.padding()
.tag(i)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.navigationTitle("\(index + 1) of \(self.urls.count)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
SwiftUI.Button {
self.dismiss()
} label: {
Text(L10n.Action.close)
}
}
}
}
}
extension AppScreenshotsPreview: Equatable {
/// Prevent re-rendering of the view if the parameters didn't change
static func == (lhs: AppScreenshotsPreview, rhs: AppScreenshotsPreview) -> Bool {
lhs.urls == rhs.urls
}
}
struct AppScreenshotsPreview_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
Color.clear
.sheet(isPresented: .constant(true)) {
NavigationView {
AppScreenshotsPreview(urls: app.screenshotURLs)
}
}
}
}

View File

@@ -0,0 +1,71 @@
//
// AppScreenshotsScrollView.swift
// SideStore
//
// Created by Fabian Thies on 27.11.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
import AsyncImage
/// Horizontal ScrollView with an asynchronously loaded image for each screenshot URL
///
/// The struct inherits the `Equatable` protocol and implements the respective comparisation function to prevent the view from being constantly re-rendered when a `@State` change in the parent view occurs.
/// This way, the `AppScreenshotsScrollView` will only be reloaded when the parameters change.
struct AppScreenshotsScrollView: View {
let urls: [URL]
var aspectRatio: CGFloat = 9/16
var height: CGFloat = 400
@State var selectedScreenshotIndex: Int?
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(Array(urls.enumerated()), id: \.offset) { i, url in
SwiftUI.Button {
self.selectedScreenshotIndex = i
} label: {
AppScreenshot(url: url)
}
}
}
.padding(.horizontal)
}
.frame(height: height)
.shadow(radius: 12)
.sheet(item: self.$selectedScreenshotIndex) { index in
NavigationView {
AppScreenshotsPreview(urls: urls, aspectRatio: aspectRatio, initialIndex: index)
}
}
}
}
extension AppScreenshotsScrollView: Equatable {
/// Prevent re-rendering of the view if the parameters didn't change
static func == (lhs: AppScreenshotsScrollView, rhs: AppScreenshotsScrollView) -> Bool {
lhs.urls == rhs.urls && lhs.aspectRatio == rhs.aspectRatio && lhs.height == rhs.height
}
}
extension Int: Identifiable {
public var id: Int {
self
}
}
import AltStoreCore
struct AppScreenshotsScrollView_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
AppScreenshotsScrollView(urls: app.screenshotURLs)
}
}

View File

@@ -0,0 +1,55 @@
//
// AppVersionHistoryView.swift
// SideStore
//
// Created by Fabian Thies on 28.01.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import AltStoreCore
import ExpandableText
struct AppVersionHistoryView: View {
let storeApp: StoreApp
var body: some View {
List {
ForEach(storeApp.versions.sorted(by: { $0.date < $1.date }), id: \.version) { version in
VStack(spacing: 8) {
HStack {
Text(version.version).bold()
Spacer()
Text(DateFormatterHelper.string(forRelativeDate: version.date))
.foregroundColor(.secondary)
}
if let versionDescription = version.localizedDescription {
ExpandableText(text: versionDescription)
.lineLimit(3)
.expandButton(TextSet(text: L10n.AppDetailView.more, font: .callout, color: .accentColor))
.buttonStyle(.plain)
} else {
Text("No version desciption available")
.italic()
.foregroundColor(.secondary)
}
}
}
}
.listStyle(PlainListStyle())
.navigationTitle("Version History")
}
}
struct AppVersionHistoryView_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
NavigationView {
AppVersionHistoryView(storeApp: app)
}
}
}

View File

@@ -0,0 +1,102 @@
//
// WriteAppReviewView.swift
// SideStore
//
// Created by Fabian Thies on 19.02.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import AltStoreCore
struct WriteAppReviewView: View {
@Environment(\.dismiss) var dismiss
let storeApp: StoreApp
@State var currentRating = 0
@State var reviewText = ""
var canSendReview: Bool {
// Only allow the user to send the review if a rating has been set and
// the review text is either empty or doesn't contain only whitespaces.
self.currentRating > 0 && (
self.reviewText.isEmpty || !self.reviewText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
var body: some View {
List {
// App Information
HStack {
AppIconView(iconUrl: storeApp.iconURL, isSideStore: storeApp.isSideStore, size: 50)
VStack(alignment: .leading) {
Text(storeApp.name)
.bold()
Text(storeApp.developerName)
.font(.callout)
.foregroundColor(.secondary)
}
}
// Rating
Section {
HStack {
Spacer()
ForEach(1...5) { rating in
SwiftUI.Button {
self.currentRating = rating
} label: {
Image(systemSymbol: rating > self.currentRating ? .star : .starFill)
.resizable()
.aspectRatio(contentMode: .fit)
}
.buttonStyle(PlainButtonStyle())
.frame(maxHeight: 40)
}
Spacer()
}
.foregroundColor(.yellow)
} header: {
Text("Rate the App")
}
// Review
Section {
TextEditor(text: self.$reviewText)
.frame(minHeight: 100, maxHeight: 250)
} header: {
Text("Leave a Review (optional)")
}
}
.navigationTitle("Write a Review")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
SwiftUI.Button("Cancel", action: self.dismiss)
}
ToolbarItem(placement: .confirmationAction) {
SwiftUI.Button("Send", action: self.sendReview)
.disabled(!self.canSendReview)
}
}
}
private func sendReview() {
NotificationManager.shared.showNotification(title: "Feature not Implemented")
self.dismiss()
}
}
struct WriteAppReviewView_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
NavigationView {
WriteAppReviewView(storeApp: app)
}
}
}

View File

@@ -0,0 +1,56 @@
//
// AddSourceView.swift
// SideStore
//
// Created by Fabian Thies on 20.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
struct AddSourceView: View {
@State var sourceUrlText: String = ""
var continueHandler: (_ urlText: String) -> ()
var body: some View {
List {
Section {
TextField("https://connect.altstore.ml", text: $sourceUrlText)
.keyboardType(.URL)
.autocapitalization(.none)
.autocorrectionDisabled()
} header: {
Text(L10n.AddSourceView.sourceURL)
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.AddSourceView.sourceWarning)
HStack(alignment: .top) {
Image(systemSymbol: .exclamationmarkTriangleFill)
Text(L10n.AddSourceView.sourceWarningContinued)
}
}
}
SwiftUI.Button {
self.continueHandler(self.sourceUrlText)
} label: {
Text(L10n.AddSourceView.continue)
}
.disabled(URL(string: self.sourceUrlText)?.host == nil)
}
.listStyle(InsetGroupedListStyle())
.navigationTitle(L10n.AddSourceView.title)
.navigationBarTitleDisplayMode(.inline)
}
}
struct AddSourceView_Previews: PreviewProvider {
static var previews: some View {
AddSourceView(continueHandler: { _ in })
}
}

View File

@@ -0,0 +1,42 @@
//
// BrowseAppPreviewView.swift
// SideStore
//
// Created by Fabian Thies on 20.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AsyncImage
import AltStoreCore
struct BrowseAppPreviewView: View {
let storeApp: StoreApp
var body: some View {
VStack(spacing: 16) {
AppRowView(app: storeApp)
if let subtitle = storeApp.subtitle {
Text(subtitle)
.multilineTextAlignment(.center)
}
if !storeApp.screenshotURLs.isEmpty {
HStack {
ForEach(storeApp.screenshotURLs.prefix(2)) { url in
AppScreenshot(url: url)
}
}
.frame(height: 300)
.shadow(radius: 8)
}
}
}
}
//struct BrowseAppPreviewView_Previews: PreviewProvider {
// static var previews: some View {
// BrowseAppPreviewView()
// }
//}

View File

@@ -0,0 +1,165 @@
//
// BrowseView.swift
// SideStoreUI
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
import AltStoreCore
struct BrowseView: View {
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.name, ascending: true),
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)
], predicate: NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID))
var apps: FetchedResults<StoreApp>
var filteredApps: [StoreApp] {
apps.items(matching: self.searchText)
}
@State
var selectedStoreApp: StoreApp?
@State var searchText = ""
@State var isShowingSourcesView = false
var body: some View {
ScrollView {
VStack(alignment: .leading) {
if searchText.isEmpty {
VStack(alignment: .leading, spacing: 32) {
promotedCategoriesView
Text(L10n.BrowseView.Section.AllApps.title)
.font(.title2)
.bold()
}
}
if searchText.isEmpty, filteredApps.count == 0 {
HintView {
Text(L10n.BrowseView.Hints.NoApps.title)
.bold()
Text(L10n.BrowseView.Hints.NoApps.text)
.font(.callout)
.foregroundColor(.secondary)
SwiftUI.Button {
self.isShowingSourcesView = true
} label: {
Label(L10n.BrowseView.Hints.NoApps.addSource, systemSymbol: .plus)
}
.buttonStyle(FilledButtonStyle())
.padding(.top, 8)
}
} else {
LazyVStack(spacing: 32) {
ForEach(filteredApps, id: \.bundleIdentifier) { app in
NavigationLink {
AppDetailView(storeApp: app)
} label: {
BrowseAppPreviewView(storeApp: app)
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
.padding()
.searchable(text: self.$searchText, placeholder: L10n.BrowseView.search)
}
.background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(L10n.BrowseView.title)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
SwiftUI.Button {
self.isShowingSourcesView = true
} label: {
Text(L10n.BrowseView.Actions.sources)
}
.sheet(isPresented: self.$isShowingSourcesView) {
NavigationView {
SourcesView()
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
SwiftUI.Button {
} label: {
Image(systemSymbol: .lineHorizontal3DecreaseCircle)
.imageScale(.large)
}
}
}
}
var promotedCategoriesView: some View {
VStack {
HStack {
Text(L10n.BrowseView.Section.PromotedCategories.title)
.font(.title2)
.bold()
Spacer()
SwiftUI.Button(action: {}, label: {
Text(L10n.BrowseView.Section.PromotedCategories.showAll)
})
.font(.callout)
}
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())]) {
PromotedCategoryView()
.shadow(color: .black.opacity(0.1), radius: 8, y: 5)
PromotedCategoryView()
.shadow(color: .black.opacity(0.1), radius: 8, y: 5)
}
}
}
}
struct BrowseView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BrowseView()
}
}
}
struct PromotedCategoryView: View {
var body: some View {
ZStack {
GeometryReader { proxy in
RadialGradient(colors: [
Color(UIColor(hexString: "477E84")!),
Color(UIColor.secondarySystemBackground),
Color(UIColor.secondarySystemBackground),
Color(UIColor(hexString: "C38FF5")!)
], center: .bottomLeading, startRadius: 0, endRadius: proxy.size.width)
}
HStack {
Image(systemSymbol: .dpadRightFill)
Text(L10n.BrowseView.Categories.gamesAndEmulators)
.multilineTextAlignment(.leading)
}
.foregroundColor(.accentColor)
.padding()
}
.aspectRatio(21/9, contentMode: .fill)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}

View File

@@ -0,0 +1,105 @@
//
// ConfirmAddSourceView.swift
// SideStore
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
import AltStoreCore
struct ConfirmAddSourceView: View {
let fetchedSource: FetchedSource
var source: Source {
fetchedSource.source
}
var confirmationHandler: (_ source: FetchedSource) -> ()
var cancellationHandler: () -> ()
var body: some View {
VStack(alignment: .leading) {
List {
Section {
VStack(alignment: .leading) {
Text("\(source.apps.count) \(L10n.ConfirmAddSourceView.apps)")
Text(source.apps.map { $0.name }.joined(separator: ", "))
.font(.callout)
.lineLimit(1)
.foregroundColor(.secondary)
}
VStack() {
Text("\(source.newsItems.count) \(L10n.ConfirmAddSourceView.newsItems)")
}
} header: {
Text(L10n.ConfirmAddSourceView.sourceContents)
}
Section {
VStack(alignment: .leading) {
Text(L10n.ConfirmAddSourceView.sourceIdentifier)
Text(source.identifier)
.font(.callout)
.foregroundColor(.secondary)
}
VStack(alignment: .leading) {
Text(L10n.ConfirmAddSourceView.sourceURL)
Text(source.sourceURL.absoluteString)
.font(.callout)
.foregroundColor(.secondary)
}
} header: {
Text(L10n.ConfirmAddSourceView.sourceInfo)
}
}
.listStyle(InsetGroupedListStyle())
Spacer()
SwiftUI.Button {
confirmationHandler(fetchedSource)
} label: {
Label(L10n.ConfirmAddSourceView.addSource, systemSymbol: .plus)
}
.buttonStyle(FilledButtonStyle())
.padding()
}
.background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
SwiftUI.Button {
} label: {
Image(systemSymbol: .xmarkCircleFill)
.foregroundColor(.secondary)
}
}
ToolbarItemGroup(placement: .navigation) {
VStack(alignment: .leading) {
Text(source.name)
.font(.title3)
.bold()
Text(source.identifier)
.font(.callout)
.foregroundColor(.secondary)
}
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
struct ConfirmAddSourceView_Previews: PreviewProvider {
static var previews: some View {
AddSourceView(continueHandler: { _ in })
}
}

View File

@@ -0,0 +1,299 @@
//
// SourcesView.swift
// SideStore
//
// Created by Fabian Thies on 20.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
import AltStoreCore
import CoreData
struct SourcesView: View {
@Environment(\.dismiss)
private var dismiss
@Environment(\.managedObjectContext)
var managedObjectContext
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \Source.name, ascending: true),
NSSortDescriptor(keyPath: \Source.sourceURL, ascending: true),
NSSortDescriptor(keyPath: \Source.identifier, ascending: true)
])
var installedSources: FetchedResults<Source>
@State var trustedSources: [Source] = []
@State private var isLoadingTrustedSources: Bool = false
@State private var sourcesFetchContext: NSManagedObjectContext?
@State var isShowingAddSourceAlert = false
@State var sourceToConfirm: FetchedSource?
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 24) {
// Installed Sources
LazyVStack(alignment: .leading, spacing: 12) {
Text(L10n.SourcesView.sourcesDescription)
.font(.callout)
.foregroundColor(.secondary)
ForEach(installedSources, id: \.identifier) { source in
VStack(alignment: .leading) {
Text(source.name)
.bold()
Text(source.sourceURL.absoluteString)
.font(.callout)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.tintedBackground(.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .circular))
.if(source.identifier != Source.altStoreIdentifier) { view in
view.contextMenu(ContextMenu(menuItems: {
SwiftUI.Button {
self.removeSource(source)
} label: {
Label(L10n.SourcesView.remove, systemSymbol: .trash)
}
}))
}
}
}
// Trusted Sources
LazyVStack(alignment: .leading, spacing: 16) {
HStack(spacing: 4) {
Text(L10n.SourcesView.trustedSources)
.font(.title3)
.bold()
Image(systemSymbol: .shieldLefthalfFill)
.foregroundColor(.accentColor)
}
Text(L10n.SourcesView.reviewedText)
.font(.callout)
.foregroundColor(.secondary)
if self.isLoadingTrustedSources {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.frame(maxWidth: .infinity)
} else {
ForEach(self.trustedSources, id: \.sourceURL) { source in
HStack {
VStack(alignment: .leading) {
Text(source.name)
.bold()
Text(source.sourceURL.absoluteString)
.font(.callout)
.foregroundColor(.secondary)
}
Spacer()
if self.installedSources.contains(where: { $0.sourceURL == source.sourceURL }) {
Image(systemSymbol: .checkmarkCircle)
.foregroundColor(.accentColor)
} else {
SwiftUI.Button {
self.fetchSource(with: source.sourceURL.absoluteString)
} label: {
Text("ADD")
.bold()
}
.buttonStyle(PillButtonStyle(tintColor: Asset.accentColor.color, progress: nil))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.tintedBackground(.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .circular))
}
}
}
}
.padding()
}
.navigationTitle(L10n.SourcesView.sources)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
SwiftUI.Button {
self.isShowingAddSourceAlert = true
} label: {
Image(systemSymbol: .plus)
}
.sheet(isPresented: self.$isShowingAddSourceAlert) {
NavigationView {
AddSourceView(continueHandler: fetchSource(with:))
}
}
.sheet(item: self.$sourceToConfirm) { source in
if #available(iOS 16.0, *) {
NavigationView {
ConfirmAddSourceView(fetchedSource: source, confirmationHandler: addSource(_:)) {
self.sourceToConfirm = nil
}
}
.presentationDetents([.medium])
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
SwiftUI.Button(action: self.dismiss) {
Text(L10n.SourcesView.done).bold()
}
}
}
.onAppear(perform: self.fetchTrustedSources)
}
func fetchSource(with urlText: String) {
self.isShowingAddSourceAlert = false
guard let url = URL(string: urlText) else {
return
}
let context = DatabaseManager.shared.persistentContainer.newBackgroundSavingViewContext()
AppManager.shared.fetchSource(sourceURL: url, managedObjectContext: context) { result in
switch result {
case let .success(source):
self.sourceToConfirm = FetchedSource(source: source, context: context)
case let .failure(error):
print(error)
}
}
}
func addSource(_ source: FetchedSource) {
source.context.perform {
do {
try source.context.save()
} catch {
print(error)
NotificationManager.shared.reportError(error: error)
}
}
self.sourceToConfirm = nil
}
func removeSource(_ source: Source) {
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let source = context.object(with: source.objectID) as! Source
context.delete(source)
do {
try context.save()
} catch {
print(error)
}
}
}
func fetchTrustedSources() {
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()
self.sourcesFetchContext = context
let dispatchGroup = DispatchGroup()
var sourcesByURL = [URL: Source]()
var errors: [(error: Error, sourceURL: URL)] = []
for sourceURL in featuredSourceURLs {
dispatchGroup.enter()
AppManager.shared.fetchSource(sourceURL: sourceURL, managedObjectContext: context) { result in
defer {
dispatchGroup.leave()
}
// Serialize access to sourcesByURL.
context.performAndWait {
switch result
{
case .failure(let error): errors.append((error, sourceURL))
case .success(let source): sourcesByURL[source.sourceURL] = source
}
}
}
}
dispatchGroup.notify(queue: .main) {
if let (error, _) = errors.first {
NotificationManager.shared.reportError(error: error)
}
let sources = featuredSourceURLs.compactMap { sourcesByURL[$0] }
self.trustedSources = sources
self.isLoadingTrustedSources = false
}
case .failure(let error):
NotificationManager.shared.reportError(error: error)
self.isLoadingTrustedSources = false
}
}
}
}
struct SourcesView_Previews: PreviewProvider {
static var previews: some View {
Color.clear
.sheet(isPresented: .constant(true)) {
NavigationView {
SourcesView()
}
}
}
}
extension Source: Identifiable {
public var id: String {
self.identifier
}
}
struct FetchedSource: Identifiable {
let source: Source
let context: NSManagedObjectContext
var id: String {
source.identifier
}
init(source: Source, context: NSManagedObjectContext) {
self.source = source
self.context = context
}
}

View File

@@ -0,0 +1,54 @@
//
// AppAction.swift
// SideStore
//
// Created by Fabian Thies on 20.12.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import Foundation
import SFSafeSymbols
enum AppAction: Int, CaseIterable {
case install, open, refresh
case activate, deactivate
case remove
case enableJIT
case backup, exportBackup, restoreBackup
case chooseCustomIcon, resetCustomIcon
var title: String {
switch self {
case .install: return L10n.AppAction.install
case .open: return L10n.AppAction.open
case .refresh: return L10n.AppAction.refresh
case .activate: return L10n.AppAction.activate
case .deactivate: return L10n.AppAction.deactivate
case .remove: return L10n.AppAction.remove
case .enableJIT: return L10n.AppAction.enableJIT
case .backup: return L10n.AppAction.backup
case .exportBackup: return L10n.AppAction.exportBackup
case .restoreBackup: return L10n.AppAction.restoreBackup
case .chooseCustomIcon: return L10n.AppAction.chooseCustomIcon
case .resetCustomIcon: return L10n.AppAction.resetIcon
}
}
var symbol: SFSymbol {
switch self {
case .install: return .squareAndArrowDown
case .open: return .arrowUpForwardApp
case .refresh: return .arrowClockwise
case .activate: return .checkmarkCircle
case .deactivate: return .xmarkCircle
case .remove: return .trash
case .enableJIT: return .bolt
case .backup: return .docOnDoc
case .exportBackup: return .arrowUpDoc
case .restoreBackup: return .arrowDownDoc
case .chooseCustomIcon: return .photo
case .resetCustomIcon: return .arrowUturnLeft
}
}
}

View File

@@ -0,0 +1,119 @@
//
// AppIDsView.swift
// SideStore
//
// Created by Fabian Thies on 23.12.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
import AltStoreCore
struct AppIDsView: View {
@Environment(\.dismiss) var dismiss
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \AppID.name, ascending: true),
NSSortDescriptor(keyPath: \AppID.bundleIdentifier, ascending: true),
NSSortDescriptor(keyPath: \AppID.expirationDate, ascending: true)
], predicate: NSPredicate(format: "%K == %@", #keyPath(AppID.team), DatabaseManager.shared.activeTeam() ?? Team()))
var appIDs: FetchedResults<AppID>
@State var isLoading: Bool = false
var body: some View {
ScrollView {
LazyVStack(alignment: .leading) {
Text(L10n.AppIDsView.description)
.foregroundColor(.secondary)
ForEach(appIDs, id: \.identifier) { appId in
HStack {
VStack(alignment: .leading) {
Text(appId.name)
.bold()
Text(appId.bundleIdentifier)
.font(.footnote)
.foregroundColor(.secondary)
}
Spacer()
if let expirationDate = appId.expirationDate {
VStack(spacing: 4) {
Text("Expires in")
.font(.caption)
.foregroundColor(.accentColor)
SwiftUI.Button {
} label: {
Text(DateFormatterHelper.string(forExpirationDate: expirationDate).uppercased())
.bold()
}
.buttonStyle(PillButtonStyle(tintColor: .altPrimary))
.disabled(true)
}
}
}
.padding()
.tintedBackground(.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 24, style: .circular))
}
}
.padding()
}
.navigationTitle(L10n.AppIDsView.title)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
if self.isLoading {
ProgressView()
.progressViewStyle(.circular)
}
}
ToolbarItem(placement: .confirmationAction) {
SwiftUI.Button(L10n.Action.done, action: self.dismiss)
}
}
.onAppear(performAsync: self.updateAppIDs)
}
func updateAppIDs() async {
self.isLoading = true
defer { self.isLoading = false }
await withCheckedContinuation { continuation in
AppManager.shared.fetchAppIDs { result in
do {
let (_, context) = try result.get()
try context.save()
} catch {
print(error)
NotificationManager.shared.reportError(error: error)
}
continuation.resume()
}
}
}
}
extension View {
func onAppear(performAsync task: @escaping () async -> Void) -> some View {
self.onAppear(perform: { Task { await task() } })
}
}
struct AppIDsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
AppIDsView()
}
}
}

View File

@@ -0,0 +1,437 @@
//
// MyAppsView.swift
// SideStoreUI
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
import MobileCoreServices
import AltStoreCore
struct MyAppsView: View {
// TODO: Refactor
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \InstalledApp.storeApp?.latestVersion?.date, ascending: true),
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)
], predicate: NSPredicate(format: "%K == YES AND %K != nil AND %K != %K",
#keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp),
#keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.latestVersion.version))
)
var updates: FetchedResults<InstalledApp>
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true),
NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false),
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)
], predicate: NSPredicate(format: "%K == YES", #keyPath(InstalledApp.isActive)))
var activeApps: FetchedResults<InstalledApp>
@AppStorage("shouldShowAppUpdateHint")
var shouldShowAppUpdateHint: Bool = true
@ObservedObject
var viewModel = MyAppsViewModel()
// TODO: Refactor
@State var isRefreshingAllApps: Bool = false
@State var selectedSideloadingIpaURL: URL?
var remainingAppIDs: Int {
guard let team = DatabaseManager.shared.activeTeam() else {
return 0
}
let maximumAppIDCount = 10
return max(maximumAppIDCount - team.appIDs.count, 0)
}
// TODO: Refactor
let sideloadFileTypes: [String] = {
if let types = UTTypeCreateAllIdentifiersForTag(kUTTagClassFilenameExtension, "ipa" as CFString, nil)?.takeRetainedValue()
{
return (types as NSArray).map { $0 as! String }
}
else
{
return ["com.apple.itunes.ipa"] // Declared by the system.
}
}()
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
if let progress = SideloadingManager.shared.progress {
VStack {
Text(L10n.MyAppsView.sideloading)
.padding()
ProgressView(progress)
.progressViewStyle(LinearProgressViewStyle())
}
.background(Color(UIColor.secondarySystemBackground))
}
if updates.isEmpty {
if shouldShowAppUpdateHint {
updatesSection
}
}
HStack {
Text(L10n.MyAppsView.active)
.font(.title2)
.bold()
Spacer()
if !self.isRefreshingAllApps {
SwiftUI.Button(L10n.MyAppsView.refreshAll, action: self.refreshAllApps)
} else {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
ForEach(activeApps, id: \.bundleIdentifier) { app in
if let storeApp = app.storeApp {
NavigationLink {
AppDetailView(storeApp: storeApp)
} label: {
self.rowView(for: app)
}
.buttonStyle(PlainButtonStyle())
} else {
self.rowView(for: app)
}
}
if let activeTeam = DatabaseManager.shared.activeTeam() {
VStack {
if activeTeam.type == .free {
Text("\(remainingAppIDs) \(L10n.MyAppsView.appIDsRemaining)")
.foregroundColor(.secondary)
}
ModalNavigationLink(L10n.MyAppsView.viewAppIDs) {
NavigationView {
AppIDsView()
}
}
}
}
}
.padding(.horizontal)
}
.background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(L10n.MyAppsView.myApps)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
ModalNavigationLink {
DocumentPicker(selectedUrl: $selectedSideloadingIpaURL, supportedTypes: sideloadFileTypes)
.ignoresSafeArea()
} label: {
Image(systemSymbol: .plus)
.imageScale(.large)
}
.onChange(of: self.selectedSideloadingIpaURL) { newValue in
guard let url = newValue else {
return
}
self.sideloadApp(at: url)
}
}
}
}
var updatesSection: some View {
HintView {
HStack(alignment: .center) {
Text(L10n.MyAppsView.Hints.NoUpdates.title)
.bold()
Spacer()
Menu {
SwiftUI.Button {
self.dismissUpdatesHint(forever: false)
} label: {
Label(L10n.MyAppsView.Hints.NoUpdates.dismissForNow, systemSymbol: .zzz)
}
SwiftUI.Button {
self.dismissUpdatesHint(forever: true)
} label: {
Label(L10n.MyAppsView.Hints.NoUpdates.dontShowAgain, systemSymbol: .xmark)
}
} label: {
Image(systemSymbol: .xmark)
}
.foregroundColor(.secondary)
}
Text(L10n.MyAppsView.Hints.NoUpdates.text)
.font(.callout)
.foregroundColor(.secondary)
}
}
@ViewBuilder
func rowView(for app: AppProtocol) -> some View {
AppRowView(app: app, showRemainingDays: true)
.contextMenu(ContextMenu(menuItems: {
ForEach(self.actions(for: app), id: \.self) { action in
SwiftUI.Button {
self.perform(action: action, for: app)
} label: {
Label(action.title, systemSymbol: action.symbol)
}
}
}))
}
func refreshAllApps() {
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
self.isRefreshingAllApps = true
self.refresh(installedApps) { result in
self.isRefreshingAllApps = false
}
}
func dismissUpdatesHint(forever: Bool) {
withAnimation {
self.shouldShowAppUpdateHint = false
}
}
}
extension MyAppsView {
// TODO: Convert to async?
func refresh(_ apps: [InstalledApp], completionHandler: @escaping ([String : Result<InstalledApp, Error>]) -> Void) {
let group = AppManager.shared.refresh(apps, presentingViewController: nil, group: self.viewModel.refreshGroup)
group.completionHandler = { results in
DispatchQueue.main.async {
let failures = results.compactMapValues { result -> Error? in
switch result {
case .failure(OperationError.cancelled):
return nil
case .failure(let error):
return error
case .success:
return nil
}
}
guard !failures.isEmpty else { return }
if let failure = failures.first, results.count == 1 {
NotificationManager.shared.reportError(error: failure.value)
} else {
// TODO: Localize
let title = "\(L10n.MyAppsView.failedToRefresh) \(failures.count) \(L10n.MyAppsView.apps)"
let error = failures.first?.value as NSError?
let message = error?.localizedFailure ?? error?.localizedFailureReason ?? error?.localizedDescription
NotificationManager.shared.showNotification(title: title, detailText: message)
}
self.viewModel.refreshGroup = nil
completionHandler(results)
}
}
self.viewModel.refreshGroup = group
}
}
extension MyAppsView {
func actions(for app: AppProtocol) -> [AppAction] {
guard let installedApp = app as? InstalledApp else {
return []
}
guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else {
return [.refresh]
}
var actions: [AppAction] = []
if installedApp.isActive {
actions.append(.open)
actions.append(.refresh)
actions.append(.enableJIT)
} else {
actions.append(.activate)
}
actions.append(.chooseCustomIcon)
if installedApp.hasAlternateIcon {
actions.append(.resetCustomIcon)
}
if installedApp.isActive {
actions.append(.backup)
} else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported {
// Allow backing up inactive apps if they are still installed,
// but on an iOS version that no longer supports legacy deactivation.
// This handles edge case where you can't install more apps until you
// delete some, but can't activate inactive apps again to back them up first.
actions.append(.backup)
}
if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) {
// TODO: Refactor
var backupExists = false
var outError: NSError? = nil
let coordinator = NSFileCoordinator()
coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { (backupDirectoryURL) in
backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path)
}
if backupExists {
actions.append(.exportBackup)
if installedApp.isActive {
actions.append(.restoreBackup)
}
} else if let error = outError {
print("Unable to check if backup exists:", error)
}
}
if installedApp.isActive {
actions.append(.deactivate)
}
if installedApp.bundleIdentifier != StoreApp.altstoreAppID {
actions.append(.remove)
}
return actions
}
func perform(action: AppAction, for app: AppProtocol) {
guard let installedApp = app as? InstalledApp else {
// Invalid state.
return
}
switch action {
case .install: break
case .open: self.open(installedApp)
case .refresh: self.refresh(installedApp)
case .activate: self.activate(installedApp)
case .deactivate: self.deactivate(installedApp)
case .remove: self.remove(installedApp)
case .enableJIT: self.enableJIT(for: installedApp)
case .backup: self.backup(installedApp)
case .exportBackup: self.exportBackup(installedApp)
case .restoreBackup: self.restoreBackup(installedApp)
case .chooseCustomIcon: self.chooseIcon(for: installedApp)
case .resetCustomIcon: self.resetIcon(for: installedApp)
}
}
func open(_ app: InstalledApp) {
UIApplication.shared.open(app.openAppURL) { success in
guard !success else { return }
NotificationManager.shared.reportError(error: OperationError.openAppFailed(name: app.name))
}
}
func refresh(_ app: InstalledApp) {
let previousProgress = AppManager.shared.refreshProgress(for: app)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
self.refresh([app]) { (results) in
print("Finished refreshing with results:", results.map { ($0, $1.error?.localizedDescription ?? "success") })
}
}
func activate(_ app: InstalledApp) {
}
func deactivate(_ app: InstalledApp) {
}
func remove(_ app: InstalledApp) {
}
func enableJIT(for app: InstalledApp) {
AppManager.shared.enableJIT(for: app) { result in
switch result {
case .success:
break
case .failure(let error):
NotificationManager.shared.reportError(error: error)
}
}
}
func backup(_ app: InstalledApp) {
}
func exportBackup(_ app: InstalledApp) {
}
func restoreBackup(_ app: InstalledApp) {
}
func chooseIcon(for app: InstalledApp) {
}
func resetIcon(for app: InstalledApp) {
}
func setIcon(for app: InstalledApp, to image: UIImage? = nil) {
}
func sideloadApp(at url: URL) {
SideloadingManager.shared.sideloadApp(at: url) { result in
switch result {
case .success:
print("App sideloaded successfully.")
case .failure(let error):
print("Failed to sideload app: \(error.localizedDescription)")
}
}
}
}
struct MyAppsView_Previews: PreviewProvider {
static let context = DatabaseManager.shared.viewContext
static let app = StoreApp.makeAltStoreApp(in: context)
static var previews: some View {
NavigationView {
MyAppsView()
}
}
}

View File

@@ -0,0 +1,16 @@
//
// MyAppsViewModel.swift
// SideStore
//
// Created by Fabian Thies on 13.12.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
import AltStoreCore
class MyAppsViewModel: ViewModel {
var refreshGroup: RefreshGroup?
}

View File

@@ -0,0 +1,129 @@
//
// NewsItemView.swift
// SideStoreUI
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AsyncImage
import AltStoreCore
struct NewsItemView: View {
typealias TapHandler<T> = (T) -> Void
let newsItem: NewsItem
private var newsSelectionHandler: TapHandler<NewsItem>? = nil
private var appSelectionHandler: TapHandler<StoreApp>? = nil
init(newsItem: NewsItem) {
self.newsItem = newsItem
}
var body: some View {
VStack(spacing: 12) {
newsContent
.onTapGesture {
newsSelectionHandler?(newsItem)
}
if let connectedApp = newsItem.storeApp {
NavigationLink {
AppDetailView(storeApp: connectedApp)
} label: {
AppRowView(app: connectedApp)
}
.buttonStyle(PlainButtonStyle())
}
}
}
var newsContent: some View {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading) {
Text(newsItem.title)
.font(.title2)
.bold()
.foregroundColor(.white)
HStack(spacing: 0) {
if let sourceName = newsItem.source?.name {
Text(sourceName)
.italic()
}
if let externalURL = newsItem.externalURL {
Text(" • ")
HStack(spacing: 0) {
Image(systemSymbol: .link)
Text(externalURL.host ?? "")
.italic()
}
}
}
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.7))
}
Text(newsItem.caption)
.foregroundColor(.white.opacity(0.7))
}
.padding(24)
if let imageUrl = newsItem.imageURL {
AsyncImage(url: imageUrl) { image in
image
.resizable()
.aspectRatio(contentMode: .fit)
} placeholder: {
Color.secondary
.frame(maxWidth: .infinity, maxHeight: 100)
}
}
}
.frame(
maxWidth: .infinity,
alignment: .topLeading
)
.background(Color(newsItem.tintColor))
.clipShape(RoundedRectangle(cornerRadius: 24, style: .circular))
}
func onNewsSelection(_ handler: @escaping TapHandler<NewsItem>) -> Self {
var newSelf = self
newSelf.newsSelectionHandler = handler
return newSelf
}
func onAppSelection(_ handler: @escaping TapHandler<StoreApp>) -> Self {
var newSelf = self
newSelf.appSelectionHandler = handler
return newSelf
}
}
extension URL: Identifiable {
public var id: String {
return self.absoluteString
}
}
//struct NewsItemView_Previews: PreviewProvider {
// static var previews: some View {
// NewsItemView()
// }
//}
extension NewsItemView: Equatable {
/// Prevent re-rendering of the view if the parameters didn't change
static func == (lhs: NewsItemView, rhs: NewsItemView) -> Bool {
lhs.newsItem.identifier == rhs.newsItem.identifier
}
}

View File

@@ -0,0 +1,97 @@
//
// NewsView.swift
// SideStoreUI
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AltStoreCore
struct NewsView: View {
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \NewsItem.date, ascending: false),
NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: true),
NSSortDescriptor(keyPath: \NewsItem.sourceIdentifier, ascending: true)
])
var news: FetchedResults<NewsItem>
@State
var activeExternalUrl: URL?
@State
var selectedStoreApp: StoreApp?
var body: some View {
ScrollView {
self.announcementsCarousel
VStack(alignment: .leading) {
Text(L10n.NewsView.Section.FromSources.title)
.font(.title2)
.bold()
LazyVStack(spacing: 24) {
ForEach(news, id: \.objectID) { newsItem in
NewsItemView(newsItem: newsItem)
.onNewsSelection { newsItem in
self.activeExternalUrl = newsItem.externalURL
}
.frame(
maxWidth: .infinity,
alignment: .topLeading
)
}
}
}
.padding()
}
.background(Color(UIColor.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(L10n.NewsView.title)
.sheet(item: self.$activeExternalUrl) { url in
SafariView(url: url)
.ignoresSafeArea()
}
.onAppear(perform: fetchNews)
}
var announcementsCarousel: some View {
TabView {
ForEach(0..<5) { _ in
RoundedRectangle(cornerRadius: 10)
.foregroundColor(.secondary)
.shadow(radius: 5, y: 3)
.padding()
}
}
.tabViewStyle(PageTabViewStyle())
.frame(maxWidth: .infinity)
.aspectRatio(16/9, contentMode: .fit)
}
func fetchNews() {
AppManager.shared.fetchSources { result in
do {
do {
let (_, context) = try result.get()
try context.save()
} catch let error as AppManager.FetchSourcesError {
try error.managedObjectContext?.save()
throw error
}
} catch {
print(error)
NotificationManager.shared.reportError(error: error)
}
}
}
}
struct NewsView_Previews: PreviewProvider {
static var previews: some View {
NewsView()
}
}

View File

@@ -0,0 +1,22 @@
//
// NewsViewModel.swift
// SideStoreUI
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AltStoreCore
class NewsViewModel: ViewModel {
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \NewsItem.date, ascending: false),
NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: true),
NSSortDescriptor(keyPath: \NewsItem.sourceIdentifier, ascending: true)
])
var news: FetchedResults<NewsItem>
init() {}
}

View File

@@ -0,0 +1,94 @@
//
// 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) {
// left
Image(uiImage: UIImage(named: "Midnight-image")!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 0.2 * proxy.size.width)
.cornerRadius(0.2 * proxy.size.width * 0.234)
.offset(x: -0.3 * proxy.size.width * self.animationProgress, y: -30)
.rotationEffect(.degrees(-20 * self.animationProgress))
.shadow(radius: 8 * self.animationProgress)
// center-left
Image(uiImage: UIImage(named: "Steel-image")!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 0.25 * proxy.size.width)
.cornerRadius(0.25 * proxy.size.width * 0.234)
.offset(x: -0.15 * proxy.size.width * self.animationProgress, y: -10)
.rotationEffect(.degrees(-10 * self.animationProgress))
.shadow(radius: 12 * self.animationProgress)
// right
Image(uiImage: UIImage(named: "Storm-image")!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 0.2 * proxy.size.width)
.cornerRadius(0.2 * proxy.size.width * 0.234)
.offset(x: self.animationProgress * 0.3 * proxy.size.width, y: -30)
.rotationEffect(.degrees(self.animationProgress * 20))
.shadow(radius: 8 * self.animationProgress)
// center-right
Image(uiImage: UIImage(named: "Starburst-image")!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 0.25 * proxy.size.width)
.cornerRadius(0.25 * proxy.size.width * 0.234)
.offset(x: self.animationProgress * 0.15 * proxy.size.width, y: -10)
.rotationEffect(.degrees(self.animationProgress * 10))
.shadow(radius: 12 * self.animationProgress)
// center
Image(uiImage: UIImage(named: "Neon-image")!)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 0.3 * proxy.size.width)
.cornerRadius(0.3 * proxy.size.width * 0.234)
.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)
}
}

View File

@@ -0,0 +1,86 @@
//
// OnboardingStepView.swift
// SideStore
//
// Created by Fabian Thies on 25.02.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
struct OnboardingStep<Title: View, Hero: View, Content: View, Action: View> {
@ViewBuilder
var title: Title
@ViewBuilder
var hero: Hero
@ViewBuilder
var content: Content
@ViewBuilder
var action: Action
}
struct OnboardingStepView<Title: View, Hero: View, Content: View, Action: View>: 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 {
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())
})
}
}

View File

@@ -0,0 +1,447 @@
//
// 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, CaseIterable {
case welcome, pairing, wireguard, wireguardConfig, addSources, finish
}
@Environment(\.dismiss) var dismiss
// Temporary workaround for UIKit compatibility
var onDismiss: (() -> Void)? = nil
var closeAfterPairing = false
@State var currentStep: OnboardingStep = .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 `<UUID>.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 pairingString = String(bytes: data, encoding: .utf8)
if pairingString == nil {
// TODO: Show error message (this will only be triggered if the pairing file is not UTF8)
debugPrint("Unable to read pairing file")
// displayError("Unable to read pairing file")
}
// Save to a file for next launch
let filename = "ALTPairingFile.mobiledevicepairing"
let documentsPath = FileManager.default.documentsDirectory.appendingPathComponent("/\(filename)")
try pairingString?.write(to: documentsPath, atomically: true, encoding: String.Encoding.utf8)
// Start minimuxer now that we have a file
start_minimuxer_threads(pairingString!)
// Show the next onboarding step, or finish onboarding
if !self.closeAfterPairing {
self.showNextStep()
} else {
self.finishOnboarding()
}
} catch {
NotificationManager.shared.reportError(error: error)
}
if (isSecuredURL) {
url.stopAccessingSecurityScopedResource()
}
}
func start_minimuxer_threads(_ pairing_file: String) {
target_minimuxer_address()
let documentsDirectory = FileManager.default.documentsDirectory.absoluteString
do {
try start(pairing_file, documentsDirectory)
} catch {
try! FileManager.default.removeItem(at: FileManager.default.documentsDirectory.appendingPathComponent("\(pairingFileName)"))
NotificationManager.shared.reportError(error: error)
debugPrint("minimuxer failed to start, please restart SideStore.", error)
// displayError("minimuxer failed to start, please restart SideStore. \((error as? LocalizedError)?.failureReason ?? "UNKNOWN ERROR!!!!!! REPORT TO GITHUB ISSUES!")")
}
start_auto_mounter(documentsDirectory)
}
}
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() {
guard self.areTrustedSourcesEnabled else {
return self.showNextStep()
}
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 {
ForEach(OnboardingView.OnboardingStep.allCases, id: \.self) { step in
Color.red
.ignoresSafeArea()
.sheet(isPresented: .constant(true)) {
OnboardingView(currentStep: step)
}
}
}
}

View File

@@ -0,0 +1,114 @@
//
// RootView.swift
// SideStoreUI
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
@_exported import Inject
struct RootView: View {
@State var selectedTab: Tab = .defaultTab
var body: some View {
TabView(selection: self.$selectedTab) {
ForEach(Tab.allCases) { tab in
NavigationView {
content(for: tab)
}
.navigationViewStyle(StackNavigationViewStyle())
.tag(tab)
.tabItem {
tab.label
}
}
}
.overlay(self.notificationsOverlay)
}
@ViewBuilder
func content(for tab: Tab) -> some View {
switch tab {
case .news:
NewsView()
case .browse:
BrowseView()
case .myApps:
MyAppsView()
case .settings:
SettingsView()
}
}
@ObservedObject
var notificationManager = NotificationManager.shared
var notificationsOverlay: some View {
VStack {
Spacer()
ForEach(Array(notificationManager.notifications.values)) { notification in
VStack(alignment: .leading) {
Text(notification.title)
.bold()
if let message = notification.message {
Text(message)
.font(.callout)
}
}
.padding()
.frame(maxWidth: .infinity)
.foregroundColor(.white)
.background(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 15)
}
Spacer()
.frame(height: 50)
}
.padding()
.animation(.easeInOut)
}
}
extension RootView {
enum Tab: Int, NavigationTab {
case news, browse, myApps, settings
static var defaultTab: RootView.Tab = .news
var displaySymbol: SFSymbol {
switch self {
case .news: return .newspaper
case .browse: return .booksVertical
case .myApps: return .squareStack
case .settings: return .gearshape
}
}
var displayName: String {
switch self {
case .news: return L10n.RootView.news
case .browse: return L10n.RootView.browse
case .myApps: return L10n.RootView.myApps
case .settings: return L10n.RootView.settings
}
}
var label: some View {
Label(self.displayName, systemSymbol: self.displaySymbol)
}
}
}
struct RootView_Previews: PreviewProvider {
static var previews: some View {
RootView()
}
}

View File

@@ -0,0 +1,76 @@
//
// AdvancedSettingsView.swift
// SideStore
//
// Created by naturecodevoid on 2/19/23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
private struct Server: Identifiable {
var id: String { value }
var display: String
var value: String
}
struct AdvancedSettingsView: View {
@ObservedObject private var iO = Inject.observer
private let anisetteServers = [
Server(display: "SideStore", value: "http://ani.sidestore.io"),
Server(display: "Macley (US)", value: "http://us1.sternserv.tech"),
Server(display: "Macley (DE)", value: "http://de1.sternserv.tech"),
Server(display: "DrPudding", value: "https://sign.rheaa.xyz"),
Server(display: "jkcoxson (AltServer)", value: "http://jkcoxson.com:2095"),
Server(display: "jkcoxson (Provision)", value: "http://jkcoxson.com:2052"),
Server(display: "Sideloadly", value: "https://sideloadly.io/anisette/irGb3Quww8zrhgqnzmrx"),
Server(display: "Nick", value: "http://45.33.29.114"),
Server(display: "Jawshoeadan", value: "https://anisette.jawshoeadan.me"),
Server(display: "crystall1nedev", value: "https://anisette.crystall1ne.software/"),
]
@AppStorage("textServer")
var usePreferred: Bool = true
@AppStorage("textInputAnisetteURL")
var anisetteURL: String = ""
@AppStorage("customAnisetteURL")
var selectedAnisetteServer: String = ""
var body: some View {
List {
Section {
Picker(L10n.AdvancedSettingsView.anisette, selection: $selectedAnisetteServer) {
ForEach(anisetteServers) { server in
Text(server.display)
}
}
}
Section {
Toggle(L10n.AdvancedSettingsView.DangerZone.usePreferred, isOn: $usePreferred)
HStack {
Text(L10n.AdvancedSettingsView.DangerZone.anisetteURL)
TextField("", text: $anisetteURL)
.autocapitalization(.none)
.autocorrectionDisabled(true)
}
} header: {
Text(L10n.AdvancedSettingsView.dangerZone)
} footer: {
Text(L10n.AdvancedSettingsView.dangerZoneInfo)
}
}
.navigationTitle(L10n.AdvancedSettingsView.title)
.enableInjection()
}
}
struct AdvancedSettingsView_Previews: PreviewProvider {
static var previews: some View {
AdvancedSettingsView()
}
}

View File

@@ -0,0 +1,135 @@
//
// AppIconsView.swift
// SideStore
//
// Created by naturecodevoid on 2/14/23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import SFSafeSymbols
struct Icon: Identifiable {
var id: String { assetName }
var displayName: String
let assetName: String
}
private struct SpecialIcon {
let assetName: String
let suffix: String?
let forceIndex: Int?
}
class AppIconsData: ObservableObject {
static let shared = AppIconsData()
private static let specialIcons = [
SpecialIcon(assetName: "Neon", suffix: "(Stable)", forceIndex: 0),
SpecialIcon(assetName: "Starburst", suffix: "(Beta)", forceIndex: 1),
SpecialIcon(assetName: "Steel", suffix: "(Nightly)", forceIndex: 2),
]
@Published var icons: [Icon] = []
@Published var primaryIcon: Icon?
@Published var selectedIconName: String?
private init() {
let bundleIcons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as! [String: Any]
let primaryIconData = bundleIcons["CFBundlePrimaryIcon"] as! [String: Any]
let primaryIconName = primaryIconData["CFBundleIconName"] as! String
primaryIcon = Icon(displayName: primaryIconName, assetName: primaryIconName)
icons.append(primaryIcon!)
for (key, _) in bundleIcons["CFBundleAlternateIcons"] as! [String: Any] {
icons.append(Icon(displayName: key, assetName: key))
}
// sort alphabetically
icons.sort { $0.assetName < $1.assetName }
for specialIcon in AppIconsData.specialIcons {
guard let icon = icons.enumerated().first(where: { $0.element.assetName == specialIcon.assetName }) else { continue }
if let suffix = specialIcon.suffix {
icons[icon.offset].displayName += " " + suffix
}
if let forceIndex = specialIcon.forceIndex {
let e = icons.remove(at: icon.offset)
icons.insert(e, at: forceIndex)
}
}
if let alternateIconName = UIApplication.shared.alternateIconName {
selectedIconName = icons.first { $0.assetName == alternateIconName }?.assetName ?? primaryIcon!.assetName
} else {
selectedIconName = primaryIcon!.assetName
}
}
}
struct AppIconsView: View {
@ObservedObject private var iO = Inject.observer
@ObservedObject private var data = AppIconsData.shared
private let artists = [
"Chris (LitRitt)": ["Neon", "Starburst", "Steel", "Storm"],
"naturecodevoid": ["Honeydew", "Midnight", "Sky"],
"Swifticul": ["Vista"],
]
@State private var selectedIcon: String? = "" // this is just so the list row background changes when selecting a value, I couldn't get it to keep the selected icon name (for some reason it was always "", even when I set it to the selected icon asset name)
private let size: CGFloat = 72
private var cornerRadius: CGFloat {
size * 0.234
}
var body: some View {
List(data.icons, selection: $selectedIcon) { icon in
SwiftUI.Button(action: {
data.selectedIconName = icon.assetName
// Pass nil for primary icon
UIApplication.shared.setAlternateIconName(icon.assetName == data.primaryIcon!.assetName ? nil : icon.assetName, completionHandler: { error in
if let error = error {
print("error when setting alternate app icon to \(icon.assetName): \(error.localizedDescription)")
} else {
print("successfully changed app icon to \(icon.assetName)")
}
})
}) {
HStack(spacing: 20) {
// if we don't have an additional image asset for each icon, it will have low resolution
Image(uiImage: UIImage(named: icon.assetName + "-image") ?? UIImage())
.resizable()
.renderingMode(.original)
.cornerRadius(cornerRadius)
.frame(width: size, height: size)
VStack(alignment: .leading) {
Text(icon.displayName)
if let artist = artists.first(where: { $0.value.contains(icon.assetName) }) {
Text("By " + artist.key)
.foregroundColor(.gray)
}
}
Spacer()
if data.selectedIconName == icon.assetName {
Image(systemSymbol: .checkmark)
.foregroundColor(Color.blue)
}
}
}.foregroundColor(.primary)
}
.navigationTitle(L10n.AppIconsView.title)
.enableInjection()
}
}
struct AppIconsView_Previews: PreviewProvider {
static var previews: some View {
AppIconsView()
}
}

View File

@@ -0,0 +1,109 @@
//
// ConnectAppleIDView.swift
// SideStore
//
// Created by Fabian Thies on 29.11.22.
// Copyright © 2022 SideStore. All rights reserved.
//
import SwiftUI
import AltSign
struct ConnectAppleIDView: View {
typealias AuthenticationHandler = (String, String, @escaping (Result<(ALTAccount, ALTAppleAPISession), Error>) -> Void) -> Void
typealias CompletionHandler = ((ALTAccount, ALTAppleAPISession, String)?) -> Void
@Environment(\.dismiss)
private var dismiss
var authenticationHandler: AuthenticationHandler?
var completionHandler: CompletionHandler?
@State var email: String = ""
@State var password: String = ""
@State var isLoading: Bool = false
var isFormValid: Bool {
!email.isEmpty && !password.isEmpty
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 32) {
Text(L10n.ConnectAppleIDView.startWithSignIn)
VStack(spacing: 16) {
RoundedTextField(title: L10n.ConnectAppleIDView.appleID, placeholder: "user@sidestore.io", text: $email)
RoundedTextField(title: L10n.ConnectAppleIDView.password, placeholder: "••••••", text: $password, isSecure: true)
}
SwiftUI.Button(action: signIn) {
Text(L10n.ConnectAppleIDView.signIn)
.bold()
}
.buttonStyle(FilledButtonStyle(isLoading: isLoading))
.disabled(!isFormValid)
Spacer()
VStack(alignment: .leading) {
Text(L10n.ConnectAppleIDView.whyDoWeNeedThis)
.bold()
Text(L10n.ConnectAppleIDView.footer)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.foregroundColor(Color(.secondarySystemBackground))
)
}
.padding(.horizontal)
}
.frame(maxWidth: .infinity)
.navigationTitle(L10n.ConnectAppleIDView.connectYourAppleID)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
SwiftUI.Button(action: self.cancel) {
Text(L10n.ConnectAppleIDView.cancel)
}
}
}
}
func signIn() {
self.isLoading = true
self.authenticationHandler?(email, password) { (result) in
defer {
self.isLoading = false
}
switch result
{
case .failure(ALTAppleAPIError.requiresTwoFactorAuthentication):
// Ignore
break
case .failure(let error as NSError):
let error = error.withLocalizedFailure(NSLocalizedString(L10n.ConnectAppleIDView.failedToSignIn, comment: ""))
print(error)
case .success((let account, let session)):
self.completionHandler?((account, session, password))
}
}
}
func cancel() {
self.completionHandler?(nil)
// self.dismiss()
}
}
struct ConnectAppleIDView_Previews: PreviewProvider {
static var previews: some View {
ConnectAppleIDView()
}
}

View File

@@ -0,0 +1,190 @@
//
// DevModeView.swift
// SideStore
//
// Created by naturecodevoid on 2/16/23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import LocalConsole
import minimuxer
// Yes, we know the password is right here. It's not supposed to be a secret, just something to hopefully prevent people breaking SideStore with dev mode and then complaining to us.
let DEV_MODE_PASSWORD = "devmode"
struct DevModePrompt: View {
@Binding var isShowingDevModePrompt: Bool
@Binding var isShowingDevModeMenu: Bool
@State var countdown = 0
@State var isShowingPasswordAlert = false
@State var isShowingIncorrectPasswordAlert = false
@State var password = ""
var button: some View {
SwiftUI.Button(action: {
if #available(iOS 16.0, *) {
isShowingPasswordAlert = true
} else {
// iOS 14 doesn't support .alert, so just go straight to dev mode without asking for a password
// iOS 15 also doesn't seem to support TextField in an alert (the text field was nonexistent)
enableDevMode()
}
}) {
Text(countdown <= 0 ? L10n.Action.enable + " " + L10n.DevModeView.title : L10n.DevModeView.read + " (\(countdown))")
.foregroundColor(.red)
}
.buttonStyle(FilledButtonStyle()) // TODO: set tintColor so text is more readable
.disabled(countdown > 0)
}
@ViewBuilder
var text: some View {
if #available(iOS 15.0, *),
let string = try? AttributedString(markdown: L10n.DevModeView.prompt, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(string)
} else {
Text(L10n.DevModeView.prompt)
}
}
var view: some View {
ScrollView {
VStack {
text
.foregroundColor(.primary)
.padding(.bottom)
button
}
.padding(.horizontal)
}
.frame(maxWidth: .infinity)
.navigationTitle(L10n.DevModeView.title)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
SwiftUI.Button(action: { isShowingDevModePrompt = false }) {
Text(L10n.Action.close)
}
}
}
.onAppear {
countdown = 20
tickCountdown()
}
}
var body: some View {
NavigationView {
if #available(iOS 15.0, *) {
view
.alert(L10n.DevModeView.password, isPresented: $isShowingPasswordAlert) {
TextField(L10n.DevModeView.password, text: $password)
.autocapitalization(.none)
.autocorrectionDisabled(true)
SwiftUI.Button(L10n.Action.submit, action: {
if password == DEV_MODE_PASSWORD {
enableDevMode()
} else {
isShowingIncorrectPasswordAlert = true
}
})
}
.alert(L10n.DevModeView.incorrectPassword, isPresented: $isShowingIncorrectPasswordAlert) {
SwiftUI.Button(L10n.Action.tryAgain, action: {
isShowingIncorrectPasswordAlert = false
isShowingPasswordAlert = true
})
SwiftUI.Button(L10n.Action.cancel, action: {
isShowingIncorrectPasswordAlert = false
isShowingDevModePrompt = false
})
}
} else {
view
}
}
}
func enableDevMode() {
UserDefaults.standard.isDevModeEnabled = true
isShowingDevModePrompt = false
isShowingDevModeMenu = true
}
func tickCountdown() {
if countdown <= 0 { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
countdown -= 1
tickCountdown()
}
}
}
struct DevModeMenu: View {
@ObservedObject private var iO = Inject.observer
@AppStorage("isConsoleEnabled")
var isConsoleEnabled: Bool = false
var body: some View {
List {
Section {
Toggle(L10n.DevModeView.console, isOn: self.$isConsoleEnabled)
.onChange(of: self.isConsoleEnabled) { value in
LCManager.shared.isVisible = value
}
NavigationLink(L10n.DevModeView.dataExplorer) {
FileExplorer.normal(url: FileManager.default.altstoreSharedDirectory)
.navigationTitle(L10n.DevModeView.dataExplorer)
}.foregroundColor(.red)
NavigationLink(L10n.DevModeView.tmpExplorer) {
FileExplorer.normal(url: FileManager.default.temporaryDirectory)
.navigationTitle(L10n.DevModeView.tmpExplorer)
}.foregroundColor(.red)
Toggle(L10n.DevModeView.skipResign, isOn: ResignAppOperation.skipResignBinding)
.foregroundColor(.red)
} footer: {
Text(L10n.DevModeView.footer)
}
Section {
AsyncFallibleButton(action: {
let dir = try dump_profiles(FileManager.default.documentsDirectory.absoluteString)
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: "shareddocuments://" + dir.toString())!, options: [:], completionHandler: nil)
}
}) { execute in
Text(L10n.DevModeView.Minimuxer.dumpProfiles)
}
NavigationLink(L10n.DevModeView.Minimuxer.afcExplorer) {
FileExplorer.afc()
.navigationTitle(L10n.DevModeView.Minimuxer.afcExplorer)
}.foregroundColor(.red)
} header: {
Text(L10n.DevModeView.minimuxer)
} footer: {
Text(L10n.DevModeView.Minimuxer.footer)
}
}
.navigationTitle(L10n.DevModeView.title)
.enableInjection()
}
}
struct DevModeView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
List {
NavigationLink("DevModeMenu") {
DevModeMenu()
}
}
}
}
}

View File

@@ -0,0 +1,169 @@
//
// ErrorLogView.swift
// SideStore
//
// Created by Fabian Thies on 03.02.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import AltStoreCore
import ExpandableText
struct ErrorLogView: View {
@Environment(\.dismiss) var dismiss
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \LoggedError.date, ascending: false)
])
var loggedErrors: FetchedResults<LoggedError>
var groupedLoggedErrors: [Date: [LoggedError]] {
Dictionary(grouping: loggedErrors, by: { Calendar.current.startOfDay(for: $0.date) })
}
@State var currentFaqUrl: URL?
@State var isShowingMinimuxerLog: Bool = false
@State var isShowingDeleteConfirmation: Bool = false
var body: some View {
List {
ForEach(groupedLoggedErrors.keys.sorted(by: { $0 > $1 }), id: \.self) { date in
Section {
let errors = groupedLoggedErrors[date] ?? []
ForEach(errors, id: \.date) { error in
VStack(spacing: 8) {
HStack(alignment: .top) {
Group {
if let storeApp = error.storeApp {
AppIconView(iconUrl: storeApp.iconURL, isSideStore: storeApp.isSideStore, size: 50)
} else {
ZStack {
RoundedRectangle(cornerRadius: 50*0.234, style: .continuous)
.foregroundColor(Color(UIColor.secondarySystemFill))
Image(systemSymbol: .exclamationmarkCircle)
.imageScale(.large)
.foregroundColor(.red)
}
.frame(width: 50, height: 50)
}
}
VStack(alignment: .leading) {
Text(error.localizedFailure ?? "Operation Failed")
.bold()
Group {
switch error.domain {
case AltServerErrorDomain: Text("SideServer Error \(error.code)")
case OperationError.domain: Text("SideStore Error \(error.code)")
default: Text(error.error.localizedErrorCode)
}
}
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text(DateFormatterHelper.timeString(for: error.date))
.font(.caption)
.foregroundColor(.secondary)
}
let nsError = error.error as NSError
let errorDescription = [nsError.localizedDescription, nsError.localizedRecoverySuggestion].compactMap { $0 }.joined(separator: "\n\n")
Menu {
SwiftUI.Button {
UIPasteboard.general.string = errorDescription
} label: {
Label("Copy Error Message", systemSymbol: .docOnDoc)
}
SwiftUI.Button {
UIPasteboard.general.string = error.error.localizedErrorCode
} label: {
Label("Copy Error Code", systemSymbol: .docOnDoc)
}
SwiftUI.Button {
self.searchFAQ(for: error)
} label: {
Label("Search FAQ", systemSymbol: .magnifyingglass)
}
} label: {
Text(errorDescription)
.multilineTextAlignment(.leading)
.foregroundColor(.primary)
}
}
}
} header: {
Text(DateFormatterHelper.string(for: date))
}
}
}
.navigationBarTitle("Error Log")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
ModalNavigationLink {
FilePreviewView(urls: [
FileManager.default.documentsDirectory.appendingPathComponent("minimuxer.log")
])
.ignoresSafeArea()
} label: {
Image(systemSymbol: .ladybug)
}
SwiftUI.Button {
self.isShowingDeleteConfirmation = true
} label: {
Image(systemSymbol: .trash)
}
.actionSheet(isPresented: self.$isShowingDeleteConfirmation) {
ActionSheet(
title: Text("Are you sure you want to clear the error log?"),
buttons: [
.destructive(Text("Clear Error Log"), action: self.clearLoggedErrors),
.cancel()
]
)
}
}
}
.sheet(item: self.$currentFaqUrl) { url in
SafariView(url: url)
}
}
func searchFAQ(for error: LoggedError) {
let baseURL = URL(string: "https://faq.altstore.io/getting-started/troubleshooting-guide")!
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
let query = [error.domain, "\(error.code)"].joined(separator: "+")
components.queryItems = [URLQueryItem(name: "q", value: query)]
self.currentFaqUrl = components.url ?? baseURL
}
func clearLoggedErrors() {
DatabaseManager.shared.purgeLoggedErrors { result in
if case let .failure(error) = result {
NotificationManager.shared.reportError(error: error)
}
}
}
}
struct ErrorLogView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ErrorLogView()
}
}
}

View File

@@ -0,0 +1,112 @@
//
// LicensesView.swift
// SideStore
//
// Created by Fabian Thies on 21.01.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
struct LicensesView: View {
let licenses = """
Jay Freeman (ldid)
Copyright (C) 2007-2012 Jay Freeman (saurik)
libimobiledevice
© 2007-2015 by the contributors of libimobiledevice - All rights reserved.
Gilles Vollant (minizip)
Copyright (C) 1998-2005 Gilles Vollant
Kishikawa Katsumi (KeychainAccess)
Copyright (c) 2014 kishikawa katsumi
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Alexander Grebenyuk (Nuke)
Copyright (c) 2015-2019 Alexander Grebenyuk
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Craig Hockenberry (MarkdownAttributedString)
Copyright (c) 2020 The Iconfactory, Inc. https://iconfactory.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The OpenSSL Project (OpenSSL)
Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. All advertising materials mentioning features or use of this software must display the following acknowledgment: "This product includes software developed by the OpenSSL Project for use in the OpenSSL Toolkit. (http://www.openssl.org/)"
4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to endorse or promote products derived from this software without prior written permission. For written permission, please contact openssl-core@openssl.org.
5. Products derived from this software may not be called "OpenSSL" nor may "OpenSSL" appear in their names without prior written permission of the OpenSSL Project.
6. Redistributions of any form whatsoever must retain the following acknowledgment:
"This product includes software developed by the OpenSSL Project for use in the OpenSSL Toolkit (http://www.openssl.org/)"
THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
This product includes cryptographic software written by Eric Young (eay@cryptsoft.com). This product includes software written by Tim Hudson (tjh@cryptsoft.com).
Eric Young (SSLeay)
/* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com)
All rights reserved.
This package is an SSL implementation written by Eric Young (eay@cryptsoft.com).
The implementation was written so as to conform with Netscapes SSL. This library is free for commercial and non-commercial use as long as the following conditions are aheared to. The following conditions apply to all code found in this distribution, be it the RC4, RSA, lhash, DES, etc., code; not just the SSL code. The SSL documentation included with this distribution is covered by the same copyright terms except that the holder is Tim Hudson (tjh@cryptsoft.com).
Copyright remains Eric Young's, and as such any Copyright notices in the code are not to be removed. If this package is used in a product, Eric Young should be given attribution as the author of the parts of the library used. This can be in the form of a textual message at program startup or in documentation (online or textual) provided with the package.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. All advertising materials mentioning features or use of this software must display the following acknowledgement:
"This product includes cryptographic software written by Eric Young (eay@cryptsoft.com)"
The word 'cryptographic' can be left out if the rouines from the library being used are not cryptographic related :-).
4. If you include any Windows specific code (or a derivative thereof) from the apps directory (application code) you must include an acknowledgement:
"This product includes software written by Tim Hudson (tjh@cryptsoft.com)" THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The licence and distribution terms for any publically available version or derivative of this code cannot be changed. i.e. this code cannot simply be copied and put under another distribution licence [including the GNU Public Licence.]
Toni Ronkko (dirent)
Copyright (c) 1998-2019 Toni Ronkko
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Microsoft Corporation (C++ REST SDK)
Copyright (c) Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Kutuzov Viktor (mman-win32)
Copyright (c) Kutuzov Viktor
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ICONS
Settings by i cons from the Noun Project
"""
var body: some View {
ScrollView {
Text(licenses)
.padding()
}
.navigationTitle("Software Licenses")
}
}
struct LicensesView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
LicensesView()
}
}
}

View File

@@ -0,0 +1,90 @@
//
// RefreshAttemptsView.swift
// SideStore
//
// Created by Fabian Thies on 04.02.23.
// Copyright © 2023 SideStore. All rights reserved.
//
import SwiftUI
import AltStoreCore
struct RefreshAttemptsView: View {
@SwiftUI.FetchRequest(sortDescriptors: [
NSSortDescriptor(keyPath: \RefreshAttempt.date, ascending: false)
])
var refreshAttempts: FetchedResults<RefreshAttempt>
var groupedRefreshAttempts: [Date: [RefreshAttempt]] {
Dictionary(grouping: refreshAttempts, by: { Calendar.current.startOfDay(for: $0.date) })
}
var body: some View {
List {
ForEach(groupedRefreshAttempts.keys.sorted(by: { $0 > $1 }), id: \.self) { date in
Section {
let attempts = groupedRefreshAttempts[date] ?? []
ForEach(attempts, id: \.date) { attempt in
VStack(alignment: .leading, spacing: 8) {
HStack {
if attempt.isSuccess {
Text("Success")
.bold()
.foregroundColor(.green)
} else {
Text("Failure")
.bold()
.foregroundColor(.red)
}
Spacer()
Text(DateFormatterHelper.timeString(for: attempt.date))
.font(.caption)
.foregroundColor(.secondary)
}
if let description = attempt.errorDescription {
Text(description)
}
}
}
} header: {
Text(DateFormatterHelper.string(for: date))
}
}
}
.background(self.listBackground)
.navigationTitle("Refresh Attempts")
}
@ViewBuilder
var listBackground: some View {
if self.refreshAttempts.isEmpty {
VStack(spacing: 8) {
Spacer()
Text("No Refresh Attempts")
.font(.title)
Text("The more you use SideStore, the more often iOS will allow it to refresh apps in the background.")
Spacer()
}
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.padding()
} else {
Color.clear
}
}
}
struct RefreshAttemptsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
RefreshAttemptsView()
}
}
}

View File

@@ -0,0 +1,374 @@
//
// SettingsView.swift
// SideStoreUI
//
// Created by Fabian Thies on 18.11.22.
// Copyright © 2022 Fabian Thies. All rights reserved.
//
import SwiftUI
import AsyncImage
import SFSafeSymbols
import LocalConsole
import AltStoreCore
import Intents
import minimuxer
struct SettingsView: View {
@ObservedObject private var iO = Inject.observer
var connectedAppleID: Team? {
DatabaseManager.shared.activeTeam()
}
@SwiftUI.FetchRequest(sortDescriptors: [], predicate: NSPredicate(format: "%K == YES", #keyPath(Team.isActiveTeam)))
var connectedTeams: FetchedResults<Team>
@AppStorage("isBackgroundRefreshEnabled")
var isBackgroundRefreshEnabled: Bool = true
@AppStorage("isDevModeEnabled")
var isDevModeEnabled: Bool = false
@AppStorage("isDebugLoggingEnabled")
var isDebugLoggingEnabled: Bool = false
@State var isShowingConnectAppleIDView = false
@State var isShowingResetPairingFileConfirmation = false
@State var isShowingDevModePrompt = false
@State var isShowingDevModeMenu = false
@State var externalURLToShow: URL?
@State var quickLookURL: URL?
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown Version"
var body: some View {
List {
Section {
if let connectedAppleID = connectedTeams.first {
HStack {
Text(L10n.SettingsView.ConnectedAppleID.name)
.foregroundColor(.secondary)
Spacer()
Text(connectedAppleID.name)
}
HStack {
Text(L10n.SettingsView.ConnectedAppleID.eMail)
.foregroundColor(.secondary)
Spacer()
Text(connectedAppleID.account.appleID)
}
HStack {
Text(L10n.SettingsView.ConnectedAppleID.type)
.foregroundColor(.secondary)
Spacer()
Text(connectedAppleID.type.localizedDescription)
}
} else {
SwiftUI.Button {
self.connectAppleID()
} label: {
Text(L10n.SettingsView.connectAppleID)
}
}
} header: {
if !connectedTeams.isEmpty {
HStack {
Text(L10n.SettingsView.ConnectedAppleID.text)
Spacer()
SwiftUI.Button {
self.disconnectAppleID()
} label: {
Text(L10n.SettingsView.ConnectedAppleID.signOut)
.font(.callout)
.bold()
}
}
}
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.SettingsView.ConnectedAppleID.Footer.p1)
Text(L10n.SettingsView.ConnectedAppleID.Footer.p2)
}
}
Section {
NavigationLink(L10n.AppIconsView.title) {
AppIconsView()
}
}
Section {
Toggle(isOn: self.$isBackgroundRefreshEnabled, label: {
Text(L10n.SettingsView.backgroundRefresh)
})
ModalNavigationLink(L10n.SettingsView.addToSiri) {
if let shortcut = INShortcut(intent: INInteraction.refreshAllApps().intent) {
SiriShortcutSetupView(shortcut: shortcut)
}
}
} header: {
Text(L10n.SettingsView.refreshingApps)
} footer: {
Text(L10n.SettingsView.refreshingAppsFooter)
}
Section {
SwiftUI.Button {
self.externalURLToShow = URL(string: "https://sidestore.io")!
} label: {
HStack {
Text("Developers")
.foregroundColor(.secondary)
Spacer()
Text("SideStore Team")
Image(systemSymbol: .chevronRight)
.foregroundColor(.secondary)
}
}
.foregroundColor(.primary)
SwiftUI.Button {
self.externalURLToShow = URL(string: "https://fabian-thies.de")!
} label: {
HStack {
Text(L10n.SettingsView.swiftUIRedesign)
.foregroundColor(.secondary)
Spacer()
Text("fabianthdev")
Image(systemSymbol: .chevronRight)
.foregroundColor(.secondary)
}
}
.foregroundColor(.primary)
NavigationLink {
LicensesView()
} label: {
Text("Licenses")
}
} header: {
Text(L10n.SettingsView.credits)
}
Section {
NavigationLink("Show Error Log") {
ErrorLogView()
}
NavigationLink("Show Refresh Attempts") {
RefreshAttemptsView()
}
NavigationLink(L10n.AdvancedSettingsView.title) {
AdvancedSettingsView()
}
Toggle(L10n.SettingsView.debugLogging, isOn: self.$isDebugLoggingEnabled)
.onChange(of: self.isDebugLoggingEnabled) { value in
UserDefaults.shared.isDebugLoggingEnabled = value
set_debug(value)
}
AsyncFallibleButton(action: self.exportLogs, label: { execute in Text(L10n.SettingsView.exportLogs) })
if MailComposeView.canSendMail {
ModalNavigationLink("Send Feedback") {
MailComposeView(recipients: ["support@sidestore.io"],
subject: "SideStore Beta \(appVersion) Feedback") {
NotificationManager.shared.showNotification(title: "Thank you for your feedback!")
} onError: { error in
NotificationManager.shared.reportError(error: error)
}
.ignoresSafeArea()
}
}
SwiftUI.Button(L10n.SettingsView.switchToUIKit, action: self.switchToUIKit)
SwiftUI.Button(L10n.SettingsView.resetImageCache, action: self.resetImageCache)
.foregroundColor(.red)
SwiftUI.Button("Reset Pairing File") {
self.isShowingResetPairingFileConfirmation = true
}
.foregroundColor(.red)
.actionSheet(isPresented: self.$isShowingResetPairingFileConfirmation) {
ActionSheet(title: Text("Are you sure to reset the pairing file?"), message: Text("You can reset the pairing file when you cannot sideload apps or enable JIT. SideStore will close when the file has been deleted."), buttons: [
.destructive(Text("Delete and Reset"), action: self.resetPairingFile),
.cancel()
])
}
if isDevModeEnabled {
NavigationLink(L10n.DevModeView.title, isActive: self.$isShowingDevModeMenu) {
DevModeMenu()
}.foregroundColor(.red)
} else {
SwiftUI.Button(L10n.DevModeView.title) {
self.isShowingDevModePrompt = true
}
.foregroundColor(.red)
.sheet(isPresented: self.$isShowingDevModePrompt) {
DevModePrompt(isShowingDevModePrompt: self.$isShowingDevModePrompt, isShowingDevModeMenu: self.$isShowingDevModeMenu)
}
}
} header: {
Text(L10n.SettingsView.debug)
}
Section {
} footer: {
Text("SideStore \(appVersion)")
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.listStyle(InsetGroupedListStyle())
.navigationTitle(L10n.SettingsView.title)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
SwiftUI.Button {
} label: {
Image(systemSymbol: .personCropCircle)
.imageScale(.large)
}
}
}
.sheet(item: $externalURLToShow) { url in
SafariView(url: url)
}
.quickLookPreview($quickLookURL)
.enableInjection()
}
// var appleIDSection: some View {
//
// }
func connectAppleID() {
guard let rootViewController = UIApplication.shared.keyWindow?.rootViewController else {
return
}
AppManager.shared.authenticate(presentingViewController: rootViewController) { (result) in
DispatchQueue.main.async {
switch result
{
case .failure(OperationError.cancelled):
// Ignore
break
case .failure(let error):
NotificationManager.shared.reportError(error: error)
case .success: break
}
}
}
}
func disconnectAppleID() {
DatabaseManager.shared.signOut { (error) in
DispatchQueue.main.async {
if let error = error
{
NotificationManager.shared.reportError(error: error)
}
}
}
}
func switchToUIKit() {
let storyboard = UIStoryboard(name: "Main", bundle: .main)
let rootVC = storyboard.instantiateViewController(withIdentifier: "tabBarController") as! TabBarController
UIApplication.shared.keyWindow?.rootViewController = rootVC
}
func resetImageCache() {
do {
let url = try FileManager.default.url(
for: .cachesDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true)
try FileManager.default.removeItem(at: url.appendingPathComponent("com.zeu.cache", isDirectory: true))
} catch let error {
fatalError("\(error)")
}
}
func resetPairingFile() {
let filename = "ALTPairingFile.mobiledevicepairing"
let fileURL = FileManager.default.documentsDirectory.appendingPathComponent(filename)
// Delete the pairing file if it exists
if FileManager.default.fileExists(atPath: fileURL.path) {
do {
try FileManager.default.removeItem(at: fileURL)
print("Pairing file deleted successfully.")
} catch {
print("Failed to delete pairing file:", error)
}
}
// Close and exit SideStore
UIApplication.shared.perform(#selector(URLSessionTask.suspend))
DispatchQueue.main.asyncAfter(deadline: .now().advanced(by: .milliseconds(500))) {
exit(0)
}
}
func exportLogs() throws {
let path = FileManager.default.documentsDirectory.appendingPathComponent("sidestore.log")
var text = LCManager.shared.currentText
// TODO: add more potentially sensitive info to this array
var remove = [String]()
if let connectedAppleID = connectedTeams.first {
remove.append(connectedAppleID.name)
remove.append(connectedAppleID.account.appleID)
remove.append(connectedAppleID.account.firstName)
remove.append(connectedAppleID.account.lastName)
remove.append(connectedAppleID.account.localizedName)
remove.append(connectedAppleID.account.identifier)
remove.append(connectedAppleID.identifier)
}
if let udid = fetch_udid() {
remove.append(udid.toString())
}
for toRemove in remove {
text = text.replacingOccurrences(of: toRemove, with: "[removed]")
}
guard let data = text.data(using: .utf8) else { throw NSError(domain: "Failed to get data.", code: 2) }
try data.write(to: path)
quickLookURL = path
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
SettingsView()
}
}
}