mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-08 22:33:26 +01:00
[AltWidget] Adds interactive Active Apps widget to view + refresh all active apps (iOS 17+)
This commit is contained in:
@@ -17,5 +17,10 @@ struct AltWidgetBundle: WidgetBundle
|
||||
|
||||
IconLockScreenWidget()
|
||||
TextLockScreenWidget()
|
||||
|
||||
if #available(iOS 17, *)
|
||||
{
|
||||
ActiveAppsWidget()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
202
AltWidget/AppsTimelineProvider.swift
Normal file
202
AltWidget/AppsTimelineProvider.swift
Normal file
@@ -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<AppsEntry>
|
||||
{
|
||||
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<Void, Never> {
|
||||
let bundleIDs = await self.fetchActiveAppBundleIDs()
|
||||
|
||||
let snapshot = await self.snapshot(for: bundleIDs)
|
||||
completion(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<AppsEntry>) -> Void)
|
||||
{
|
||||
Task<Void, Never> {
|
||||
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<NSDictionary>
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
BIN
AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@2x.png
vendored
Normal file
BIN
AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@3x.png
vendored
Normal file
BIN
AltWidget/Previews.xcassets/Clip.imageset/ClipIcon@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
22
AltWidget/Previews.xcassets/Clip.imageset/Contents.json
vendored
Normal file
22
AltWidget/Previews.xcassets/Clip.imageset/Contents.json
vendored
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
6
AltWidget/Previews.xcassets/Contents.json
Normal file
6
AltWidget/Previews.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.0 KiB |
158
AltWidget/Widgets/ActiveAppsWidget.swift
Normal file
158
AltWidget/Widgets/ActiveAppsWidget.swift
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user