diff --git a/DebugWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/DebugWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/DebugWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DebugWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/DebugWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/DebugWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DebugWidget/Assets.xcassets/Contents.json b/DebugWidget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/DebugWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DebugWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/DebugWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/DebugWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DebugWidget/DebugWidget.swift b/DebugWidget/DebugWidget.swift new file mode 100644 index 0000000..c540b17 --- /dev/null +++ b/DebugWidget/DebugWidget.swift @@ -0,0 +1,95 @@ +// +// DebugWidget.swift +// DebugWidget +// +// Created by Stephen on 5/30/25. +// + +import WidgetKit +import SwiftUI +import UIKit + +// MARK: - Timeline Entry +struct AppsEntry: TimelineEntry { + let date: Date + let bundleIDs: [String] +} + +// MARK: - Provider +struct AppsProvider: TimelineProvider { + private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") + + func placeholder(in context: Context) -> AppsEntry { + AppsEntry(date: .now, bundleIDs: []) + } + + func getSnapshot(in context: Context, completion: @escaping (AppsEntry) -> Void) { + completion(makeEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = makeEntry() + completion(Timeline(entries: [entry], policy: .never)) + } + + private func makeEntry() -> AppsEntry { + let favs = sharedDefaults?.stringArray(forKey: "favoriteApps") ?? [] + let bundleIDs = Array(favs.prefix(4)) + return AppsEntry(date: .now, bundleIDs: bundleIDs) + } +} + +// MARK: - Widget View +struct AppsWidgetEntryView: View { + let entry: AppsEntry + + var body: some View { + HStack(spacing: 8) { + ForEach(0..<4, id: \.self) { idx in + if idx < entry.bundleIDs.count { + IconCell(bundleID: entry.bundleIDs[idx]) + } else { + PlaceholderCell() + } + } + } + .padding(8) + .containerBackground(Color(UIColor.systemBackground), for: .widget) + } + + @ViewBuilder + private func IconCell(bundleID: String) -> some View { + if let img = loadIcon(for: bundleID) { + Link(destination: URL(string: "stikjit://enable-jit?bundle-id=\(bundleID)")!) { + Image(uiImage: img) + .resizable() + .aspectRatio(1, contentMode: .fit) + .cornerRadius(12) + } + } else { + PlaceholderCell() + } + } + + @ViewBuilder + private func PlaceholderCell() -> some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(UIColor.systemGray5)) + Image(systemName: "plus") + .font(.system(size: 24, weight: .medium)) + .foregroundColor(.gray) + } + .aspectRatio(1, contentMode: .fit) + } + + private func loadIcon(for bundleID: String) -> UIImage? { + guard let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.com.stik.sj") + else { return nil } + let url = container + .appendingPathComponent("icons", isDirectory: true) + .appendingPathComponent("\(bundleID).png") + return UIImage(contentsOfFile: url.path) + } +} diff --git a/DebugWidget/DebugWidgetBundle.swift b/DebugWidget/DebugWidgetBundle.swift new file mode 100644 index 0000000..c2fb896 --- /dev/null +++ b/DebugWidget/DebugWidgetBundle.swift @@ -0,0 +1,23 @@ +// +// DebugWidgetBundle.swift +// DebugWidget +// +// Created by Stephen on 5/30/25. +// + +import WidgetKit +import SwiftUI + +@main +struct AppsWidget: Widget { + let kind: String = "AppsWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: AppsProvider()) { entry in + AppsWidgetEntryView(entry: entry) + } + .configurationDisplayName("StikDebug Favorites") + .description("Quick-launch your top 4 favorite debug targets.") + .supportedFamilies([.systemMedium]) + } +} diff --git a/DebugWidget/Info.plist b/DebugWidget/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/DebugWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/DebugWidgetExtension.entitlements b/DebugWidgetExtension.entitlements new file mode 100644 index 0000000..cfb4d0e --- /dev/null +++ b/DebugWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.stik.sj + + + diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index 8450ee3..77442d0 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -7,11 +7,21 @@ objects = { /* Begin PBXBuildFile section */ + DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6D2DE97EA400F63846 /* WidgetKit.framework */; }; + DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC139F6F2DE97EA400F63846 /* SwiftUI.framework */; }; + DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DCA690122DAF660E007C91A8 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCA690112DAF660E007C91A8 /* NetworkExtension.framework */; }; DCA6901A2DAF660E007C91A8 /* TunnelProv.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DCA690102DAF660E007C91A8 /* TunnelProv.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + DC139F7F2DE97EA600F63846 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DC139F6B2DE97EA400F63846; + remoteInfo = DebugWidgetExtension; + }; DC6F1D492D94EADF0071B2B6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DC6F1D2F2D94EADD0071B2B6 /* Project object */; @@ -42,6 +52,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + DC139F812DE97EA600F63846 /* DebugWidgetExtension.appex in Embed Foundation Extensions */, DCA6901A2DAF660E007C91A8 /* TunnelProv.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -50,6 +61,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = DebugWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DC139F6D2DE97EA400F63846 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + DC139F6F2DE97EA400F63846 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugWidgetExtension.entitlements; sourceTree = ""; }; DC6F1D372D94EADD0071B2B6 /* StikDebug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StikDebug.app; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StikDebugUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -66,6 +81,13 @@ ); target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; }; + DC139F852DE97EA600F63846 /* Exceptions for "DebugWidget" folder in "DebugWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */; + }; DCA6901E2DAF660E007C91A8 /* Exceptions for "TunnelProv" folder in "TunnelProv" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -76,6 +98,14 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + DC139F712DE97EA400F63846 /* DebugWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DC139F852DE97EA600F63846 /* Exceptions for "DebugWidget" folder in "DebugWidgetExtension" target */, + ); + path = DebugWidget; + sourceTree = ""; + }; DC6F1D392D94EADD0071B2B6 /* StikJIT */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -105,6 +135,15 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + DC139F692DE97EA400F63846 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC139F702DE97EA400F63846 /* SwiftUI.framework in Frameworks */, + DC139F6E2DE97EA400F63846 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DC6F1D342D94EADD0071B2B6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -140,10 +179,12 @@ DC6F1D2E2D94EADD0071B2B6 = { isa = PBXGroup; children = ( + DC139F862DE97F2000F63846 /* DebugWidgetExtension.entitlements */, DC6F1D392D94EADD0071B2B6 /* StikJIT */, DC6F1D4B2D94EADF0071B2B6 /* StikJITTests */, DC6F1D552D94EADF0071B2B6 /* StikJITUITests */, DCA690132DAF660E007C91A8 /* TunnelProv */, + DC139F712DE97EA400F63846 /* DebugWidget */, DC6F1D752D94EB620071B2B6 /* Frameworks */, DC6F1D382D94EADD0071B2B6 /* Products */, ); @@ -156,6 +197,7 @@ DC6F1D482D94EADF0071B2B6 /* StikDebugTests.xctest */, DC6F1D522D94EADF0071B2B6 /* StikDebugUITests.xctest */, DCA690102DAF660E007C91A8 /* TunnelProv.appex */, + DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -164,6 +206,8 @@ isa = PBXGroup; children = ( DCA690112DAF660E007C91A8 /* NetworkExtension.framework */, + DC139F6D2DE97EA400F63846 /* WidgetKit.framework */, + DC139F6F2DE97EA400F63846 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -171,6 +215,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = DC139F842DE97EA600F63846 /* Build configuration list for PBXNativeTarget "DebugWidgetExtension" */; + buildPhases = ( + DC139F682DE97EA400F63846 /* Sources */, + DC139F692DE97EA400F63846 /* Frameworks */, + DC139F6A2DE97EA400F63846 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DC139F712DE97EA400F63846 /* DebugWidget */, + ); + name = DebugWidgetExtension; + packageProductDependencies = ( + ); + productName = DebugWidgetExtension; + productReference = DC139F6C2DE97EA400F63846 /* DebugWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; DC6F1D362D94EADD0071B2B6 /* StikDebug */ = { isa = PBXNativeTarget; buildConfigurationList = DC6F1D5C2D94EADF0071B2B6 /* Build configuration list for PBXNativeTarget "StikDebug" */; @@ -184,6 +250,7 @@ ); dependencies = ( DCA690192DAF660E007C91A8 /* PBXTargetDependency */, + DC139F802DE97EA600F63846 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( DC6F1D392D94EADD0071B2B6 /* StikJIT */, @@ -273,6 +340,9 @@ LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1620; TargetAttributes = { + DC139F6B2DE97EA400F63846 = { + CreatedOnToolsVersion = 16.2; + }; DC6F1D362D94EADD0071B2B6 = { CreatedOnToolsVersion = 16.2; LastSwiftMigration = 1620; @@ -308,11 +378,19 @@ DC6F1D472D94EADF0071B2B6 /* StikDebugTests */, DC6F1D512D94EADF0071B2B6 /* StikDebugUITests */, DCA6900F2DAF660E007C91A8 /* TunnelProv */, + DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + DC139F6A2DE97EA400F63846 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DC6F1D352D94EADD0071B2B6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -344,6 +422,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + DC139F682DE97EA400F63846 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DC6F1D332D94EADD0071B2B6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -375,6 +460,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + DC139F802DE97EA600F63846 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DC139F6B2DE97EA400F63846 /* DebugWidgetExtension */; + targetProxy = DC139F7F2DE97EA600F63846 /* PBXContainerItemProxy */; + }; DC6F1D4A2D94EADF0071B2B6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DC6F1D362D94EADD0071B2B6 /* StikDebug */; @@ -393,6 +483,67 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + DC139F822DE97EA600F63846 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = DebugWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SZ977XLF24; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DebugWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DebugWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj.DebugWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + DC139F832DE97EA600F63846 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = DebugWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = SZ977XLF24; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DebugWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = DebugWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.sj.DebugWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; DC6F1D5A2D94EADF0071B2B6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -754,6 +905,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + DC139F842DE97EA600F63846 /* Build configuration list for PBXNativeTarget "DebugWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DC139F822DE97EA600F63846 /* Debug */, + DC139F832DE97EA600F63846 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DC6F1D322D94EADD0071B2B6 /* Build configuration list for PBXProject "StikDebug" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/StikJIT/StikJIT.entitlements b/StikJIT/StikJIT.entitlements index a5dceae..5fe9b26 100644 --- a/StikJIT/StikJIT.entitlements +++ b/StikJIT/StikJIT.entitlements @@ -10,6 +10,10 @@ allow-vpn com.apple.security.app-sandbox + com.apple.security.application-groups + + group.com.stik.sj + com.apple.security.files.user-selected.read-only diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index fa83080..cf5eb00 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -7,13 +7,25 @@ import SwiftUI import UIKit +import WidgetKit struct InstalledAppsListView: View { @StateObject private var viewModel = InstalledAppsViewModel() @State private var appIcons: [String: UIImage] = [:] - @Environment(\.dismiss) private var dismiss + private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")! + @AppStorage("recentApps") private var recentApps: [String] = [] - @AppStorage("favoriteApps") private var favoriteApps: [String] = [] + @AppStorage("favoriteApps") private var favoriteApps: [String] = [] { + didSet { + if favoriteApps.count > 4 { + favoriteApps = Array(favoriteApps.prefix(4)) + } + sharedDefaults.set(favoriteApps, forKey: "favoriteApps") + WidgetCenter.shared.reloadAllTimelines() + } + } + + @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void var body: some View { @@ -26,51 +38,37 @@ struct InstalledAppsListView: View { } } .navigationTitle("Installed Apps") - .navigationBarItems( - trailing: Button("Done") { - dismiss() + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { dismiss() } } - ) + } } .background(Color(UIColor.systemGroupedBackground).edgesIgnoringSafeArea(.all)) } // MARK: - Empty State - private var emptyState: some View { VStack(spacing: 16) { Image(systemName: "magnifyingglass") - .resizable() - .scaledToFit() - .frame(width: 60, height: 60) - .foregroundColor(.gray) - + .resizable().scaledToFit().frame(width: 60, height: 60).foregroundColor(.gray) Text("No Debuggable App Found") - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) - + .font(.title2).fontWeight(.semibold).foregroundColor(.primary) Text(""" StikDebug can only connect to apps with the "**get-task-allow**" entitlement. \ Please check if the app you want to connect to is signed with a **development** certificate. """) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) + .font(.body).foregroundColor(.secondary).multilineTextAlignment(.center).padding(.horizontal) } - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemBackground)) + .padding().frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor.systemBackground)) } // MARK: - List of Apps - private var appsList: some View { List { - // Favorites if !favoriteApps.isEmpty { - Section(header: Text("Favorites")) { + Section(header: Text("Favorites - (\(favoriteApps.count)/4)")) { ForEach(favoriteApps, id: \.self) { bundleID in AppButton( bundleID: bundleID, @@ -78,13 +76,13 @@ Please check if the app you want to connect to is signed with a **development** recentApps: $recentApps, favoriteApps: $favoriteApps, appIcons: $appIcons, - onSelectApp: onSelectApp + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults ) } } } - // Recents if !recentApps.isEmpty { Section(header: Text("Recents")) { ForEach(recentApps, id: \.self) { bundleID in @@ -94,12 +92,16 @@ Please check if the app you want to connect to is signed with a **development** recentApps: $recentApps, favoriteApps: $favoriteApps, appIcons: $appIcons, - onSelectApp: onSelectApp + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults ) .swipeActions(edge: .trailing) { Button(role: .destructive) { withAnimation { - recentApps.removeAll(where: { $0 == bundleID }) + recentApps.removeAll { $0 == bundleID } + sharedDefaults.set(recentApps, forKey: "recentApps") + sharedDefaults.set(favoriteApps, forKey: "favoriteApps") + WidgetCenter.shared.reloadAllTimelines() } } label: { Label("Delete", systemImage: "trash") @@ -109,32 +111,17 @@ Please check if the app you want to connect to is signed with a **development** } } - // All Applications - if favoriteApps.isEmpty && recentApps.isEmpty { - Section { - ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in - AppButton( - bundleID: bundleID, - appName: appName, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp - ) - } - } - } else { - Section(header: Text("All Applications")) { - ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in - AppButton( - bundleID: bundleID, - appName: appName, - recentApps: $recentApps, - favoriteApps: $favoriteApps, - appIcons: $appIcons, - onSelectApp: onSelectApp - ) - } + Section(header: Text((favoriteApps.isEmpty && recentApps.isEmpty) ? "" : "All Applications")) { + ForEach(viewModel.apps.sorted(by: { $0.key < $1.key }), id: \.key) { bundleID, appName in + AppButton( + bundleID: bundleID, + appName: appName, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + appIcons: $appIcons, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults + ) } } } @@ -143,13 +130,14 @@ Please check if the app you want to connect to is signed with a **development** } struct AppButton: View { - @State var bundleID: String - @State var appName: String + let bundleID: String + let appName: String @Binding var recentApps: [String] @Binding var favoriteApps: [String] @Binding var appIcons: [String: UIImage] @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true var onSelectApp: (String) -> Void + let sharedDefaults: UserDefaults @Environment(\.colorScheme) private var colorScheme @@ -163,18 +151,14 @@ struct AppButton: View { .padding(.vertical, loadAppIconsOnJIT ? 0 : 8) } .contextMenu { - Button { - toggleFavorite() - } label: { + Button(action: toggleFavorite) { Label( favoriteApps.contains(bundleID) ? "Remove Favorite" : "Add to Favorites", systemImage: favoriteApps.contains(bundleID) ? "star.slash" : "star" ) + .disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4) } - - Button { - UIPasteboard.general.string = bundleID - } label: { + Button { UIPasteboard.general.string = bundleID } label: { Label("Copy Bundle ID", systemImage: "doc.on.doc") } } @@ -184,23 +168,14 @@ struct AppButton: View { Group { if loadAppIconsOnJIT, let image = appIcons[bundleID] { Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 60, height: 60) - .cornerRadius(12) - .shadow( - color: colorScheme == .dark ? .black.opacity(0.2) : .gray.opacity(0.2), - radius: 3, x: 0, y: 1 - ) + .resizable().aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60).cornerRadius(12) + .shadow(color: colorScheme == .dark ? .black.opacity(0.2) : .gray.opacity(0.2), radius: 3, x: 0, y: 1) } else { RoundedRectangle(cornerRadius: 12) .fill(Color(UIColor.systemGray5)) .frame(width: 60, height: 60) - .overlay( - Image(systemName: "app") - .font(.system(size: 26)) - .foregroundColor(.gray) - ) + .overlay(Image(systemName: "app").font(.system(size: 26)).foregroundColor(.gray)) .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) .onAppear { loadAppIcon(for: bundleID) } } @@ -209,31 +184,30 @@ struct AppButton: View { private var appText: some View { VStack(alignment: .leading, spacing: 4) { - Text(appName) - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.primary) - Text(bundleID) - .font(.system(size: 15)) - .foregroundColor(.gray) - .lineLimit(1) + Text(appName).font(.system(size: 18, weight: .semibold)).foregroundColor(.primary) + Text(bundleID).font(.system(size: 15)).foregroundColor(.gray).lineLimit(1) } } private func selectApp() { - recentApps.removeAll(where: { $0 == bundleID }) + recentApps.removeAll { $0 == bundleID } recentApps.insert(bundleID, at: 0) - if recentApps.count > 3 { - recentApps = Array(recentApps.prefix(3)) - } + if recentApps.count > 3 { recentApps = Array(recentApps.prefix(3)) } + sharedDefaults.set(recentApps, forKey: "recentApps") + sharedDefaults.set(favoriteApps, forKey: "favoriteApps") + WidgetCenter.shared.reloadAllTimelines() onSelectApp(bundleID) } private func toggleFavorite() { if favoriteApps.contains(bundleID) { - favoriteApps.removeAll(where: { $0 == bundleID }) - } else { + favoriteApps.removeAll { $0 == bundleID } + } else if favoriteApps.count < 4 { favoriteApps.insert(bundleID, at: 0) } + sharedDefaults.set(recentApps, forKey: "recentApps") + sharedDefaults.set(favoriteApps, forKey: "favoriteApps") + WidgetCenter.shared.reloadAllTimelines() } private func loadAppIcon(for bundleID: String) { @@ -243,6 +217,7 @@ struct AppButton: View { DispatchQueue.main.async { withAnimation(.easeIn(duration: 0.2)) { appIcons[bundleID] = image + saveIconToGroup(image, bundleID: bundleID) } } } @@ -250,6 +225,17 @@ struct AppButton: View { } } +fileprivate func saveIconToGroup(_ image: UIImage, bundleID: String) { + guard + let data = image.pngData(), + let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj") + else { return } + let iconsDir = container.appendingPathComponent("icons", isDirectory: true) + try? FileManager.default.createDirectory(at: iconsDir, withIntermediateDirectories: true) + let fileURL = iconsDir.appendingPathComponent("\(bundleID).png") + try? data.write(to: fileURL) +} + extension Array: @retroactive RawRepresentable where Element: Codable { public init?(rawValue: String) { guard let data = rawValue.data(using: .utf8),