mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-12 08:13:26 +01:00
Reorganize AltStore project into UIKit and SwiftUI folders
This commit is contained in:
454
AltStore/SwiftUI/Views/App Detail/AppDetailView.swift
Normal file
454
AltStore/SwiftUI/Views/App Detail/AppDetailView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
AltStore/SwiftUI/Views/App Detail/AppPermissionsGrid.swift
Normal file
56
AltStore/SwiftUI/Views/App Detail/AppPermissionsGrid.swift
Normal 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()
|
||||
// }
|
||||
//}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
102
AltStore/SwiftUI/Views/App Detail/WriteAppReviewView.swift
Normal file
102
AltStore/SwiftUI/Views/App Detail/WriteAppReviewView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
56
AltStore/SwiftUI/Views/Browse/AddSourceView.swift
Normal file
56
AltStore/SwiftUI/Views/Browse/AddSourceView.swift
Normal 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 })
|
||||
}
|
||||
}
|
||||
42
AltStore/SwiftUI/Views/Browse/BrowseAppPreviewView.swift
Normal file
42
AltStore/SwiftUI/Views/Browse/BrowseAppPreviewView.swift
Normal 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()
|
||||
// }
|
||||
//}
|
||||
165
AltStore/SwiftUI/Views/Browse/BrowseView.swift
Normal file
165
AltStore/SwiftUI/Views/Browse/BrowseView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
105
AltStore/SwiftUI/Views/Browse/ConfirmAddSourceView.swift
Normal file
105
AltStore/SwiftUI/Views/Browse/ConfirmAddSourceView.swift
Normal 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 })
|
||||
}
|
||||
}
|
||||
299
AltStore/SwiftUI/Views/Browse/SourcesView.swift
Normal file
299
AltStore/SwiftUI/Views/Browse/SourcesView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
54
AltStore/SwiftUI/Views/My Apps/AppAction.swift
Normal file
54
AltStore/SwiftUI/Views/My Apps/AppAction.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
119
AltStore/SwiftUI/Views/My Apps/AppIDsView.swift
Normal file
119
AltStore/SwiftUI/Views/My Apps/AppIDsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
437
AltStore/SwiftUI/Views/My Apps/MyAppsView.swift
Normal file
437
AltStore/SwiftUI/Views/My Apps/MyAppsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
16
AltStore/SwiftUI/Views/My Apps/MyAppsViewModel.swift
Normal file
16
AltStore/SwiftUI/Views/My Apps/MyAppsViewModel.swift
Normal 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?
|
||||
|
||||
}
|
||||
129
AltStore/SwiftUI/Views/News/NewsItemView.swift
Normal file
129
AltStore/SwiftUI/Views/News/NewsItemView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
97
AltStore/SwiftUI/Views/News/NewsView.swift
Normal file
97
AltStore/SwiftUI/Views/News/NewsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
22
AltStore/SwiftUI/Views/News/NewsViewModel.swift
Normal file
22
AltStore/SwiftUI/Views/News/NewsViewModel.swift
Normal 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() {}
|
||||
}
|
||||
94
AltStore/SwiftUI/Views/Onboarding/AppIconsShowcase.swift
Normal file
94
AltStore/SwiftUI/Views/Onboarding/AppIconsShowcase.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
86
AltStore/SwiftUI/Views/Onboarding/OnboardingStepView.swift
Normal file
86
AltStore/SwiftUI/Views/Onboarding/OnboardingStepView.swift
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
447
AltStore/SwiftUI/Views/Onboarding/OnboardingView.swift
Normal file
447
AltStore/SwiftUI/Views/Onboarding/OnboardingView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
AltStore/SwiftUI/Views/RootView.swift
Normal file
114
AltStore/SwiftUI/Views/RootView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
76
AltStore/SwiftUI/Views/Settings/AdvancedSettingsView.swift
Normal file
76
AltStore/SwiftUI/Views/Settings/AdvancedSettingsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
135
AltStore/SwiftUI/Views/Settings/AppIconsView.swift
Normal file
135
AltStore/SwiftUI/Views/Settings/AppIconsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
109
AltStore/SwiftUI/Views/Settings/ConnectAppleIDView.swift
Normal file
109
AltStore/SwiftUI/Views/Settings/ConnectAppleIDView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
190
AltStore/SwiftUI/Views/Settings/DevModeView.swift
Normal file
190
AltStore/SwiftUI/Views/Settings/DevModeView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
169
AltStore/SwiftUI/Views/Settings/ErrorLogView.swift
Normal file
169
AltStore/SwiftUI/Views/Settings/ErrorLogView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
112
AltStore/SwiftUI/Views/Settings/LicensesView.swift
Normal file
112
AltStore/SwiftUI/Views/Settings/LicensesView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
90
AltStore/SwiftUI/Views/Settings/RefreshAttemptsView.swift
Normal file
90
AltStore/SwiftUI/Views/Settings/RefreshAttemptsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
374
AltStore/SwiftUI/Views/Settings/SettingsView.swift
Normal file
374
AltStore/SwiftUI/Views/Settings/SettingsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user