fix(widget): blank app icons

Signed-off-by: Mod4 <omarelfarok24135@gmail.com>
Co-authored-by: mahee96 <47920326+mahee96@users.noreply.github.com>
This commit is contained in:
Mod4
2026-05-09 23:45:42 +04:00
committed by GitHub
parent 9223da751d
commit 0d8a2df802
5 changed files with 236 additions and 36 deletions

View File

@@ -7,6 +7,7 @@
//
import SwiftUI
import WidgetKit
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
}
}
}

View 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() }
}

View File

@@ -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
}
}

View File

@@ -75,6 +75,9 @@ private struct ActiveAppsWidgetView: View
@Environment(\.colorScheme)
private var colorScheme
@Environment(\.widgetRenderingMode)
private var renderingMode
var body: some View {
Group {
if entry.apps.isEmpty
@@ -92,6 +95,12 @@ private struct ActiveAppsWidgetView: View
{
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
{
LinearGradient(colors: [.altGradientLight, .altGradientDark], startPoint: .top, endPoint: .bottom)
@@ -128,11 +137,16 @@ private struct ActiveAppsWidgetView: View
let daysRemaining = app.expirationDate.numberOfCalendarDays(since: entry.date)
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)
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(cornerRadius)
.luminanceToAlphaInAccentedMode()
.mask(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.widgetAccentableIfAvailable()
VStack(alignment: .leading, spacing: 1) {
Text(app.name)
@@ -151,6 +165,7 @@ private struct ActiveAppsWidgetView: View
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
}
.widgetAccentableIfAvailable()
Spacer()
@@ -167,6 +182,7 @@ private struct ActiveAppsWidgetView: View
.activatesRefreshAllAppsIntent()
// this modifier invalidates the view (disables user interaction and shows a blinking effect)
.invalidatableContent()
.widgetAccentableIfAvailable()
}
.frame(height: rowHeight)

View File

@@ -15,36 +15,52 @@ struct AppDetailWidget: Widget
private let kind: String = "AppDetail"
public var body: some WidgetConfiguration {
let configuration = IntentConfiguration(kind: kind,
intent: ViewAppIntent.self,
provider: AppsTimelineProvider()) { (entry) in
AppDetailWidgetView(entry: entry)
}
.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, *)
// On iOS 16+ use AppIntentConfiguration it correctly supports
// containerBackground and contentMarginsDisabled(), unlike the legacy
// IntentConfiguration which breaks on iOS 17+ with the
// "Please adopt containerBackground" error.
if #available(iOSApplicationExtension 17, *)
{
return configuration
.contentMarginsDisabled()
return AppIntentConfiguration(
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
{
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
{
var entry: AppsEntry<Intent>
let apps: [AppSnapshot]
let date: Date
let isPlaceholder: Bool
var body: some View {
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
Group {
@@ -52,11 +68,7 @@ private struct AppDetailWidgetView: View
VStack(alignment: .leading, spacing: 5) {
let imageHeight = geometry.size.height * 0.4
Image(uiImage: app.icon ?? UIImage())
.resizable()
.aspectRatio(CGSize(width: 1, height: 1), contentMode: .fit)
.frame(height: imageHeight)
.mask(RoundedRectangle(cornerRadius: imageHeight / 5.0, style: .continuous))
AppIconView(icon: app.icon, imageHeight: imageHeight)
Text(app.name.uppercased())
.font(.system(size: 12, weight: .semibold, design: .rounded))
@@ -65,6 +77,7 @@ private struct AppDetailWidgetView: View
.minimumScaleFactor(0.5)
}
.fixedSize(horizontal: false, vertical: true)
.widgetAccentableIfAvailable()
Spacer(minLength: 0)
@@ -97,7 +110,7 @@ private struct AppDetailWidgetView: View
{
Countdown(startDate: app.refreshedDate,
endDate: app.expirationDate,
currentDate: self.entry.date)
currentDate: date)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundColor(Color.white)
.opacity(0.8)
@@ -108,6 +121,7 @@ private struct AppDetailWidgetView: View
}
.fixedSize(horizontal: false, vertical: true)
.activatesRefreshAllAppsIntent()
.widgetAccentableIfAvailable()
}
.padding()
}
@@ -118,7 +132,7 @@ private struct AppDetailWidgetView: View
VStack {
// Put conditional inside VStack, or else an empty view will be returned
// if isPlaceholder == false, which messes up layout.
if !entry.isPlaceholder
if !isPlaceholder
{
Text("App Not Found")
.font(.system(.body, design: .rounded))
@@ -131,15 +145,12 @@ private struct AppDetailWidgetView: View
}
.widgetBackground(
backgroundView(
icon: entry.apps.first?.icon,
tintColor: entry.apps.first?.tintColor
icon: apps.first?.icon,
tintColor: apps.first?.tintColor
)
)
}
}
private extension AppDetailWidgetView
{
func backgroundView(icon: UIImage? = nil, tintColor: UIColor? = nil) -> some View
{
let icon = icon ?? UIImage(named: "SideStore")!
@@ -173,12 +184,6 @@ private extension AppDetailWidgetView
.saturation(saturation)
.blur(radius: blurRadius, opaque: true)
.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)
.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, *)
#Preview(as: .systemSmall) {
AppDetailWidget()