mirror of
https://github.com/SideStore/SideStore.git
synced 2026-05-11 20:05:40 +02:00
Compare commits
2 Commits
9223da751d
...
a0d3c5ed4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0d3c5ed4a | ||
|
|
0d8a2df802 |
@@ -242,6 +242,7 @@
|
|||||||
<key>public.filename-extension</key>
|
<key>public.filename-extension</key>
|
||||||
<array>
|
<array>
|
||||||
<string>mobiledevicepairing</string>
|
<string>mobiledevicepairing</string>
|
||||||
|
<string>mobiledevicepair</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
extension View
|
extension View
|
||||||
{
|
{
|
||||||
@@ -78,4 +79,59 @@ extension View
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opts this view into the widget accent group on iOS 16+, which lets the
|
||||||
|
/// system tint it with the user's chosen colour in tinted (accented) mode.
|
||||||
|
/// No-op on older OS versions where the API does not exist.
|
||||||
|
@ViewBuilder
|
||||||
|
func widgetAccentableIfAvailable() -> some View
|
||||||
|
{
|
||||||
|
if #available(iOSApplicationExtension 16, *)
|
||||||
|
{
|
||||||
|
self.widgetAccentable()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `luminanceToAlpha()` only when the widget is rendering in
|
||||||
|
/// accented (tinted) mode on iOS 16+. This converts the view's pixel
|
||||||
|
/// brightness into opacity so the system can overlay the user's chosen
|
||||||
|
/// tint colour correctly — without it, images appear as white rectangles
|
||||||
|
/// in tinted mode. No-op in fullColor/dark/light mode and on older OS.
|
||||||
|
@ViewBuilder
|
||||||
|
func luminanceToAlphaInAccentedMode() -> some View
|
||||||
|
{
|
||||||
|
if #available(iOSApplicationExtension 16, *)
|
||||||
|
{
|
||||||
|
LuminanceToAlphaWrapper(content: self)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper view that reads widgetRenderingMode (iOS 16+) and conditionally
|
||||||
|
/// applies luminanceToAlpha(). Kept separate so the environment read is
|
||||||
|
/// cleanly scoped behind the @available gate.
|
||||||
|
@available(iOSApplicationExtension 16, *)
|
||||||
|
private struct LuminanceToAlphaWrapper<Content: View>: View
|
||||||
|
{
|
||||||
|
let content: Content
|
||||||
|
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if renderingMode == .accented
|
||||||
|
{
|
||||||
|
content.luminanceToAlpha()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
AltWidget/Intents/ViewAppIntent.swift
Normal file
73
AltWidget/Intents/ViewAppIntent.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//
|
||||||
|
// ViewAppIntent.swift
|
||||||
|
// AltWidgetExtension
|
||||||
|
//
|
||||||
|
// Replaces the legacy SiriKit ViewAppIntent (ViewApp.intentdefinition) with a
|
||||||
|
// modern AppIntents-based intent. Required because IntentConfiguration does not
|
||||||
|
// support containerBackground on iOS 17+, causing the blank-widget bug.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppIntents
|
||||||
|
import WidgetKit
|
||||||
|
import AltStoreCore
|
||||||
|
|
||||||
|
// Represents one installed app in the picker list.
|
||||||
|
@available(iOSApplicationExtension 17, *)
|
||||||
|
struct InstalledAppEntity: AppEntity
|
||||||
|
{
|
||||||
|
// Disambiguates from the AppEntity name used in AppIntents framework.
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Installed App"
|
||||||
|
static var defaultQuery = InstalledAppQuery()
|
||||||
|
|
||||||
|
var id: String // bundle identifier
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(title: "\(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOSApplicationExtension 17, *)
|
||||||
|
struct InstalledAppQuery: EntityQuery
|
||||||
|
{
|
||||||
|
func entities(for identifiers: [String]) async throws -> [InstalledAppEntity]
|
||||||
|
{
|
||||||
|
try await DatabaseManager.shared.start()
|
||||||
|
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||||
|
return try await context.performAsync {
|
||||||
|
let fetchRequest = InstalledApp.fetchRequest()
|
||||||
|
fetchRequest.predicate = NSPredicate(
|
||||||
|
format: "%K IN %@",
|
||||||
|
#keyPath(InstalledApp.bundleIdentifier),
|
||||||
|
identifiers
|
||||||
|
)
|
||||||
|
fetchRequest.returnsObjectsAsFaults = false
|
||||||
|
let apps = try context.fetch(fetchRequest)
|
||||||
|
return apps.map { InstalledAppEntity(id: $0.bundleIdentifier, name: $0.name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func suggestedEntities() async throws -> [InstalledAppEntity]
|
||||||
|
{
|
||||||
|
try await DatabaseManager.shared.start()
|
||||||
|
let context = DatabaseManager.shared.persistentContainer.newBackgroundContext()
|
||||||
|
return try await context.performAsync {
|
||||||
|
InstalledApp.all(in: context)
|
||||||
|
.map { InstalledAppEntity(id: $0.bundleIdentifier, name: $0.name) }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOSApplicationExtension 17, *)
|
||||||
|
struct SelectAppIntent: WidgetConfigurationIntent
|
||||||
|
{
|
||||||
|
static var title: LocalizedStringResource = "Select App"
|
||||||
|
static var description = IntentDescription("Choose which app to display.")
|
||||||
|
|
||||||
|
@Parameter(title: "App")
|
||||||
|
var app: InstalledAppEntity?
|
||||||
|
|
||||||
|
// WidgetConfigurationIntent requires perform() — no-op for configuration intents.
|
||||||
|
func perform() async throws -> some IntentResult { .result() }
|
||||||
|
}
|
||||||
@@ -221,3 +221,33 @@ class AppsTimelineProvider: AppsTimelineProviderBase<Intent>, IntentTimelineProv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Modern AppIntents-based provider for AppDetailWidget on iOS 17+.
|
||||||
|
// Replaces AppsTimelineProvider (IntentTimelineProvider) which uses the legacy
|
||||||
|
// SiriKit Intents framework that breaks containerBackground on iOS 17+.
|
||||||
|
@available(iOSApplicationExtension 17, *)
|
||||||
|
class SelectAppTimelineProvider: AppsTimelineProviderBase<SelectAppIntent>, AppIntentTimelineProvider
|
||||||
|
{
|
||||||
|
typealias Intent = SelectAppIntent
|
||||||
|
|
||||||
|
func snapshot(for intent: SelectAppIntent, in context: Context) async -> AppsEntry<SelectAppIntent>
|
||||||
|
{
|
||||||
|
let bundleID = await resolvedBundleID(for: intent)
|
||||||
|
return await self.snapshot(for: [bundleID], in: intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeline(for intent: SelectAppIntent, in context: Context) async -> Timeline<AppsEntry<SelectAppIntent>>
|
||||||
|
{
|
||||||
|
let bundleID = await resolvedBundleID(for: intent)
|
||||||
|
return await self.timeline(for: [bundleID], in: intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user hasn't picked an app yet, fall back to the first active app
|
||||||
|
// rather than a hardcoded bundle ID that may not exist in the database.
|
||||||
|
private func resolvedBundleID(for intent: SelectAppIntent) async -> String
|
||||||
|
{
|
||||||
|
if let id = intent.app?.id { return id }
|
||||||
|
let activeIDs = await self.fetchActiveAppBundleIDs()
|
||||||
|
return activeIDs.first ?? StoreApp.altstoreAppID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ private struct ActiveAppsWidgetView: View
|
|||||||
|
|
||||||
@Environment(\.colorScheme)
|
@Environment(\.colorScheme)
|
||||||
private var colorScheme
|
private var colorScheme
|
||||||
|
|
||||||
|
@Environment(\.widgetRenderingMode)
|
||||||
|
private var renderingMode
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -92,6 +95,12 @@ private struct ActiveAppsWidgetView: View
|
|||||||
{
|
{
|
||||||
LinearGradient(colors: [.altGradientDark, .altGradientExtraDark], startPoint: .top, endPoint: .bottom)
|
LinearGradient(colors: [.altGradientDark, .altGradientExtraDark], startPoint: .top, endPoint: .bottom)
|
||||||
}
|
}
|
||||||
|
else if renderingMode == .accented
|
||||||
|
{
|
||||||
|
// Plain dark background in tinted mode so the system's
|
||||||
|
// accent colour composites cleanly over it.
|
||||||
|
Color.black
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LinearGradient(colors: [.altGradientLight, .altGradientDark], startPoint: .top, endPoint: .bottom)
|
LinearGradient(colors: [.altGradientLight, .altGradientDark], startPoint: .top, endPoint: .bottom)
|
||||||
@@ -128,11 +137,16 @@ private struct ActiveAppsWidgetView: View
|
|||||||
let daysRemaining = app.expirationDate.numberOfCalendarDays(since: entry.date)
|
let daysRemaining = app.expirationDate.numberOfCalendarDays(since: entry.date)
|
||||||
|
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
// In tinted (accented) mode, luminanceToAlpha() converts the icon's
|
||||||
|
// brightness into opacity so the system can tint it with the user's
|
||||||
|
// chosen accent colour. widgetAccentable() opts the view into that
|
||||||
|
// accent group. In fullColor mode both are no-ops (via the helpers).
|
||||||
Image(uiImage: resizedIcon)
|
Image(uiImage: resizedIcon)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.aspectRatio(contentMode: .fit)
|
||||||
.cornerRadius(cornerRadius)
|
.luminanceToAlphaInAccentedMode()
|
||||||
|
.mask(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
.widgetAccentableIfAvailable()
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(app.name)
|
Text(app.name)
|
||||||
@@ -151,6 +165,7 @@ private struct ActiveAppsWidgetView: View
|
|||||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
.widgetAccentableIfAvailable()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -167,6 +182,7 @@ private struct ActiveAppsWidgetView: View
|
|||||||
.activatesRefreshAllAppsIntent()
|
.activatesRefreshAllAppsIntent()
|
||||||
// this modifier invalidates the view (disables user interaction and shows a blinking effect)
|
// this modifier invalidates the view (disables user interaction and shows a blinking effect)
|
||||||
.invalidatableContent()
|
.invalidatableContent()
|
||||||
|
.widgetAccentableIfAvailable()
|
||||||
|
|
||||||
}
|
}
|
||||||
.frame(height: rowHeight)
|
.frame(height: rowHeight)
|
||||||
|
|||||||
@@ -15,36 +15,52 @@ struct AppDetailWidget: Widget
|
|||||||
private let kind: String = "AppDetail"
|
private let kind: String = "AppDetail"
|
||||||
|
|
||||||
public var body: some WidgetConfiguration {
|
public var body: some WidgetConfiguration {
|
||||||
let configuration = IntentConfiguration(kind: kind,
|
// On iOS 16+ use AppIntentConfiguration — it correctly supports
|
||||||
intent: ViewAppIntent.self,
|
// containerBackground and contentMarginsDisabled(), unlike the legacy
|
||||||
provider: AppsTimelineProvider()) { (entry) in
|
// IntentConfiguration which breaks on iOS 17+ with the
|
||||||
AppDetailWidgetView(entry: entry)
|
// "Please adopt containerBackground" error.
|
||||||
}
|
if #available(iOSApplicationExtension 17, *)
|
||||||
.supportedFamilies([.systemSmall])
|
|
||||||
.configurationDisplayName("App Status")
|
|
||||||
.description("View remaining days until your sideloaded apps expire. Tap the countdown timer to refresh them in the background.")
|
|
||||||
|
|
||||||
if #available(iOS 17, *)
|
|
||||||
{
|
{
|
||||||
return configuration
|
return AppIntentConfiguration(
|
||||||
.contentMarginsDisabled()
|
kind: kind,
|
||||||
|
intent: SelectAppIntent.self,
|
||||||
|
provider: SelectAppTimelineProvider()
|
||||||
|
) { entry in
|
||||||
|
AppDetailWidgetView(apps: entry.apps, date: entry.date, isPlaceholder: entry.isPlaceholder)
|
||||||
|
}
|
||||||
|
.supportedFamilies([.systemSmall])
|
||||||
|
.configurationDisplayName("App Status")
|
||||||
|
.description("View remaining days until your sideloaded apps expire. Tap the countdown timer to refresh them in the background.")
|
||||||
|
.contentMarginsDisabled()
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return configuration
|
// Legacy path for iOS 15.
|
||||||
|
return IntentConfiguration(
|
||||||
|
kind: kind,
|
||||||
|
intent: ViewAppIntent.self,
|
||||||
|
provider: AppsTimelineProvider()
|
||||||
|
) { entry in
|
||||||
|
AppDetailWidgetView(apps: entry.apps, date: entry.date, isPlaceholder: entry.isPlaceholder)
|
||||||
|
}
|
||||||
|
.supportedFamilies([.systemSmall])
|
||||||
|
.configurationDisplayName("App Status")
|
||||||
|
.description("View remaining days until your sideloaded apps expire. Tap the countdown timer to refresh them in the background.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AppDetailWidgetView: View
|
private struct AppDetailWidgetView: View
|
||||||
{
|
{
|
||||||
var entry: AppsEntry<Intent>
|
let apps: [AppSnapshot]
|
||||||
|
let date: Date
|
||||||
|
let isPlaceholder: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let app = self.entry.apps.first
|
if let app = apps.first
|
||||||
{
|
{
|
||||||
let daysRemaining = app.expirationDate.numberOfCalendarDays(since: self.entry.date)
|
let daysRemaining = app.expirationDate.numberOfCalendarDays(since: date)
|
||||||
|
|
||||||
GeometryReader { (geometry) in
|
GeometryReader { (geometry) in
|
||||||
Group {
|
Group {
|
||||||
@@ -52,11 +68,7 @@ private struct AppDetailWidgetView: View
|
|||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
let imageHeight = geometry.size.height * 0.4
|
let imageHeight = geometry.size.height * 0.4
|
||||||
|
|
||||||
Image(uiImage: app.icon ?? UIImage())
|
AppIconView(icon: app.icon, imageHeight: imageHeight)
|
||||||
.resizable()
|
|
||||||
.aspectRatio(CGSize(width: 1, height: 1), contentMode: .fit)
|
|
||||||
.frame(height: imageHeight)
|
|
||||||
.mask(RoundedRectangle(cornerRadius: imageHeight / 5.0, style: .continuous))
|
|
||||||
|
|
||||||
Text(app.name.uppercased())
|
Text(app.name.uppercased())
|
||||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||||
@@ -65,6 +77,7 @@ private struct AppDetailWidgetView: View
|
|||||||
.minimumScaleFactor(0.5)
|
.minimumScaleFactor(0.5)
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.widgetAccentableIfAvailable()
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
@@ -97,7 +110,7 @@ private struct AppDetailWidgetView: View
|
|||||||
{
|
{
|
||||||
Countdown(startDate: app.refreshedDate,
|
Countdown(startDate: app.refreshedDate,
|
||||||
endDate: app.expirationDate,
|
endDate: app.expirationDate,
|
||||||
currentDate: self.entry.date)
|
currentDate: date)
|
||||||
.font(.system(size: 20, weight: .semibold, design: .rounded))
|
.font(.system(size: 20, weight: .semibold, design: .rounded))
|
||||||
.foregroundColor(Color.white)
|
.foregroundColor(Color.white)
|
||||||
.opacity(0.8)
|
.opacity(0.8)
|
||||||
@@ -108,6 +121,7 @@ private struct AppDetailWidgetView: View
|
|||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.activatesRefreshAllAppsIntent()
|
.activatesRefreshAllAppsIntent()
|
||||||
|
.widgetAccentableIfAvailable()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -118,7 +132,7 @@ private struct AppDetailWidgetView: View
|
|||||||
VStack {
|
VStack {
|
||||||
// Put conditional inside VStack, or else an empty view will be returned
|
// Put conditional inside VStack, or else an empty view will be returned
|
||||||
// if isPlaceholder == false, which messes up layout.
|
// if isPlaceholder == false, which messes up layout.
|
||||||
if !entry.isPlaceholder
|
if !isPlaceholder
|
||||||
{
|
{
|
||||||
Text("App Not Found")
|
Text("App Not Found")
|
||||||
.font(.system(.body, design: .rounded))
|
.font(.system(.body, design: .rounded))
|
||||||
@@ -131,15 +145,12 @@ private struct AppDetailWidgetView: View
|
|||||||
}
|
}
|
||||||
.widgetBackground(
|
.widgetBackground(
|
||||||
backgroundView(
|
backgroundView(
|
||||||
icon: entry.apps.first?.icon,
|
icon: apps.first?.icon,
|
||||||
tintColor: entry.apps.first?.tintColor
|
tintColor: apps.first?.tintColor
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private extension AppDetailWidgetView
|
|
||||||
{
|
|
||||||
func backgroundView(icon: UIImage? = nil, tintColor: UIColor? = nil) -> some View
|
func backgroundView(icon: UIImage? = nil, tintColor: UIColor? = nil) -> some View
|
||||||
{
|
{
|
||||||
let icon = icon ?? UIImage(named: "SideStore")!
|
let icon = icon ?? UIImage(named: "SideStore")!
|
||||||
@@ -173,12 +184,6 @@ private extension AppDetailWidgetView
|
|||||||
.saturation(saturation)
|
.saturation(saturation)
|
||||||
.blur(radius: blurRadius, opaque: true)
|
.blur(radius: blurRadius, opaque: true)
|
||||||
.scaleEffect(geometry.size.width / imageHeight, anchor: .center)
|
.scaleEffect(geometry.size.width / imageHeight, anchor: .center)
|
||||||
// .onAppear {
|
|
||||||
// print("Geometry size: \(geometry.size)")
|
|
||||||
// print("Image height: \(imageHeight), Geometry width: \(geometry.size.width)")
|
|
||||||
// print("Icon size: \(icon.size)")
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
Color(tintColor)
|
Color(tintColor)
|
||||||
.opacity(tintOpacity)
|
.opacity(tintOpacity)
|
||||||
@@ -193,6 +198,26 @@ private extension AppDetailWidgetView
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In tinted/clear mode: luminanceToAlpha converts pixel brightness → opacity so
|
||||||
|
// the system can overlay the accent colour. Must come BEFORE the mask so the
|
||||||
|
// squircle corners are clipped after conversion (reverse order = corner bleed).
|
||||||
|
// widgetAccentable() opts the result into the accent group.
|
||||||
|
private struct AppIconView: View
|
||||||
|
{
|
||||||
|
let icon: UIImage?
|
||||||
|
let imageHeight: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image(uiImage: icon ?? UIImage())
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(CGSize(width: 1, height: 1), contentMode: .fit)
|
||||||
|
.frame(height: imageHeight)
|
||||||
|
.luminanceToAlphaInAccentedMode()
|
||||||
|
.mask(RoundedRectangle(cornerRadius: imageHeight / 5.0, style: .continuous))
|
||||||
|
.widgetAccentableIfAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@available(iOS 17, *)
|
@available(iOS 17, *)
|
||||||
#Preview(as: .systemSmall) {
|
#Preview(as: .systemSmall) {
|
||||||
AppDetailWidget()
|
AppDetailWidget()
|
||||||
|
|||||||
Reference in New Issue
Block a user