From 0bd73257c2abd92c7d7c2cf6f5f9d488b17c0791 Mon Sep 17 00:00:00 2001 From: Stephen B <158498287+StephenDev0@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:47:01 -0400 Subject: [PATCH] Update the url scheme to support iOS 26 JIT --- DebugWidget/DebugWidget.swift | 193 ++++++++++++++--- DebugWidget/DebugWidgetBundle.swift | 14 +- StikJIT/Views/HomeView.swift | 242 +++++++++++++++++++--- StikJIT/Views/InstalledAppsListView.swift | 74 +++++++ 4 files changed, 454 insertions(+), 69 deletions(-) diff --git a/DebugWidget/DebugWidget.swift b/DebugWidget/DebugWidget.swift index c540b17..55ae269 100644 --- a/DebugWidget/DebugWidget.swift +++ b/DebugWidget/DebugWidget.swift @@ -9,47 +9,45 @@ import WidgetKit import SwiftUI import UIKit -// MARK: - Timeline Entry -struct AppsEntry: TimelineEntry { +// MARK: - Favorites Widget ---------------------------------------------------- + +struct FavoritesEntry: TimelineEntry { let date: Date let bundleIDs: [String] } -// MARK: - Provider -struct AppsProvider: TimelineProvider { +struct FavoritesProvider: TimelineProvider { private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") - func placeholder(in context: Context) -> AppsEntry { - AppsEntry(date: .now, bundleIDs: []) + func placeholder(in context: Context) -> FavoritesEntry { + FavoritesEntry(date: .now, bundleIDs: []) } - func getSnapshot(in context: Context, completion: @escaping (AppsEntry) -> Void) { + func getSnapshot(in context: Context, completion: @escaping (FavoritesEntry) -> Void) { completion(makeEntry()) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + 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) + private func makeEntry() -> FavoritesEntry { + let favorites = sharedDefaults?.stringArray(forKey: "favoriteApps") ?? [] + return FavoritesEntry(date: .now, bundleIDs: Array(favorites.prefix(4))) } } -// MARK: - Widget View -struct AppsWidgetEntryView: View { - let entry: AppsEntry +struct FavoritesWidgetEntryView: View { + let entry: FavoritesEntry var body: some View { HStack(spacing: 8) { ForEach(0..<4, id: \.self) { idx in if idx < entry.bundleIDs.count { - IconCell(bundleID: entry.bundleIDs[idx]) + iconCell(bundleID: entry.bundleIDs[idx]) } else { - PlaceholderCell() + placeholderCell() } } } @@ -58,7 +56,7 @@ struct AppsWidgetEntryView: View { } @ViewBuilder - private func IconCell(bundleID: String) -> some View { + 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) @@ -67,12 +65,12 @@ struct AppsWidgetEntryView: View { .cornerRadius(12) } } else { - PlaceholderCell() + placeholderCell() } } @ViewBuilder - private func PlaceholderCell() -> some View { + private func placeholderCell() -> some View { ZStack { RoundedRectangle(cornerRadius: 12) .fill(Color(UIColor.systemGray5)) @@ -82,14 +80,153 @@ struct AppsWidgetEntryView: View { } .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) +struct FavoritesWidget: Widget { + let kind: String = "FavoritesWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: FavoritesProvider()) { entry in + FavoritesWidgetEntryView(entry: entry) + } + .configurationDisplayName("StikDebug Favorites") + .description("Quick-launch your top 4 favorite debug targets.") + .supportedFamilies([.systemMedium]) } } + +// MARK: - System Apps Widget ------------------------------------------------- + +struct SystemAppSnapshot: Identifiable { + let bundleID: String + let displayName: String + var id: String { bundleID } +} + +struct SystemAppsEntry: TimelineEntry { + let date: Date + let items: [SystemAppSnapshot] +} + +struct SystemAppsProvider: TimelineProvider { + private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") + + func placeholder(in context: Context) -> SystemAppsEntry { + SystemAppsEntry(date: .now, items: []) + } + + func getSnapshot(in context: Context, completion: @escaping (SystemAppsEntry) -> Void) { + completion(makeEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = makeEntry() + completion(Timeline(entries: [entry], policy: .never)) + } + + private func makeEntry() -> SystemAppsEntry { + let pinned = sharedDefaults?.stringArray(forKey: "pinnedSystemApps") ?? [] + let names = sharedDefaults?.dictionary(forKey: "pinnedSystemAppNames") as? [String: String] ?? [:] + let snapshots = pinned.prefix(4).map { bundleID -> SystemAppSnapshot in + let displayName = names[bundleID] ?? friendlyName(bundleID: bundleID) + return SystemAppSnapshot(bundleID: bundleID, displayName: displayName) + } + return SystemAppsEntry(date: .now, items: snapshots) + } + + private func friendlyName(bundleID: String) -> String { + let components = bundleID.split(separator: ".") + if let last = components.last { + let cleaned = last.replacingOccurrences(of: "_", with: " ") + let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed.capitalized } + } + return bundleID + } +} + +struct SystemAppsWidgetEntryView: View { + let entry: SystemAppsEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(entry.items) { item in + Link(destination: URL(string: "stikjit://enable-jit?bundle-id=\(item.bundleID)")!) { + HStack(spacing: 10) { + icon(for: item.bundleID) + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text(item.displayName) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + Text(item.bundleID) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + } + .padding(8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.secondarySystemBackground).opacity(0.35)) + ) + } + } + + if entry.items.isEmpty { + Text("Pin system apps from StikDebug to see them here.") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.top, 4) + } + } + .padding(12) + .containerBackground(Color(UIColor.systemBackground), for: .widget) + } + + @ViewBuilder + private func icon(for bundleID: String) -> some View { + if let image = loadIcon(for: bundleID) { + Image(uiImage: image) + .resizable() + .aspectRatio(1, contentMode: .fill) + .cornerRadius(10) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color(UIColor.systemGray5)) + .overlay( + Image(systemName: "app") + .font(.system(size: 20, weight: .regular)) + .foregroundColor(.gray) + ) + } + } +} + +struct SystemAppsWidget: Widget { + let kind: String = "SystemAppsWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SystemAppsProvider()) { entry in + SystemAppsWidgetEntryView(entry: entry) + } + .configurationDisplayName("Hidden System Apps") + .description("Launch your pinned hidden system apps directly from the widget.") + .supportedFamilies([.systemMedium]) + } +} + +// MARK: - Shared Helpers ----------------------------------------------------- + +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 index c2fb896..5089d30 100644 --- a/DebugWidget/DebugWidgetBundle.swift +++ b/DebugWidget/DebugWidgetBundle.swift @@ -9,15 +9,9 @@ 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]) +struct StikDebugWidgetBundle: WidgetBundle { + var body: some Widget { + FavoritesWidget() + // SystemAppsWidget() // Temporarily disabled } } diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index e4c0e1d..f56c9b7 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -9,6 +9,7 @@ import SwiftUI import UniformTypeIdentifiers import Pipify import UIKit +import WidgetKit struct JITEnableConfiguration { var bundleID: String? = nil @@ -59,6 +60,10 @@ struct HomeView: View { @State private var heartbeatOK = false @State private var cachedAppNames: [String: String] = [:] @State private var isLoadingQuickApps = false + @AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = [] + @AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:] + @State private var launchingSystemApps: Set = [] + @State private var systemLaunchMessage: String? = nil @AppStorage("showiOS26Disclaimer") private var showiOS26Disclaimer: Bool = true @@ -96,6 +101,9 @@ struct HomeView: View { readinessCard quickConnectCard + if !systemPinnedItems.isEmpty { + systemAppsCard + } toolsCard tipsCard } @@ -124,6 +132,9 @@ struct HomeView: View { if justCopied { toast("Copied") } + if let message = systemLaunchMessage { + toast(message) + } } .navigationTitle("Home") } @@ -218,37 +229,10 @@ struct HomeView: View { var autoScriptData: Data? = nil var autoScriptName: String? = nil - - let appName: String? = (try? JITEnableContext.shared.getAppList()[selectedBundle]) - - if #available(iOS 26, *) { - if ProcessInfo.processInfo.hasTXM, let appName { - if appName == "maciOS" { - if let url = Bundle.main.url(forResource: "script1", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "script1.js" - } - } else if appName == "Amethyst" { - if let url = Bundle.main.url(forResource: "script2", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "script2.js" - } - } else if appName == "MeloNX" { - if let url = Bundle.main.url(forResource: "melo", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "melo.js" - } - } else if appName == "UTM" { - if let url = Bundle.main.url(forResource: "utmjit", withExtension: "js"), - let data = try? Data(contentsOf: url) { - autoScriptData = data - autoScriptName = "utmjit.js" - } - } - } + + if let auto = autoScriptForBundleIfNeeded(bundleID: selectedBundle) { + autoScriptData = auto.data + autoScriptName = auto.name } startJITInBackground(bundleID: selectedBundle, @@ -307,6 +291,15 @@ struct HomeView: View { if let scriptName = components?.queryItems?.first(where: { $0.name == "script-name" })?.value { config.scriptName = scriptName } + + // If no explicit script was provided via URL, try to attach the same + // auto-selected script we use from the Installed Apps flow. + if config.scriptData == nil, let bundle = config.bundleID, + let auto = autoScriptForBundleIfNeeded(bundleID: bundle) { + config.scriptData = auto.data + config.scriptName = auto.name + } + if viewDidAppeared { startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true) } else { @@ -664,6 +657,32 @@ struct HomeView: View { } } + private var systemAppsCard: some View { + homeCard { + VStack(alignment: .leading, spacing: 14) { + Text("System Apps".localized) + .font(.headline.weight(.semibold)) + .foregroundStyle(.primary) + + Text("Launch hidden system bundles without leaving Home.".localized) + .font(.footnote) + .foregroundStyle(.secondary) + + VStack(spacing: 10) { + ForEach(systemPinnedItems) { item in + SystemPinnedRow( + item: item, + accentColor: accentColor, + isLaunching: launchingSystemApps.contains(item.bundleID), + action: { launchSystemApp(item: item) }, + onRemove: { removePinnedSystemApp(bundleID: item.bundleID) } + ) + } + } + } + } + } + private var quickConnectItems: [QuickConnectItem] { var seen = Set() var ordered: [QuickConnectItem] = [] @@ -675,6 +694,13 @@ struct HomeView: View { return ordered } + private var systemPinnedItems: [SystemPinnedItem] { + pinnedSystemApps.compactMap { bundleID in + let displayName = pinnedSystemAppNames[bundleID] ?? friendlyName(for: bundleID) + return SystemPinnedItem(bundleID: bundleID, displayName: displayName) + } + } + private func friendlyName(for bundleID: String) -> String { if let cached = cachedAppNames[bundleID], !cached.isEmpty { return cached } let components = bundleID.split(separator: ".") @@ -953,6 +979,53 @@ struct HomeView: View { pipRequired = false } } + + private func launchSystemApp(item: SystemPinnedItem) { + guard !launchingSystemApps.contains(item.bundleID) else { return } + launchingSystemApps.insert(item.bundleID) + HapticFeedbackHelper.trigger() + + DispatchQueue.global(qos: .userInitiated).async { + let success = JITEnableContext.shared.launchAppWithoutDebug(item.bundleID, logger: nil) + + DispatchQueue.main.async { + launchingSystemApps.remove(item.bundleID) + if success { + LogManager.shared.addInfoLog("Launch request sent for \(item.bundleID)") + systemLaunchMessage = String(format: "Launch requested: %@".localized, item.displayName) + } else { + LogManager.shared.addErrorLog("Failed to launch \(item.bundleID)") + systemLaunchMessage = String(format: "Failed to launch %@".localized, item.displayName) + } + scheduleSystemToastDismiss() + } + } + } + + private func removePinnedSystemApp(bundleID: String) { + Haptics.light() + pinnedSystemApps.removeAll { $0 == bundleID } + pinnedSystemAppNames.removeValue(forKey: bundleID) + persistPinnedSystemApps() + } + + private func scheduleSystemToastDismiss() { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + if systemLaunchMessage != nil { + withAnimation { + systemLaunchMessage = nil + } + } + } + } + + private func persistPinnedSystemApps() { + if let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj") { + sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps") + sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames") + } + WidgetCenter.shared.reloadAllTimelines() + } private func addRecentPID(_ pid: Int) { var list = recentPIDs.filter { $0 != pid } @@ -1156,6 +1229,71 @@ private struct QuickConnectRow: View { } } +private struct SystemPinnedRow: View { + let item: SystemPinnedItem + let accentColor: Color + let isLaunching: Bool + var action: () -> Void + var onRemove: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + QuickAppBadge(title: item.displayName, accentColor: accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text(item.displayName) + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(item.bundleID) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + + if isLaunching { + ProgressView() + .controlSize(.small) + .tint(accentColor) + } else { + Image(systemName: "play.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(accentColor) + } + } + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(UIColor.secondarySystemBackground).opacity(0.6)) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.1), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isLaunching) + .contextMenu { + Button("Remove from Home".localized, systemImage: "star.slash") { + onRemove() + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + onRemove() + } label: { + Label("Remove".localized, systemImage: "trash") + } + } + } +} + private struct QuickAppBadge: View { let title: String let accentColor: Color @@ -1232,6 +1370,12 @@ private struct QuickConnectItem: Identifiable { var id: String { bundleID } } +private struct SystemPinnedItem: Identifiable { + let bundleID: String + let displayName: String + var id: String { bundleID } +} + // MARK: - Connect-by-PID Sheet (minus/plus removed) private struct ConnectByPIDSheet: View { @@ -1450,3 +1594,39 @@ public extension ProcessInfo { }() } } + +// MARK: - Auto script selection for URL and in-app flows + +private extension HomeView { + struct AutoScript { + let data: Data + let name: String + } + + func autoScriptForBundleIfNeeded(bundleID: String) -> AutoScript? { + guard #available(iOS 26, *), ProcessInfo.processInfo.hasTXM else { return nil } + let appName: String? = { + let list = try? JITEnableContext.shared.getAppList() + return list?[bundleID] + }() + + func load(resource: String, as name: String) -> AutoScript? { + guard let url = Bundle.main.url(forResource: resource, withExtension: "js"), + let data = try? Data(contentsOf: url) else { return nil } + return AutoScript(data: data, name: name) + } + + switch appName { + case "maciOS": + return load(resource: "script1", as: "script1.js") + case "Amethyst": + return load(resource: "script2", as: "script2.js") + case "MeloNX": + return load(resource: "melo", as: "melo.js") + case "UTM": + return load(resource: "utmjit", as: "utmjit.js") + default: + return nil + } + } +} diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 21e308f..9ff6f99 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -38,6 +38,8 @@ struct InstalledAppsListView: View { @State private var prefetchedBundleIDs: Set = [] @State private var selectedTab: AppListTab = .debuggable @State private var systemSearchText: String = "" + @AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = [] + @AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:] @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void @@ -276,6 +278,7 @@ struct InstalledAppsListView: View { } } .onChange(of: selectedTab) { _, _ in prefetchPriorityIcons() } + .onChange(of: pinnedSystemApps) { _, _ in prefetchPriorityIcons() } } // MARK: Empty State @@ -429,6 +432,7 @@ struct InstalledAppsListView: View { appendUnique(favoriteApps) appendUnique(recentApps) + appendUnique(pinnedSystemApps) appendUnique(debuggableSortedApps.map { $0.key }) appendUnique(sortedSystemApps.map { $0.key }) appendUnique(sortedOtherApps.map { $0.key }) @@ -462,6 +466,7 @@ struct InstalledAppsListView: View { glassSection(title: "Hidden System Apps".localized) { LazyVStack(spacing: 12) { ForEach(apps, id: \.key) { bundleID, appName in + let isPinned = pinnedSystemApps.contains(bundleID) LaunchAppRow( bundleID: bundleID, appName: appName, @@ -471,6 +476,31 @@ struct InstalledAppsListView: View { ) { startLaunching(bundleID: bundleID) } + .overlay(alignment: .topTrailing) { + if isPinned { + Image(systemName: "star.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.yellow) + .padding(6) + } + } + .contextMenu { + Button((isPinned ? "Remove from Home" : "Add to Home").localized, systemImage: isPinned ? "star.slash" : "star") { + toggleSystemPin(bundleID: bundleID, appName: appName) + } + Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") { + UIPasteboard.general.string = bundleID + Haptics.light() + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + toggleSystemPin(bundleID: bundleID, appName: appName) + } label: { + Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star") + } + .tint(.yellow) + } } } } @@ -776,6 +806,8 @@ struct InstalledAppsListView: View { var touched = false let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? [] let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? [] + let prevPinned = (sharedDefaults.array(forKey: "pinnedSystemApps") as? [String]) ?? [] + let prevPinnedNames = (sharedDefaults.dictionary(forKey: "pinnedSystemAppNames") as? [String: String]) ?? [:] if prevR != recentApps { sharedDefaults.set(recentApps, forKey: "recentApps") @@ -785,6 +817,14 @@ struct InstalledAppsListView: View { sharedDefaults.set(favoriteApps, forKey: "favoriteApps") touched = true } + if prevPinned != pinnedSystemApps { + sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps") + touched = true + } + if prevPinnedNames != pinnedSystemAppNames { + sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames") + touched = true + } if touched { WidgetCenter.shared.reloadAllTimelines() } } @@ -816,6 +856,25 @@ struct InstalledAppsListView: View { } } } + + private func toggleSystemPin(bundleID: String, appName: String) { + Haptics.light() + if let index = pinnedSystemApps.firstIndex(of: bundleID) { + pinnedSystemApps.remove(at: index) + pinnedSystemAppNames.removeValue(forKey: bundleID) + } else { + pinnedSystemApps.removeAll { $0 == bundleID } + pinnedSystemApps.insert(bundleID, at: 0) + pinnedSystemAppNames[bundleID] = appName + let maxPins = 8 + if pinnedSystemApps.count > maxPins { + let surplus = Array(pinnedSystemApps.suffix(from: maxPins)) + for id in surplus { pinnedSystemAppNames.removeValue(forKey: id) } + pinnedSystemApps = Array(pinnedSystemApps.prefix(maxPins)) + } + } + persistIfChanged() + } } // MARK: - App Button Row @@ -1412,6 +1471,21 @@ extension Array: @retroactive RawRepresentable where Element: Codable { } } +extension Dictionary: @retroactive RawRepresentable where Key: Codable, Value: Codable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode([Key: Value].self, from: data) + else { return nil } + self = result + } + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) + else { return "{}" } + return result + } +} + // MARK: - Preview #Preview {