diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 26957f4c..153b27f4 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -365,12 +365,14 @@ D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; }; D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.swift */; }; D55467B82A8D5E2600F4CE90 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */; }; + D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */; }; D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; }; D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; D5708417292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; D570841A2924680D00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */; }; D5728CA72A0D79D30014E73C /* OptionalProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5728CA62A0D79D30014E73C /* OptionalProtocol.swift */; }; + D577AB7B2A967DF5007FE952 /* AppsTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D577AB7A2A967DF5007FE952 /* AppsTimelineProvider.swift */; }; D577AB7F2A96878A007FE952 /* AppDetailWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D577AB7E2A96878A007FE952 /* AppDetailWidget.swift */; }; D57968CB29CB99EF00539069 /* VibrantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D57968CA29CB99EF00539069 /* VibrantButton.swift */; }; D57DF638271E32F000677701 /* PatchApp.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D57DF637271E32F000677701 /* PatchApp.storyboard */; }; @@ -412,8 +414,10 @@ D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */; }; D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */; }; D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */; }; + D5FB7A0E2AA25A4E00EF863D /* Previews.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5FB7A0D2AA25A4E00EF863D /* Previews.xcassets */; }; D5FD4EC52A952EAD0097BEE8 /* AltWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FD4EC42A952EAD0097BEE8 /* AltWidgetBundle.swift */; }; D5FD4EC92A9530C00097BEE8 /* AppSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FD4EC82A9530C00097BEE8 /* AppSnapshot.swift */; }; + D5FD4ECB2A9532960097BEE8 /* DatabaseManager+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FD4ECA2A9532960097BEE8 /* DatabaseManager+Async.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -939,10 +943,12 @@ D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = ""; }; D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = ""; }; + D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; D571ADCD2A02FA7400B24B63 /* SourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceError.swift; sourceTree = ""; }; D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTAppPermission.swift; sourceTree = ""; }; D5728CA62A0D79D30014E73C /* OptionalProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalProtocol.swift; sourceTree = ""; }; + D577AB7A2A967DF5007FE952 /* AppsTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsTimelineProvider.swift; sourceTree = ""; }; D577AB7E2A96878A007FE952 /* AppDetailWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailWidget.swift; sourceTree = ""; }; D57968CA29CB99EF00539069 /* VibrantButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VibrantButton.swift; sourceTree = ""; }; D57DF637271E32F000677701 /* PatchApp.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = PatchApp.storyboard; sourceTree = ""; }; @@ -986,8 +992,10 @@ D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = ""; }; D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = ""; }; + D5FB7A0D2AA25A4E00EF863D /* Previews.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Previews.xcassets; sourceTree = ""; }; D5FD4EC42A952EAD0097BEE8 /* AltWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltWidgetBundle.swift; sourceTree = ""; }; D5FD4EC82A9530C00097BEE8 /* AppSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSnapshot.swift; sourceTree = ""; }; + D5FD4ECA2A9532960097BEE8 /* DatabaseManager+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DatabaseManager+Async.swift"; sourceTree = ""; }; EA79A60285C6AF5848AA16E9 /* Pods-AltStore.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AltStore.debug.xcconfig"; path = "Target Support Files/Pods-AltStore/Pods-AltStore.debug.xcconfig"; sourceTree = ""; }; FC3822AB1C4CF1D4CDF7445D /* Pods_AltServer.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AltServer.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -1507,6 +1515,7 @@ BF66EEC62501AECA007EE018 /* AppPermission.swift */, D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */, BF66EECA2501AECA007EE018 /* DatabaseManager.swift */, + D5FD4ECA2A9532960097BEE8 /* DatabaseManager+Async.swift */, BF66EEC02501AECA007EE018 /* InstalledApp.swift */, BF66EECB2501AECA007EE018 /* InstalledExtension.swift */, D58916FD28C7C55C00E39C8B /* LoggedError.swift */, @@ -1618,11 +1627,13 @@ BF8B17F0250AC62400F8157F /* AltWidgetExtension.entitlements */, D5FD4EC42A952EAD0097BEE8 /* AltWidgetBundle.swift */, D586222D2AA115D000A493E1 /* Provider.swift */, + D577AB7A2A967DF5007FE952 /* AppsTimelineProvider.swift */, D50C29F22A8ECD71009AB488 /* Widgets */, D51AF9752A97D29100471312 /* Model */, D577AB802A968B7E007FE952 /* Components */, D5151BE42A9038FA00C96F28 /* Extensions */, BF989170250AABF4002ACF50 /* Assets.xcassets */, + D5FB7A0D2AA25A4E00EF863D /* Previews.xcassets */, BF989172250AABF4002ACF50 /* Info.plist */, ); path = AltWidget; @@ -1971,6 +1982,7 @@ isa = PBXGroup; children = ( D577AB7E2A96878A007FE952 /* AppDetailWidget.swift */, + D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */, BF98917D250AAC4F002ACF50 /* LockScreenWidget.swift */, ); path = Widgets; @@ -2487,6 +2499,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D5FB7A0E2AA25A4E00EF863D /* Previews.xcassets in Resources */, BF989171250AABF4002ACF50 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -2748,6 +2761,7 @@ D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */, BFBF331B2526762200B7B8C9 /* AltStore8ToAltStore9.xcmappingmodel in Sources */, 0EE7FDC72BE8CF4100D1E390 /* ALTWrappedError.m in Sources */, + D5FD4ECB2A9532960097BEE8 /* DatabaseManager+Async.swift in Sources */, D5893F802A1419E800E767CD /* NSManagedObjectContext+Conveniences.swift in Sources */, D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */, D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */, @@ -2800,6 +2814,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */, + D577AB7B2A967DF5007FE952 /* AppsTimelineProvider.swift in Sources */, D577AB7F2A96878A007FE952 /* AppDetailWidget.swift in Sources */, BF98917E250AAC4F002ACF50 /* Countdown.swift in Sources */, D5151BE22A90363300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */, @@ -3408,6 +3424,8 @@ DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_DEBUG_DYLIB = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_ASSET_PATHS = AltWidget/Previews.xcassets; INFOPLIST_FILE = AltWidget/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -3439,6 +3457,8 @@ DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; ENABLE_DEBUG_DYLIB = NO; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_ASSET_PATHS = AltWidget/Previews.xcassets; INFOPLIST_FILE = AltWidget/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/AltStoreCore/Extensions/UIColor+AltStore.swift b/AltStoreCore/Extensions/UIColor+AltStore.swift index 74af4c5d..0d82756c 100644 --- a/AltStoreCore/Extensions/UIColor+AltStore.swift +++ b/AltStoreCore/Extensions/UIColor+AltStore.swift @@ -14,8 +14,7 @@ public extension UIColor static let altPrimary = UIColor(named: "Primary", in: colorBundle, compatibleWith: nil)! static let deltaPrimary = UIColor(named: "DeltaPrimary", in: colorBundle, compatibleWith: nil) - - static let altPink = UIColor(named: "Pink", in: colorBundle, compatibleWith: nil)! + static let clipPrimary = UIColor(named: "ClipPrimary", in: colorBundle, compatibleWith: nil) static let refreshRed = UIColor(named: "RefreshRed", in: colorBundle, compatibleWith: nil)! static let refreshOrange = UIColor(named: "RefreshOrange", in: colorBundle, compatibleWith: nil)! diff --git a/AltStoreCore/Model/DatabaseManager+Async.swift b/AltStoreCore/Model/DatabaseManager+Async.swift new file mode 100644 index 00000000..f7cedd4e --- /dev/null +++ b/AltStoreCore/Model/DatabaseManager+Async.swift @@ -0,0 +1,28 @@ +// +// DatabaseManager+Async.swift +// AltStoreCore +// +// Created by Riley Testut on 8/22/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation + +public extension DatabaseManager +{ + func start() async throws + { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + self.start { error in + if let error + { + continuation.resume(throwing: error) + } + else + { + continuation.resume() + } + } + } + } +} diff --git a/AltStoreCore/Resources/Colors.xcassets/Pink.colorset/Contents.json b/AltStoreCore/Resources/Colors.xcassets/ClipPrimary.colorset/Contents.json similarity index 74% rename from AltStoreCore/Resources/Colors.xcassets/Pink.colorset/Contents.json rename to AltStoreCore/Resources/Colors.xcassets/ClipPrimary.colorset/Contents.json index 4d3bc04a..e350f753 100644 --- a/AltStoreCore/Resources/Colors.xcassets/Pink.colorset/Contents.json +++ b/AltStoreCore/Resources/Colors.xcassets/ClipPrimary.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.698", - "green" : "0.255", - "red" : "0.925" + "blue" : "140", + "green" : "0", + "red" : "236" } }, "idiom" : "universal" diff --git a/AltWidget/AltWidgetBundle.swift b/AltWidget/AltWidgetBundle.swift index a3c36462..b8446a86 100644 --- a/AltWidget/AltWidgetBundle.swift +++ b/AltWidget/AltWidgetBundle.swift @@ -17,5 +17,10 @@ struct AltWidgetBundle: WidgetBundle IconLockScreenWidget() TextLockScreenWidget() + + if #available(iOS 17, *) + { + ActiveAppsWidget() + } } } diff --git a/AltWidget/AppsTimelineProvider.swift b/AltWidget/AppsTimelineProvider.swift new file mode 100644 index 00000000..398182b7 --- /dev/null +++ b/AltWidget/AppsTimelineProvider.swift @@ -0,0 +1,202 @@ +// +// AppsTimelineProvider.swift +// AltWidgetExtension +// +// Created by Riley Testut on 8/23/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import WidgetKit +import CoreData + +import AltStoreCore + +struct AppsEntry: TimelineEntry +{ + var date: Date + var relevance: TimelineEntryRelevance? + + var apps: [AppSnapshot] + var isPlaceholder: Bool = false +} + +struct AppsTimelineProvider +{ + typealias Entry = AppsEntry + + func placeholder(in context: TimelineProviderContext) -> AppsEntry + { + return AppsEntry(date: Date(), apps: [], isPlaceholder: true) + } + + func snapshot(for appBundleIDs: [String]) async -> AppsEntry + { + do + { + try await self.prepare() + + let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) + + let entry = AppsEntry(date: Date(), apps: apps) + return entry + } + catch + { + print("Failed to prepare widget snapshot:", error) + + let entry = AppsEntry(date: Date(), apps: []) + return entry + } + } + + func timeline(for appBundleIDs: [String]) async -> Timeline + { + do + { + try await self.prepare() + + let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) + + let entries = self.makeEntries(for: apps) + let timeline = Timeline(entries: entries, policy: .atEnd) + return timeline + } + catch + { + print("Failed to prepare widget timeline:", error) + + let entry = AppsEntry(date: Date(), apps: []) + let timeline = Timeline(entries: [entry], policy: .atEnd) + return timeline + } + } +} + +private extension AppsTimelineProvider +{ + func prepare() async throws + { + try await DatabaseManager.shared.start() + } + + func fetchApps(withBundleIDs bundleIDs: [String]) async throws -> [AppSnapshot] + { + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + let apps = try await context.performAsync { + let fetchRequest = InstalledApp.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(InstalledApp.bundleIdentifier), bundleIDs) + fetchRequest.returnsObjectsAsFaults = false + + let installedApps = try context.fetch(fetchRequest) + + let apps = installedApps.map { AppSnapshot(installedApp: $0) } + + // Always list apps in alphabetical order. + let sortedApps = apps.sorted { $0.name < $1.name } + return sortedApps + } + + return apps + } + + func makeEntries(for snapshots: [AppSnapshot]) -> [AppsEntry] + { + let sortedAppsByExpirationDate = snapshots.sorted { $0.expirationDate < $1.expirationDate } + guard let firstExpiringApp = sortedAppsByExpirationDate.first, let lastExpiringApp = sortedAppsByExpirationDate.last else { return [] } + + let currentDate = Calendar.current.startOfDay(for: Date()) + let numberOfDays = lastExpiringApp.expirationDate.numberOfCalendarDays(since: currentDate) + + // Generate a timeline consisting of one entry per day. + var entries: [AppsEntry] = [] + + switch numberOfDays + { + case ..<0: + let entry = AppsEntry(date: currentDate, relevance: TimelineEntryRelevance(score: 0.0), apps: snapshots) + entries.append(entry) + + case 0: + let entry = AppsEntry(date: currentDate, relevance: TimelineEntryRelevance(score: 1.0), apps: snapshots) + entries.append(entry) + + default: + // To reduce memory consumption, we only generate entries for the next week. This includes: + // * 1 for each day the "least expired" app is valid (up to 7) + // * 1 "0 days remaining" + // * 1 "Expired" + + let numberOfEntries = min(numberOfDays, 7) + 2 + + let appEntries = (0 ..< numberOfEntries).map { (dayOffset) -> AppsEntry in + let entryDate = Calendar.current.date(byAdding: .day, value: dayOffset, to: currentDate) ?? currentDate.addingTimeInterval(Double(dayOffset) * 60 * 60 * 24) + + let daysSinceRefresh = entryDate.numberOfCalendarDays(since: firstExpiringApp.refreshedDate) + let totalNumberOfDays = firstExpiringApp.expirationDate.numberOfCalendarDays(since: firstExpiringApp.refreshedDate) + + var score = (entryDate <= firstExpiringApp.expirationDate) ? Float(daysSinceRefresh + 1) / Float(totalNumberOfDays + 1) : 1 // Expired apps have a score of 1. + if snapshots.allSatisfy({ $0.expirationDate > currentDate }) + { + // Unless ALL apps are expired, in which case relevance is 0. + score = 0 + } + + let entry = AppsEntry(date: entryDate, relevance: TimelineEntryRelevance(score: score), apps: snapshots) + return entry + } + + entries.append(contentsOf: appEntries) + } + + return entries + } +} + +extension AppsTimelineProvider: TimelineProvider +{ + func getSnapshot(in context: Context, completion: @escaping (AppsEntry) -> Void) + { + Task { + let bundleIDs = await self.fetchActiveAppBundleIDs() + + let snapshot = await self.snapshot(for: bundleIDs) + completion(snapshot) + } + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) + { + Task { + let bundleIDs = await self.fetchActiveAppBundleIDs() + + let timeline = await self.timeline(for: bundleIDs) + completion(timeline) + } + } + + private func fetchActiveAppBundleIDs() async -> [String] + { + do + { + try await self.prepare() + + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + let bundleIDs = try await context.performAsync { + let fetchRequest = InstalledApp.activeAppsFetchRequest() as! NSFetchRequest + fetchRequest.resultType = .dictionaryResultType + fetchRequest.propertiesToFetch = [#keyPath(InstalledApp.bundleIdentifier)] + + let bundleIDs = try context.fetch(fetchRequest).compactMap { $0[#keyPath(InstalledApp.bundleIdentifier)] as? String } + return bundleIDs + } + + return bundleIDs + } + catch + { + print("Failed to fetch active bundle IDs, falling back to AltStore bundle ID.", error) + + return [StoreApp.altstoreAppID] + } + } +} diff --git a/AltWidget/Components/Countdown.swift b/AltWidget/Components/Countdown.swift index 0bdf4106..65ca61bb 100644 --- a/AltWidget/Components/Countdown.swift +++ b/AltWidget/Components/Countdown.swift @@ -15,13 +15,15 @@ struct Countdown: View var endDate: Date? var currentDate: Date = Date() + var strokeWidth: Double = 4.0 + @Environment(\.font) private var font private var numberOfDays: Int { guard let date = self.endDate else { return 0 } let numberOfDays = date.numberOfCalendarDays(since: self.currentDate) - return numberOfDays + return max(numberOfDays, 0) // Never show negative values. } private var fractionComplete: CGFloat { @@ -35,7 +37,7 @@ struct Countdown: View @ViewBuilder private func overlay(progress: CGFloat) -> some View { - let strokeStyle = StrokeStyle(lineWidth: 4.0, lineCap: .round, lineJoin: .round) + let strokeStyle = StrokeStyle(lineWidth: self.strokeWidth, lineCap: .round, lineJoin: .round) if self.numberOfDays > 9 || self.numberOfDays < 0 { Capsule(style: .continuous) diff --git a/AltWidget/Model/AppSnapshot.swift b/AltWidget/Model/AppSnapshot.swift index 24842cda..7cd6a8ae 100644 --- a/AltWidget/Model/AppSnapshot.swift +++ b/AltWidget/Model/AppSnapshot.swift @@ -39,3 +39,51 @@ extension AppSnapshot self.icon = application?.icon?.resizing(toFill: CGSize(width: 180, height: 180)) } } + +extension AppSnapshot +{ + static func makePreviewSnapshots() -> (altstore: AppSnapshot, delta: AppSnapshot, clip: AppSnapshot, longAltStore: AppSnapshot, longDelta: AppSnapshot, longClip: AppSnapshot) + { + let shortRefreshedDate = Calendar.current.date(byAdding: .day, value: -2, to: Date()) ?? Date() + let shortExpirationDate = Calendar.current.date(byAdding: .day, value: 7, to: shortRefreshedDate) ?? Date() + + let longRefreshedDate = Calendar.current.date(byAdding: .day, value: -100, to: Date()) ?? Date() + let longExpirationDate = Calendar.current.date(byAdding: .day, value: 365, to: longRefreshedDate) ?? Date() + + let altstore = AppSnapshot(name: "AltStore", + bundleIdentifier: "com.rileytestut.AltStore", + expirationDate: shortExpirationDate, + refreshedDate: shortRefreshedDate, + tintColor: .altPrimary, + icon: UIImage(named: "AltStore")) + + let delta = AppSnapshot(name: "Delta", + bundleIdentifier: "com.rileytestut.Delta", + expirationDate: shortExpirationDate, + refreshedDate: shortRefreshedDate, + tintColor: .deltaPrimary, + icon: UIImage(named: "Delta")) + + let clip = AppSnapshot(name: "Clip", + bundleIdentifier: "com.rileytestut.Clip", + expirationDate: shortExpirationDate, + refreshedDate: shortRefreshedDate, + tintColor: .clipPrimary, + icon: UIImage(named: "Clip")) + + let longAltStore = altstore.with(refreshedDate: longRefreshedDate, expirationDate: longExpirationDate) + let longDelta = delta.with(refreshedDate: longRefreshedDate, expirationDate: longExpirationDate) + let longClip = clip.with(refreshedDate: longRefreshedDate, expirationDate: longExpirationDate) + + return (altstore, delta, clip, longAltStore, longDelta, longClip) + } + + private func with(refreshedDate: Date, expirationDate: Date) -> AppSnapshot + { + var app = self + app.refreshedDate = refreshedDate + app.expirationDate = expirationDate + + return app + } +} diff --git a/AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@2x.png b/AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@2x.png new file mode 100644 index 00000000..cd7060c3 Binary files /dev/null and b/AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@2x.png differ diff --git a/AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@3x.png b/AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@3x.png new file mode 100644 index 00000000..eac14e24 Binary files /dev/null and b/AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@3x.png differ diff --git a/AltWidget/Previews.xcassets/Clip.imageset/Contents.json b/AltWidget/Previews.xcassets/Clip.imageset/Contents.json new file mode 100644 index 00000000..8067994c --- /dev/null +++ b/AltWidget/Previews.xcassets/Clip.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ClipIcon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ClipIcon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AltWidget/Previews.xcassets/Contents.json b/AltWidget/Previews.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/AltWidget/Previews.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/AltWidget/Assets.xcassets/Delta.imageset/Contents.json b/AltWidget/Previews.xcassets/Delta.imageset/Contents.json similarity index 100% rename from AltWidget/Assets.xcassets/Delta.imageset/Contents.json rename to AltWidget/Previews.xcassets/Delta.imageset/Contents.json diff --git a/AltWidget/Assets.xcassets/Delta.imageset/icon-120.png b/AltWidget/Previews.xcassets/Delta.imageset/icon-120.png similarity index 100% rename from AltWidget/Assets.xcassets/Delta.imageset/icon-120.png rename to AltWidget/Previews.xcassets/Delta.imageset/icon-120.png diff --git a/AltWidget/Assets.xcassets/Delta.imageset/icon-180.png b/AltWidget/Previews.xcassets/Delta.imageset/icon-180.png similarity index 100% rename from AltWidget/Assets.xcassets/Delta.imageset/icon-180.png rename to AltWidget/Previews.xcassets/Delta.imageset/icon-180.png diff --git a/AltWidget/Widgets/ActiveAppsWidget.swift b/AltWidget/Widgets/ActiveAppsWidget.swift new file mode 100644 index 00000000..468f9522 --- /dev/null +++ b/AltWidget/Widgets/ActiveAppsWidget.swift @@ -0,0 +1,158 @@ +// +// HomeScreenWidget.swift +// AltWidgetExtension +// +// Created by Riley Testut on 8/16/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import SwiftUI +import WidgetKit +import CoreData + +import AltStoreCore +import AltSign + +private extension Color +{ + static let altGradientLight = Color.init(.displayP3, red: 123.0/255.0, green: 200.0/255.0, blue: 176.0/255.0) + static let altGradientDark = Color.init(.displayP3, red: 0.0/255.0, green: 128.0/255.0, blue: 132.0/255.0) + + static let altGradientExtraDark = Color.init(.displayP3, red: 2.0/255.0, green: 82.0/255.0, blue: 103.0/255.0) +} + +@available(iOS 17, *) +struct ActiveAppsWidget: Widget +{ + private let kind: String = "ActiveApps" + + public var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: AppsTimelineProvider()) { entry in + ActiveAppsWidgetView(entry: entry) + } + .supportedFamilies([.systemMedium]) + .configurationDisplayName("AltWidget") + .description("View remaining days until your active apps expire.") + } +} + +@available(iOS 17, *) +private struct ActiveAppsWidgetView: View +{ + var entry: AppsEntry + + @Environment(\.colorScheme) + private var colorScheme + + var body: some View { + Group { + if entry.apps.isEmpty + { + placeholder + } + else + { + content + } + } + .foregroundStyle(.white) + .containerBackground(for: .widget) { + if colorScheme == .dark + { + LinearGradient(colors: [.altGradientDark, .altGradientExtraDark], startPoint: .top, endPoint: .bottom) + } + else + { + LinearGradient(colors: [.altGradientLight, .altGradientDark], startPoint: .top, endPoint: .bottom) + } + } + } + + private var content: some View { + GeometryReader { (geometry) in + + let numberOfApps = max(entry.apps.count, 1) // Ensure we don't divide by 0 + let preferredRowHeight = (geometry.size.height / Double(numberOfApps)) - 8 + let rowHeight = min(preferredRowHeight, geometry.size.height / 2) + + ZStack(alignment: .center) { + VStack(spacing: 12) { + ForEach(entry.apps, id: \.bundleIdentifier) { app in + + let daysRemaining = app.expirationDate.numberOfCalendarDays(since: entry.date) + let cornerRadius = rowHeight / 5.0 + + HStack(spacing: 10) { + Image(uiImage: app.icon ?? UIImage(named: "AltStore")!) + .resizable() + .aspectRatio(contentMode: .fit) + .cornerRadius(cornerRadius) + + VStack(alignment: .leading, spacing: 1) { + Text(app.name) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + + let text = if entry.date > app.expirationDate + { + Text("Expired") + } + else + { + Text("Expires in \(daysRemaining) ") + (daysRemaining == 1 ? Text("day") : Text("days")) + } + + text + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + } + + HStack { + Spacer() + + Countdown(startDate: app.refreshedDate, + endDate: app.expirationDate, + currentDate: entry.date, + strokeWidth: 3.0) // Slightly thinner circle stroke width + .background { + Color.black.opacity(0.1) + .mask(Capsule()) + .padding(.all, -5) + } + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .invalidatableContent() + } + .activatesRefreshAllAppsIntent() + } + .frame(height: rowHeight) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private var placeholder: some View { + Text("App Not Found") + .font(.system(.body, design: .rounded)) + .fontWeight(.semibold) + .foregroundColor(Color.white.opacity(0.4)) + } +} + +#Preview(as: .systemMedium) { + ActiveAppsWidget() +} timeline: { + let expiredDate = Date().addingTimeInterval(1 * 60 * 60 * 24 * 7) + let (altstore, delta, clip, longAltStore, longDelta, longClip) = AppSnapshot.makePreviewSnapshots() + + AppsEntry(date: Date(), apps: [altstore, delta, clip]) + AppsEntry(date: Date(), apps: [longAltStore, longDelta, longClip]) + + AppsEntry(date: expiredDate, apps: [altstore, delta, clip]) + + AppsEntry(date: Date(), apps: [altstore, delta]) + AppsEntry(date: Date(), apps: [altstore]) + + AppsEntry(date: Date(), apps: []) + AppsEntry(date: Date(), apps: [], isPlaceholder: true) +}