@capgo/capacitor-updater 8.46.3 → 8.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -19
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +466 -30
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +239 -22
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +95 -3
- package/dist/docs.json +473 -0
- package/dist/esm/definitions.d.ts +241 -0
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +4 -1
- package/dist/esm/web.js +30 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +30 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +30 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +354 -15
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +194 -8
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +2 -0
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +47 -3
- package/package.json +1 -1
|
@@ -21,6 +21,7 @@ import UIKit
|
|
|
21
21
|
private let INFO_SUFFIX: String = "_info"
|
|
22
22
|
private let FALLBACK_VERSION: String = "pastVersion"
|
|
23
23
|
private let NEXT_VERSION: String = "nextVersion"
|
|
24
|
+
private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
|
|
24
25
|
private var unzipPercent = 0
|
|
25
26
|
private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
|
|
26
27
|
|
|
@@ -37,6 +38,7 @@ import UIKit
|
|
|
37
38
|
public var defaultChannel: String = ""
|
|
38
39
|
public var appId: String = ""
|
|
39
40
|
public var deviceID = ""
|
|
41
|
+
public var previewSession = false
|
|
40
42
|
public var publicKey: String = ""
|
|
41
43
|
|
|
42
44
|
// Cached key ID calculated once from publicKey
|
|
@@ -363,7 +365,7 @@ import UIKit
|
|
|
363
365
|
if statusCode == 429 {
|
|
364
366
|
// Send a statistic about the rate limit BEFORE setting the flag
|
|
365
367
|
// Only send once to prevent infinite loop if the stat request itself gets rate limited
|
|
366
|
-
if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
368
|
+
if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
|
|
367
369
|
CapgoUpdater.rateLimitStatisticSent = true
|
|
368
370
|
|
|
369
371
|
// Dispatch to background queue to avoid blocking the main thread
|
|
@@ -701,11 +703,11 @@ import UIKit
|
|
|
701
703
|
}
|
|
702
704
|
}
|
|
703
705
|
|
|
704
|
-
private func createInfoObject() -> InfoObject {
|
|
706
|
+
private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
|
|
705
707
|
return InfoObject(
|
|
706
708
|
platform: "ios",
|
|
707
709
|
device_id: self.deviceID,
|
|
708
|
-
app_id: self.appId,
|
|
710
|
+
app_id: appIdOverride ?? self.appId,
|
|
709
711
|
custom_id: self.customId,
|
|
710
712
|
version_build: self.versionBuild,
|
|
711
713
|
version_code: self.versionCode,
|
|
@@ -721,7 +723,7 @@ import UIKit
|
|
|
721
723
|
)
|
|
722
724
|
}
|
|
723
725
|
|
|
724
|
-
public func getLatest(url: URL, channel: String?) -> AppVersion {
|
|
726
|
+
public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
|
|
725
727
|
let latest: AppVersion = AppVersion()
|
|
726
728
|
func applyLatestResponse(_ value: AppVersionDec?) {
|
|
727
729
|
if let url = value?.url {
|
|
@@ -765,7 +767,7 @@ import UIKit
|
|
|
765
767
|
}
|
|
766
768
|
}
|
|
767
769
|
|
|
768
|
-
var parameters: InfoObject = self.createInfoObject()
|
|
770
|
+
var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
|
|
769
771
|
if let channel = channel {
|
|
770
772
|
parameters.defaultChannel = channel
|
|
771
773
|
}
|
|
@@ -874,6 +876,132 @@ import UIKit
|
|
|
874
876
|
return actualHash == expectedHash
|
|
875
877
|
}
|
|
876
878
|
|
|
879
|
+
private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
|
|
880
|
+
guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
|
|
881
|
+
return nil
|
|
882
|
+
}
|
|
883
|
+
if !self.publicKey.isEmpty && !sessionKey.isEmpty {
|
|
884
|
+
do {
|
|
885
|
+
fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
|
|
886
|
+
} catch {
|
|
887
|
+
logger.error("Checksum decryption failed while checking missing manifest files")
|
|
888
|
+
logger.debug("File: \(entry.file_name ?? "unknown"), Error: \(error.localizedDescription)")
|
|
889
|
+
return nil
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return fileHash
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private func isManifestEntryAvailableLocally(entry: ManifestEntry, sessionKey: String) -> Bool {
|
|
896
|
+
guard let fileName = entry.file_name,
|
|
897
|
+
let fileHash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
|
|
898
|
+
return false
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
|
|
902
|
+
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
903
|
+
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
904
|
+
return true
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
908
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
909
|
+
let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
|
|
910
|
+
let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
|
|
911
|
+
if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
|
|
912
|
+
return true
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if isBrotli {
|
|
916
|
+
let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
|
|
917
|
+
if FileManager.default.fileExists(atPath: legacyCacheFilePath.path) && verifyChecksum(file: legacyCacheFilePath, expectedHash: fileHash) {
|
|
918
|
+
return true
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return false
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
|
|
926
|
+
return manifest.filter { entry in
|
|
927
|
+
!isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
public func missingBundleFilesResult(manifest: [ManifestEntry], sessionKey: String) -> [String: Any] {
|
|
932
|
+
let missing = getMissingBundleFiles(manifest: manifest, sessionKey: sessionKey)
|
|
933
|
+
return [
|
|
934
|
+
"missing": missing.map { $0.toDict() },
|
|
935
|
+
"total": manifest.count,
|
|
936
|
+
"missingCount": missing.count,
|
|
937
|
+
"reusableCount": manifest.count - missing.count
|
|
938
|
+
]
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private func manifestSizeUrl(from updateUrl: URL) -> URL {
|
|
942
|
+
var components = URLComponents(url: updateUrl, resolvingAgainstBaseURL: false)
|
|
943
|
+
let path = components?.path ?? updateUrl.path
|
|
944
|
+
let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
945
|
+
components?.path = trimmedPath == ""
|
|
946
|
+
? "/manifest_size"
|
|
947
|
+
: "/\(trimmedPath)/manifest_size"
|
|
948
|
+
components?.query = nil
|
|
949
|
+
return components?.url ?? updateUrl.appendingPathComponent("manifest_size")
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private func unavailableBundleSizeResult(manifest: [ManifestEntry], error: String) -> [String: Any] {
|
|
953
|
+
return [
|
|
954
|
+
"totalSize": 0,
|
|
955
|
+
"knownFiles": 0,
|
|
956
|
+
"unknownFiles": manifest.count,
|
|
957
|
+
"files": manifest.map {
|
|
958
|
+
var dict = $0.toDict()
|
|
959
|
+
dict["error"] = error
|
|
960
|
+
return dict
|
|
961
|
+
}
|
|
962
|
+
]
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
public func getBundleDownloadSize(updateUrl: URL, version: String?, manifest: [ManifestEntry]) -> [String: Any] {
|
|
966
|
+
if manifest.isEmpty {
|
|
967
|
+
return [
|
|
968
|
+
"totalSize": 0,
|
|
969
|
+
"knownFiles": 0,
|
|
970
|
+
"unknownFiles": 0,
|
|
971
|
+
"files": []
|
|
972
|
+
]
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
var parameters = self.createInfoObject().toParameters()
|
|
976
|
+
parameters["version"] = version ?? ""
|
|
977
|
+
parameters["manifest"] = manifest.map { $0.toDict() }
|
|
978
|
+
|
|
979
|
+
guard let request = createRequest(url: manifestSizeUrl(from: updateUrl), method: "POST", parameters: parameters) else {
|
|
980
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "request_error")
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
let result = performRequest(request, label: "getBundleDownloadSize")
|
|
984
|
+
if result.timedOut {
|
|
985
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "timeout_error")
|
|
986
|
+
}
|
|
987
|
+
if let error = result.error {
|
|
988
|
+
logger.error("Error getting bundle download size")
|
|
989
|
+
logger.debug("Error: \(error.localizedDescription)")
|
|
990
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
|
|
991
|
+
}
|
|
992
|
+
guard let data = result.data,
|
|
993
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
994
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "parse_error")
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
let statusCode = result.response?.statusCode ?? 0
|
|
998
|
+
if statusCode < 200 || statusCode >= 300 {
|
|
999
|
+
return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
return json
|
|
1003
|
+
}
|
|
1004
|
+
|
|
877
1005
|
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
878
1006
|
let id = self.randomString(length: 10)
|
|
879
1007
|
logger.info("downloadManifest start \(id)")
|
|
@@ -899,8 +1027,8 @@ import UIKit
|
|
|
899
1027
|
|
|
900
1028
|
let totalFiles = manifest.count
|
|
901
1029
|
|
|
902
|
-
//
|
|
903
|
-
manifestDownloadQueue.maxConcurrentOperationCount = min(
|
|
1030
|
+
// Keep this bounded because each manifest operation waits on a URLSession callback.
|
|
1031
|
+
manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
|
|
904
1032
|
|
|
905
1033
|
// Thread-safe counters for concurrent operations
|
|
906
1034
|
let completedFiles = AtomicCounter()
|
|
@@ -1599,6 +1727,15 @@ import UIKit
|
|
|
1599
1727
|
return false
|
|
1600
1728
|
}
|
|
1601
1729
|
|
|
1730
|
+
if let previewFallback = self.getPreviewFallbackBundle(),
|
|
1731
|
+
!previewFallback.isDeleted(),
|
|
1732
|
+
!previewFallback.isErrorStatus(),
|
|
1733
|
+
previewFallback.getId() == id {
|
|
1734
|
+
logger.info("Cannot delete the preview fallback bundle")
|
|
1735
|
+
logger.debug("Bundle ID: \(id)")
|
|
1736
|
+
return false
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1602
1739
|
// Check if this is the next bundle and prevent deletion if it is
|
|
1603
1740
|
if let next = self.getNextBundle(),
|
|
1604
1741
|
!next.isDeleted() &&
|
|
@@ -1883,6 +2020,21 @@ import UIKit
|
|
|
1883
2020
|
return true
|
|
1884
2021
|
}
|
|
1885
2022
|
|
|
2023
|
+
func stagePreviewFallbackReload(bundle: BundleInfo) -> Bool {
|
|
2024
|
+
guard !bundle.isErrorStatus() else {
|
|
2025
|
+
return false
|
|
2026
|
+
}
|
|
2027
|
+
if bundle.isBuiltin() {
|
|
2028
|
+
self.setCurrentBundle(bundle: self.DEFAULT_FOLDER)
|
|
2029
|
+
return true
|
|
2030
|
+
}
|
|
2031
|
+
guard bundleExists(id: bundle.getId()) else {
|
|
2032
|
+
return false
|
|
2033
|
+
}
|
|
2034
|
+
self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
|
|
2035
|
+
return true
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1886
2038
|
func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
|
|
1887
2039
|
guard !bundle.isBuiltin() else {
|
|
1888
2040
|
return
|
|
@@ -1922,9 +2074,11 @@ import UIKit
|
|
|
1922
2074
|
public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
|
|
1923
2075
|
self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
|
|
1924
2076
|
let fallback: BundleInfo = self.getFallbackBundle()
|
|
2077
|
+
let previewFallback = self.getPreviewFallbackBundle()
|
|
2078
|
+
let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
|
|
1925
2079
|
logger.info("Fallback bundle is: \(fallback.toString())")
|
|
1926
2080
|
logger.info("Version successfully loaded: \(bundle.toString())")
|
|
1927
|
-
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
|
|
2081
|
+
if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
|
|
1928
2082
|
let res = self.delete(id: fallback.getId())
|
|
1929
2083
|
if res {
|
|
1930
2084
|
logger.info("Deleted previous bundle")
|
|
@@ -2264,6 +2418,11 @@ import UIKit
|
|
|
2264
2418
|
}
|
|
2265
2419
|
|
|
2266
2420
|
private func sendStatsWithMetadata(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]?) {
|
|
2421
|
+
if previewSession {
|
|
2422
|
+
logger.debug("Skipping sendStats during preview session.")
|
|
2423
|
+
return
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2267
2426
|
// Check if rate limit was exceeded
|
|
2268
2427
|
if CapgoUpdater.rateLimitExceeded {
|
|
2269
2428
|
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
|
|
@@ -2458,6 +2617,33 @@ import UIKit
|
|
|
2458
2617
|
return self.getBundleInfo(id: id)
|
|
2459
2618
|
}
|
|
2460
2619
|
|
|
2620
|
+
public func getPreviewFallbackBundle() -> BundleInfo? {
|
|
2621
|
+
guard let id = UserDefaults.standard.string(forKey: self.PREVIEW_FALLBACK_VERSION) else {
|
|
2622
|
+
return nil
|
|
2623
|
+
}
|
|
2624
|
+
let bundle = self.getBundleInfo(id: id)
|
|
2625
|
+
if !bundle.isBuiltin() && !self.bundleExists(id: id) {
|
|
2626
|
+
_ = self.setPreviewFallbackBundle(fallback: nil)
|
|
2627
|
+
return nil
|
|
2628
|
+
}
|
|
2629
|
+
return bundle
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
public func setPreviewFallbackBundle(fallback: String?) -> Bool {
|
|
2633
|
+
guard let fallbackId = fallback else {
|
|
2634
|
+
UserDefaults.standard.removeObject(forKey: self.PREVIEW_FALLBACK_VERSION)
|
|
2635
|
+
UserDefaults.standard.synchronize()
|
|
2636
|
+
return true
|
|
2637
|
+
}
|
|
2638
|
+
let newBundle: BundleInfo = self.getBundleInfo(id: fallbackId)
|
|
2639
|
+
if !newBundle.isBuiltin() && !self.bundleExists(id: fallbackId) {
|
|
2640
|
+
return false
|
|
2641
|
+
}
|
|
2642
|
+
UserDefaults.standard.set(fallbackId, forKey: self.PREVIEW_FALLBACK_VERSION)
|
|
2643
|
+
UserDefaults.standard.synchronize()
|
|
2644
|
+
return true
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2461
2647
|
public func setNextBundle(next: String?) -> Bool {
|
|
2462
2648
|
guard let nextId: String = next else {
|
|
2463
2649
|
UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
|
|
@@ -240,6 +240,8 @@ public class AppVersion: NSObject {
|
|
|
240
240
|
var breaking: Bool?
|
|
241
241
|
var data: [String: String]?
|
|
242
242
|
var manifest: [ManifestEntry]?
|
|
243
|
+
var missing: [String: Any]?
|
|
244
|
+
var downloadSize: [String: Any]?
|
|
243
245
|
var link: String?
|
|
244
246
|
var comment: String?
|
|
245
247
|
var statusCode: Int = 0
|
|
@@ -27,7 +27,8 @@ extension UIWindow {
|
|
|
27
27
|
override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
|
28
28
|
if motion == .motionShake {
|
|
29
29
|
// Find the CapacitorUpdaterPlugin instance
|
|
30
|
-
guard let
|
|
30
|
+
guard let bridgeViewController = rootViewController as? CAPBridgeViewController,
|
|
31
|
+
let bridge = bridgeViewController.bridge,
|
|
31
32
|
let plugin = bridge.plugin(withName: "CapacitorUpdaterPlugin") as? CapacitorUpdaterPlugin else {
|
|
32
33
|
return
|
|
33
34
|
}
|
|
@@ -37,8 +38,9 @@ extension UIWindow {
|
|
|
37
38
|
return
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
if plugin.hasActivePreviewSession() {
|
|
42
|
+
showDefaultMenu(plugin: plugin, bridge: bridge)
|
|
43
|
+
} else if plugin.shakeChannelSelectorEnabled {
|
|
42
44
|
showChannelSelector(plugin: plugin, bridge: bridge)
|
|
43
45
|
} else {
|
|
44
46
|
showDefaultMenu(plugin: plugin, bridge: bridge)
|
|
@@ -54,6 +56,48 @@ extension UIWindow {
|
|
|
54
56
|
return
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
if !plugin.hasActivePreviewSession() {
|
|
60
|
+
showConfiguredDefaultMenu(plugin: plugin, bridge: bridge)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "App"
|
|
65
|
+
let title = "Preview \(appName) Menu"
|
|
66
|
+
let message = "Reload the current preview or leave the test app."
|
|
67
|
+
let okButtonTitle = "Leave test app"
|
|
68
|
+
let reloadButtonTitle = "Reload app"
|
|
69
|
+
let cancelButtonTitle = "Close menu"
|
|
70
|
+
|
|
71
|
+
let alertShake = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
72
|
+
|
|
73
|
+
alertShake.addAction(UIAlertAction(title: okButtonTitle, style: .default) { _ in
|
|
74
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
75
|
+
if !plugin.leavePreviewSessionFromShakeMenu() {
|
|
76
|
+
DispatchQueue.main.async {
|
|
77
|
+
self.showError(message: "Could not leave the test app.", plugin: plugin)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
alertShake.addAction(UIAlertAction(title: reloadButtonTitle, style: .default) { _ in
|
|
84
|
+
DispatchQueue.main.async {
|
|
85
|
+
if !plugin.reloadPreviewSessionFromShakeMenu() {
|
|
86
|
+
self.showError(message: "Could not reload the test app.", plugin: plugin)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
alertShake.addAction(UIAlertAction(title: cancelButtonTitle, style: .default))
|
|
92
|
+
|
|
93
|
+
DispatchQueue.main.async {
|
|
94
|
+
if let topVC = UIApplication.topViewController() {
|
|
95
|
+
topVC.present(alertShake, animated: true)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private func showConfiguredDefaultMenu(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) {
|
|
57
101
|
let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "App"
|
|
58
102
|
let title = "Preview \(appName) Menu"
|
|
59
103
|
let message = "What would you like to do?"
|