Compare commits

...

2 Commits

Author SHA1 Message Date
SternXD
a0d3c5ed4a fix(#703): Allow mobiledevicepair extension for pairing
Signed-off-by: Stern <stern@sidestore.io>
2026-05-09 15:47:00 -04:00
Mod4
0d8a2df802 fix(widget): blank app icons
Signed-off-by: Mod4 <omarelfarok24135@gmail.com>
Co-authored-by: mahee96 <47920326+mahee96@users.noreply.github.com>
2026-05-09 15:45:42 -04:00
6 changed files with 237 additions and 36 deletions

View File

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

View File

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

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) @Environment(\.colorScheme)
private var colorScheme private var colorScheme
@Environment(\.widgetRenderingMode)
private var renderingMode
var body: some View { var body: some View {
Group { Group {
if entry.apps.isEmpty if entry.apps.isEmpty
@@ -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)

View File

@@ -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, *)
{
return AppIntentConfiguration(
kind: kind,
intent: SelectAppIntent.self,
provider: SelectAppTimelineProvider()
) { entry in
AppDetailWidgetView(apps: entry.apps, date: entry.date, isPlaceholder: entry.isPlaceholder)
} }
.supportedFamilies([.systemSmall]) .supportedFamilies([.systemSmall])
.configurationDisplayName("App Status") .configurationDisplayName("App Status")
.description("View remaining days until your sideloaded apps expire. Tap the countdown timer to refresh them in the background.") .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
.contentMarginsDisabled() .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()