diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 69672d6d..22da75f8 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ A809F69F2D04D7B300F0F0F3 /* libem_proxy_static.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A809F6942D04D71200F0F0F3 /* libem_proxy_static.a */; }; A809F6A82D04DA1900F0F0F3 /* minimuxer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A32D04DA1900F0F0F3 /* minimuxer.swift */; }; A809F6A92D04DA1900F0F0F3 /* SwiftBridgeCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */; }; + A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */; }; + A80D790F2D2F217000A40F40 /* PaginationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */; }; A82067842D03DC0600645C0D /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; }; A82067C42D03E0DE00645C0D /* SemanticVersion in Frameworks */ = {isa = PBXBuildFile; productRef = A82067C32D03E0DE00645C0D /* SemanticVersion */; }; A859ED5C2D1EE827003DCC58 /* OpenSSL.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */; }; @@ -624,6 +626,8 @@ A809F6A52D04DA1900F0F0F3 /* minimuxer-helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "minimuxer-helpers.swift"; sourceTree = ""; }; A809F6A62D04DA1900F0F0F3 /* SwiftBridgeCore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SwiftBridgeCore.h; sourceTree = ""; }; A809F6A72D04DA1900F0F0F3 /* SwiftBridgeCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBridgeCore.swift; sourceTree = ""; }; + A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationIntent.swift; sourceTree = ""; }; + A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationViewModel.swift; sourceTree = ""; }; A859ED5B2D1EE80D003DCC58 /* OpenSSL.xcframework */ = {isa = PBXFileReference; expectedSignature = "AppleDeveloperProgram:67RAULRX93:Marcin Krzyzanowski"; lastKnownFileType = wrapper.xcframework; name = OpenSSL.xcframework; path = SideStore/AltSign/Dependencies/OpenSSL/Frameworks/OpenSSL.xcframework; sourceTree = ""; }; A85ACB8E2D1F31C400AA3DE7 /* AltBackup.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltBackup.xcconfig; sourceTree = ""; }; A85ACB8F2D1F31C400AA3DE7 /* AltStore.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AltStore.debug.xcconfig; sourceTree = ""; }; @@ -1161,6 +1165,14 @@ name = Products; sourceTree = ""; }; + A80D790B2D2F209700A40F40 /* Intents */ = { + isa = PBXGroup; + children = ( + A80D790C2D2F20AF00A40F40 /* PaginationIntent.swift */, + ); + path = Intents; + sourceTree = ""; + }; A85ACB942D1F31C400AA3DE7 /* xcconfigs */ = { isa = PBXGroup; children = ( @@ -1693,6 +1705,7 @@ BF98916C250AABF3002ACF50 /* AltWidget */ = { isa = PBXGroup; children = ( + A80D790B2D2F209700A40F40 /* Intents */, A800F6FE2CE28DE300208744 /* Extensions */, BF8B17F0250AC62400F8157F /* AltWidgetExtension.entitlements */, D5FD4EC42A952EAD0097BEE8 /* AltWidgetBundle.swift */, @@ -2067,6 +2080,7 @@ D50C29F22A8ECD71009AB488 /* Widgets */ = { isa = PBXGroup; children = ( + A80D790E2D2F217000A40F40 /* PaginationViewModel.swift */, D577AB7E2A96878A007FE952 /* AppDetailWidget.swift */, D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */, BF98917D250AAC4F002ACF50 /* LockScreenWidget.swift */, @@ -2898,12 +2912,14 @@ D577AB7F2A96878A007FE952 /* AppDetailWidget.swift in Sources */, BF98917E250AAC4F002ACF50 /* Countdown.swift in Sources */, D5151BE22A90363300C96F28 /* RefreshAllAppsWidgetIntent.swift in Sources */, + A80D790D2D2F20AF00A40F40 /* PaginationIntent.swift in Sources */, D5FD4EC92A9530C00097BEE8 /* AppSnapshot.swift in Sources */, D5151BE72A90395400C96F28 /* View+AltWidget.swift in Sources */, BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */, A800F7042CE28E3800208744 /* View+AltWidget.swift in Sources */, BF98917F250AAC4F002ACF50 /* LockScreenWidget.swift in Sources */, D5FD4EC52A952EAD0097BEE8 /* AltWidgetBundle.swift in Sources */, + A80D790F2D2F217000A40F40 /* PaginationViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AltWidget/AppsTimelineProvider.swift b/AltWidget/AppsTimelineProvider.swift index 08cf3dd7..5f8f0a1a 100644 --- a/AltWidget/AppsTimelineProvider.swift +++ b/AltWidget/AppsTimelineProvider.swift @@ -24,6 +24,29 @@ struct AppsTimelineProvider { typealias Entry = AppsEntry + private var viewModel: PaginationViewModel? + private let widgetID: String? + + init(_ viewModel: PaginationViewModel? = nil){ + self.viewModel = viewModel + self.widgetID = viewModel?.widgetID + } + + private func reloadEntries(apps: [AppSnapshot]){ + guard let viewModel = self.viewModel else { return } + + let entries = Set(viewModel.backup_entries.map{ (app: AppSnapshot) in app.bundleIdentifier }) + let app_entries = Set(apps.map{ (app: AppSnapshot) in app.bundleIdentifier }) + + // this updates the in-memory entries + if entries.isEmpty || entries != app_entries{ + self.viewModel?.setEntries(apps) + // initialize the view + self.viewModel?.handlePagination(.up) + } + } + + func placeholder(in context: TimelineProviderContext) -> AppsEntry { return AppsEntry(date: Date(), apps: [], isPlaceholder: true) @@ -37,6 +60,9 @@ struct AppsTimelineProvider let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) + // send this for pagination + reloadEntries(apps: apps) + let entry = AppsEntry(date: Date(), apps: apps) return entry } @@ -56,7 +82,10 @@ struct AppsTimelineProvider try await self.prepare() let apps = try await self.fetchApps(withBundleIDs: appBundleIDs) - + + // send this for pagination + reloadEntries(apps: apps) + let entries = self.makeEntries(for: apps) let timeline = Timeline(entries: entries, policy: .atEnd) return timeline @@ -203,6 +232,7 @@ extension AppsTimelineProvider: TimelineProvider extension AppsTimelineProvider: IntentTimelineProvider { + typealias Intent = ViewAppIntent func getSnapshot(for intent: Intent, in context: Context, completion: @escaping (AppsEntry) -> Void) diff --git a/AltWidget/Extensions/View+AltWidget.swift b/AltWidget/Extensions/View+AltWidget.swift index 74dc4231..7bdd0cbd 100644 --- a/AltWidget/Extensions/View+AltWidget.swift +++ b/AltWidget/Extensions/View+AltWidget.swift @@ -53,4 +53,29 @@ extension View self } } + + @ViewBuilder + func pageUpButton(widgetID: String) -> some View { + if #available(iOSApplicationExtension 17, *) { + Button(intent: PaginationIntent(.up, widgetID)){ + self + } + .buttonStyle(.plain) + } else { + self + } + } + + @ViewBuilder + func pageDownButton(widgetID: String) -> some View { + if #available(iOSApplicationExtension 17, *) { + Button(intent: PaginationIntent(.down, widgetID)){ + self + } + .buttonStyle(.plain) + } else { + self + } + } + } diff --git a/AltWidget/Intents/PaginationIntent.swift b/AltWidget/Intents/PaginationIntent.swift new file mode 100644 index 00000000..733c84ff --- /dev/null +++ b/AltWidget/Intents/PaginationIntent.swift @@ -0,0 +1,46 @@ +// +// PaginationIntent.swift +// AltStore +// +// Created by Magesh K on 08/01/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import AppIntents +import WidgetKit + +public enum Direction: String{ + case up = "up" + case down = "down" +} + +@available(iOS 17, *) +struct PaginationIntent: AppIntent, @unchecked Sendable { + static var title: LocalizedStringResource { "Scroll up or down in Active Apps Widget" } + static var isDiscoverable: Bool { false } + + @Parameter(title: "Direction") + var direction: String + + @Parameter(title: "Widget Identifier") + var widgetID: String + + init(){} + + init(_ direction: Direction, _ widgetID: String){ + self.direction = direction.rawValue + self.widgetID = widgetID + } + + func perform() async throws -> some IntentResult { + let direction = Direction(rawValue: direction)! + guard let viewModel = PaginationViewModel.instance(widgetID) else{ + print("viewModel for widgetID: \(widgetID) not found, ignoring request") + return .result() + } + viewModel.handlePagination(direction) + WidgetCenter.shared.reloadTimelines(ofKind: viewModel.widgetID) + return .result() + } +} + diff --git a/AltWidget/Widgets/ActiveAppsWidget.swift b/AltWidget/Widgets/ActiveAppsWidget.swift index c24214e3..f41dbf1a 100644 --- a/AltWidget/Widgets/ActiveAppsWidget.swift +++ b/AltWidget/Widgets/ActiveAppsWidget.swift @@ -24,17 +24,25 @@ private extension Color //@available(iOS 17, *) struct ActiveAppsWidget: Widget { - private let kind: String = "ActiveApps" + private var viewModel = PaginationViewModel.getNewInstance( + "ActiveApps" + UUID().uuidString + ) public var body: some WidgetConfiguration { if #available(iOS 17, *) { - return StaticConfiguration(kind: kind, provider: AppsTimelineProvider()) { entry in - ActiveAppsWidgetView(entry: entry) + let staticConfig = StaticConfiguration( + kind: viewModel.widgetID, + provider: AppsTimelineProvider(viewModel) + ) { entry in + ActiveAppsWidgetView(entry: entry, viewModel: viewModel) } .supportedFamilies([.systemMedium]) .configurationDisplayName("Active Apps") .description("View remaining days until your active apps expire. Tap the countdown timers to refresh them in the background.") + + // this widgetConfiguration is requested/drawn once per widget per process lifecycle + return staticConfig } else { @@ -50,9 +58,16 @@ private struct ActiveAppsWidgetView: View { var entry: AppsEntry + @ObservedObject private var viewModel: PaginationViewModel + @Environment(\.colorScheme) private var colorScheme + init(entry: AppsEntry, viewModel: PaginationViewModel){ + self.entry = entry + self.viewModel = viewModel + } + var body: some View { Group { if entry.apps.isEmpty @@ -80,16 +95,20 @@ private struct ActiveAppsWidgetView: View 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 MAX_ROWS_PER_PAGE = PaginationViewModel.MAX_ROWS_PER_PAGE + + let preferredRowHeight = (geometry.size.height / Double(MAX_ROWS_PER_PAGE)) - 8 let rowHeight = min(preferredRowHeight, geometry.size.height / 2) - ZStack(alignment: .center) { - VStack(spacing: 12) { - ForEach(entry.apps, id: \.bundleIdentifier) { app in + HStack(alignment: .center) { + +// VStack(spacing: 12) { + LazyVStack(spacing: 12) { + ForEach($viewModel.sliding_window, id: \.bundleIdentifier) { app in + let app = app.wrappedValue // remove the binding + + let icon: UIImage = app.icon ?? UIImage(named: "SideStore")! - let icon = app.icon ?? UIImage(named: "SideStore")! - // 1024x1024 images are not supported by previews but supported by device // so we scale the image to 97% so as to reduce its actual size but not too much // to somewhere below value, acceptable by previews ie < 1042x948 @@ -99,9 +118,9 @@ private struct ActiveAppsWidgetView: View width: icon.size.width * scalingFactor, height: icon.size.height * scalingFactor ) - + let resizedIcon = icon.resizing(to: resizedSize)! - + let daysRemaining = app.expirationDate.numberOfCalendarDays(since: entry.date) let cornerRadius = rowHeight / 5.0 @@ -142,12 +161,33 @@ private struct ActiveAppsWidgetView: View } .font(.system(size: 16, weight: .semibold, design: .rounded)) .invalidatableContent() - .padding(.horizontal, 8) .activatesRefreshAllAppsIntent() } .frame(height: rowHeight) } } + + Spacer(minLength: 16) + + let buttonWidth: CGFloat = 16 + VStack { + Image(systemName: "arrow.up") + .resizable() + .frame(width: buttonWidth, height: buttonWidth) + .mask(Capsule()) + .opacity(0.3) + .pageUpButton(widgetID: viewModel.widgetID) + + Spacer() + + Image(systemName: "arrow.down") + .resizable() + .frame(width: buttonWidth, height: buttonWidth) + .opacity(0.3) + .mask(Capsule()) + .pageDownButton(widgetID: viewModel.widgetID) + } + .padding(.vertical) } .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/AltWidget/Widgets/LockScreenWidget.swift b/AltWidget/Widgets/LockScreenWidget.swift index 5cd35901..b1ca5866 100644 --- a/AltWidget/Widgets/LockScreenWidget.swift +++ b/AltWidget/Widgets/LockScreenWidget.swift @@ -82,6 +82,7 @@ private struct ComplicationView: View let progress = Double(daysRemaining) / Double(totalDays) + // TODO: Gauge initialized with an out-of-bounds progress amount. The amount will be clamped to the nearest bound. Gauge(value: progress) { if daysRemaining < 0 { diff --git a/AltWidget/Widgets/PaginationViewModel.swift b/AltWidget/Widgets/PaginationViewModel.swift new file mode 100644 index 00000000..32f7b0a7 --- /dev/null +++ b/AltWidget/Widgets/PaginationViewModel.swift @@ -0,0 +1,105 @@ +// +// PaginationViewModel.swift +// AltStore +// +// Created by Magesh K on 09/01/25. +// Copyright © 2025 SideStore. All rights reserved. +// + +import Foundation +import Combine + +class PaginationViewModel: ObservableObject { + + private static var instances: [String:PaginationViewModel] = [:] + + public static let MAX_ROWS_PER_PAGE = 3 + + @Published public var sliding_window: [AppSnapshot] = [] + @Published public var refreshed: Bool = false + + private init(){} + + private var _widgetID: String? + public lazy var widgetID: String = { + return _widgetID! + }() + + public static func getNewInstance(_ widgetID: String) -> PaginationViewModel { + let instance = PaginationViewModel() + PaginationViewModel.instances[widgetID] = instance + instance._widgetID = widgetID + return instance + } + + public static func instance(_ widgetID: String) -> PaginationViewModel? { + return PaginationViewModel.instances[widgetID] + } + + public private(set) var backup_entries: [AppSnapshot] = [] + + private var r_queue: [AppSnapshot] = [] + private var l_queue: [AppSnapshot] = [] + + private var lastIndex: Int { r_queue.count - 1 } + + public func setEntries(_ entries: [AppSnapshot]) { + r_queue = entries + backup_entries = entries + } + + public func handlePagination(_ direction: Direction) { + + var sliding_window = Array(sliding_window) + var l_queue = Array(l_queue) + var r_queue = Array(r_queue) + + // If entries is empty, do nothing + guard !backup_entries.isEmpty else { + sliding_window.removeAll() + return + } + + switch direction { + case .up: + // move window contents to left-q since we are moving right side + if !sliding_window.isEmpty { + // take the window as-is and put it to right of l_queue + l_queue.append(contentsOf: sliding_window) + } + + // clear the window + sliding_window.removeAll() + + let size = min(r_queue.count, Self.MAX_ROWS_PER_PAGE) + for _ in 0..