Merge pull request #7407 from BlueWallet/marketw

FIX: Widgets do not work any longer on iOS and macOS #7380
This commit is contained in:
GLaDOS 2024-12-14 20:08:23 +00:00 committed by GitHub
commit 8de33f9b86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 484 additions and 243 deletions

View file

@ -162,7 +162,7 @@
B4D0B2682C1DED67006B6B1B /* ReceiveMethod.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D0B2672C1DED67006B6B1B /* ReceiveMethod.swift */; };
B4EE583C226703320003363C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B40D4E35225841ED00428FCC /* Assets.xcassets */; };
B4EFF73B2C3F6C5E0095D655 /* MockData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4EFF73A2C3F6C5E0095D655 /* MockData.swift */; };
C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
C978A716948AB7DEC5B6F677 /* (null) in Frameworks */ = {isa = PBXBuildFile; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -427,7 +427,7 @@
files = (
782F075B5DD048449E2DECE9 /* libz.tbd in Frameworks */,
764B49B1420D4AEB8109BF62 /* libsqlite3.0.tbd in Frameworks */,
C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */,
C978A716948AB7DEC5B6F677 /* (null) in Frameworks */,
17CDA0718F42DB2CE856C872 /* libPods-BlueWallet.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1670,7 +1670,7 @@
"DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = Widgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -1726,7 +1726,7 @@
"DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU;
GCC_C_LANGUAGE_STANDARD = gnu11;
INFOPLIST_FILE = Widgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View file

@ -57,6 +57,15 @@
},
"Colombia (Colombian Peso)" : {
},
"Configure Market Widget with ${electrumHost}" : {
},
"Configure Market Widget with ${electrumHost} and show error messages: ${showErrorMessages}" : {
},
"Configure the Market Widget to show the Electrum host connected to/being attempted and display error messages if data retrieval fails." : {
},
"Croatia (Croatian Kuna)" : {
@ -78,6 +87,34 @@
},
"Denmark (Danish Krone)" : {
},
"display_in_BROWSER_TITLE" : {
"localizations" : {
"en_US" : {
"stringUnit" : {
"state" : "translated",
"value" : "View in Browser"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ver en el navegador"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voir dans le navigateur"
}
}
}
},
"Electrum Host" : {
},
"Error" : {
},
"European Union (Euro)" : {
@ -154,6 +191,15 @@
},
"Market Rate" : {
},
"Market Widget" : {
},
"Market Widget Configuration" : {
},
"Market Widget is connected to ${electrumHost}" : {
},
"Mexico (Mexican Peso)" : {
@ -205,6 +251,9 @@
},
"send" : {
},
"Show Error Messages" : {
},
"Singapore (Singapore Dollar)" : {
@ -283,6 +332,9 @@
},
"View the current price of Bitcoin." : {
},
"View the Electrum host connected to/being attempted" : {
},
"View your accumulated balance." : {
@ -307,28 +359,6 @@
}
}
},
"display_in_BROWSER_TITLE" : {
"localizations" : {
"en_US" : {
"stringUnit" : {
"state" : "translated",
"value" : "View in Browser"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ver en el navegador"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Voir dans le navigateur"
}
}
}
},
"VIEW_TRANSACTION_DETAILS_TITLE" : {
"extractionState" : "manual",
"localizations" : {

View file

@ -14,88 +14,87 @@ struct APIError: LocalizedError {
extension MarketAPI {
static func fetchNextBlockFee(completion: @escaping ((MarketData?, Error?) -> Void), userElectrumSettings: UserDefaultsElectrumSettings = UserDefaultsGroup.getElectrumSettings(), retries: Int = 0) {
Task.detached {
let client = SwiftTCPClient()
defer {
print("Closing connection to \(userElectrumSettings.host ?? "unknown"):\(userElectrumSettings.sslPort ?? userElectrumSettings.port ?? 0).")
client.close()
static func fetchNextBlockFee() async throws -> MarketData {
let client = SwiftTCPClient(hosts: hardcodedPeers)
defer {
client.close()
print("Closed SwiftTCPClient connection.")
}
guard await client.connectToNextAvailable(validateCertificates: false) else {
print("Failed to connect to any Electrum peer.")
throw APIError()
}
let message = "{\"id\": 1, \"method\": \"mempool.get_fee_histogram\", \"params\": []}\n"
guard let data = message.data(using: .utf8) else {
print("Failed to encode message to data.")
throw APIError()
}
print("Sending fee histogram request: \(message)")
guard await client.send(data: data) else {
print("Failed to send fee histogram request.")
throw APIError()
}
do {
let receivedData = try await client.receive()
print("Received data: \(receivedData)")
guard let json = try JSONSerialization.jsonObject(with: receivedData, options: .allowFragments) as? [String: AnyObject],
let feeHistogram = json["result"] as? [[Double]] else {
print("Invalid JSON structure in response.")
throw APIError()
}
guard let host = userElectrumSettings.host, let portToUse = userElectrumSettings.sslPort ?? userElectrumSettings.port else {
completion(nil, APIError())
return
}
let isSSLSupported = userElectrumSettings.sslPort != nil
print("Attempting to connect to \(host):\(portToUse) with SSL supported: \(isSSLSupported).")
let connected = await client.connect(to: host, port: portToUse, useSSL: isSSLSupported)
if connected {
print("Successfully connected to \(host):\(portToUse) with SSL: \(isSSLSupported).")
} else {
print("Failed to connect to \(host):\(portToUse) with SSL: \(isSSLSupported).")
if retries < client.maxRetries {
print("Retrying fetchNextBlockFee (\(retries + 1)/\(client.maxRetries))...")
fetchNextBlockFee(completion: completion, userElectrumSettings: userElectrumSettings, retries: retries + 1)
} else {
completion(nil, APIError())
}
return
}
let message = "{\"id\": 1, \"method\": \"mempool.get_fee_histogram\", \"params\": []}\n"
guard let data = message.data(using: .utf8), await client.send(data: data) else {
print("Message sending failed to \(host):\(portToUse) with SSL supported: \(isSSLSupported).")
completion(nil, APIError())
return
}
print("Message sent successfully to \(host):\(portToUse) with SSL: \(isSSLSupported).")
do {
let receivedData = try await client.receive()
print("Data received. Parsing...")
guard let json = try JSONSerialization.jsonObject(with: receivedData, options: .allowFragments) as? [String: AnyObject],
let feeHistogram = json["result"] as? [[Double]] else {
print("Failed to parse response from \(host).")
completion(nil, APIError())
return
}
let fastestFee = calcEstimateFeeFromFeeHistogram(numberOfBlocks: 1, feeHistogram: feeHistogram)
let marketData = MarketData(nextBlock: String(format: "%.0f", fastestFee), sats: "0", price: "0", rate: 0, dateString: "")
print("Parsed MarketData: \(marketData)")
completion(marketData, nil)
} catch {
print("Error receiving data from \(host): \(error.localizedDescription)")
if retries < client.maxRetries {
print("Retrying fetchNextBlockFee (\(retries + 1)/\(client.maxRetries))...")
fetchNextBlockFee(completion: completion, userElectrumSettings: userElectrumSettings, retries: retries + 1)
} else {
completion(nil, APIError())
}
}
let fastestFee = calcEstimateFeeFromFeeHistogram(numberOfBlocks: 1, feeHistogram: feeHistogram)
print("Calculated fastest fee: \(fastestFee)")
return MarketData(nextBlock: String(format: "%.0f", fastestFee), sats: "0", price: "0", rate: 0, dateString: "")
} catch {
print("Error during fetchNextBlockFee: \(error.localizedDescription)")
throw APIError()
}
}
static func fetchMarketData(currency: String, completion: @escaping ((MarketData?, Error?) -> Void)) {
static func fetchMarketData(currency: String) async throws -> MarketData {
var marketDataEntry = MarketData(nextBlock: "...", sats: "...", price: "...", rate: 0)
MarketAPI.fetchPrice(currency: currency, completion: { (result, error) in
if let result = result {
marketDataEntry.rate = result.rateDouble
marketDataEntry.price = result.formattedRate ?? "!"
do {
if let priceResult = try await fetchPrice(currency: currency) {
marketDataEntry.rate = priceResult.rateDouble
marketDataEntry.price = priceResult.formattedRate ?? "!"
print("Fetched price data: rateDouble=\(priceResult.rateDouble), formattedRate=\(priceResult.formattedRate ?? "nil")")
}
MarketAPI.fetchNextBlockFee { (marketData, error) in
if let nextBlock = marketData?.nextBlock {
marketDataEntry.nextBlock = nextBlock
} else {
marketDataEntry.nextBlock = "!"
}
if let rateDouble = result?.rateDouble {
marketDataEntry.sats = numberFormatter.string(from: NSNumber(value: Double(10 / rateDouble) * 10000000)) ?? "!"
}
completion(marketDataEntry, nil)
} catch {
print("Error fetching price: \(error.localizedDescription)")
}
do {
let nextBlockData = try await fetchNextBlockFee()
marketDataEntry.nextBlock = nextBlockData.nextBlock
print("Fetched next block fee data: nextBlock=\(nextBlockData.nextBlock)")
} catch {
print("Error fetching next block fee: \(error.localizedDescription)")
marketDataEntry.nextBlock = "!"
}
marketDataEntry.sats = numberFormatter.string(from: NSNumber(value: Double(10 / marketDataEntry.rate) * 10000000)) ?? "!"
print("Calculated sats: \(marketDataEntry.sats)")
return marketDataEntry
}
static func fetchMarketData(currency: String, completion: @escaping (Result<MarketData, Error>) -> ()) {
Task {
do {
let marketData = try await fetchMarketData(currency: currency)
completion(.success(marketData))
} catch {
completion(.failure(error))
}
})
}
}
}

View file

@ -14,18 +14,22 @@ struct UserDefaultsElectrumSettings {
var sslPort: UInt16?
}
let hardcodedPeers = [
UserDefaultsElectrumSettings(host: "mainnet.foundationdevices.com", port: 50001, sslPort: 50002),
UserDefaultsElectrumSettings(host: "electrum.jochen-hoenicke.de", port: 50001, sslPort: 50006),
let hardcodedPeers = DefaultElectrumPeers.map { settings in
(
host: settings.host ?? "",
port: settings.sslPort ?? settings.port ?? 50001,
useSSL: settings.sslPort != nil
)
}
let DefaultElectrumPeers = [
UserDefaultsElectrumSettings(host: "mainnet.foundationdevices.com", port: 50001, sslPort: 50002),
// UserDefaultsElectrumSettings(host: "electrum.jochen-hoenicke.de", port: 50001, sslPort: 50006),
UserDefaultsElectrumSettings(host: "electrum1.bluewallet.io", port: 50001, sslPort: 443),
UserDefaultsElectrumSettings(host: "electrum.acinq.co", port: 50001, sslPort: 50002),
UserDefaultsElectrumSettings(host: "electrum.bitaroo.net", port: 50001, sslPort: 50002),
]
let DefaultElectrumPeers = [
UserDefaultsElectrumSettings(host: "electrum1.bluewallet.io", port: 50001, sslPort: 443), //
] + hardcodedPeers
class UserDefaultsGroup {
static private let suite = UserDefaults(suiteName: UserDefaultsGroupKey.GroupName.rawValue)

View file

@ -8,7 +8,6 @@
import Foundation
struct WidgetDataStore: Codable {
let rate: String
let lastUpdate: String
@ -60,3 +59,4 @@ struct WidgetDataStore: Codable {
}
}

View file

@ -10,105 +10,103 @@ import WidgetKit
import SwiftUI
struct MarketWidgetProvider: TimelineProvider {
static var lastSuccessfulEntry: MarketWidgetEntry?
static var lastSuccessfulEntry: MarketWidgetEntry?
func placeholder(in context: Context) -> MarketWidgetEntry {
return MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "26", sats: "9 134", price: "$10 000", rate: 10000))
}
func getSnapshot(in context: Context, completion: @escaping (MarketWidgetEntry) -> ()) {
let entry: MarketWidgetEntry
if (context.isPreview) {
entry = MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "26", sats: "9 134", price: "$10 000", rate: 10000))
} else {
entry = MarketWidgetEntry(date: Date(), marketData: emptyMarketData)
func placeholder(in context: Context) -> MarketWidgetEntry {
return MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "26", sats: "9 134", price: "$10 000", rate: 10000))
}
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [MarketWidgetEntry] = []
if context.isPreview {
let entry = MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "26", sats: "9 134", price: "$10 000", rate: 10000))
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
} else {
let userPreferredCurrency = Currency.getUserPreferredCurrency()
fetchMarketDataWithRetry(currency: userPreferredCurrency, retries: 3) { (entry) in
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
}
private func fetchMarketDataWithRetry(currency: String, retries: Int, completion: @escaping (MarketWidgetEntry) -> ()) {
MarketAPI.fetchMarketData(currency: currency) { (result, error) in
if let result = result {
let entry = MarketWidgetEntry(date: Date(), marketData: result)
MarketWidgetProvider.lastSuccessfulEntry = entry
completion(entry)
} else if retries > 0 {
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
self.fetchMarketDataWithRetry(currency: currency, retries: retries - 1, completion: completion)
}
} else {
if let lastEntry = MarketWidgetProvider.lastSuccessfulEntry {
completion(lastEntry)
} else {
let entry = MarketWidgetEntry(date: Date(), marketData: emptyMarketData)
completion(entry)
}
}
}
}
func getSnapshot(in context: Context, completion: @escaping (MarketWidgetEntry) -> ()) {
let entry: MarketWidgetEntry
if context.isPreview {
entry = MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "26", sats: "9 134", price: "$10 000", rate: 10000))
} else {
entry = MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "...", sats: "...", price: "...", rate: 0))
}
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let currentDate = Date()
var entries: [MarketWidgetEntry] = []
let marketDataEntry = MarketWidgetEntry(date: currentDate, marketData: MarketData(nextBlock: "...", sats: "...", price: "...", rate: 0))
entries.append(marketDataEntry) // Initial placeholder entry
let userPreferredCurrency = Currency.getUserPreferredCurrency()
fetchMarketDataWithRetry(currency: userPreferredCurrency, retries: 3) { marketData in
let entry = MarketWidgetEntry(date: Date(), marketData: marketData)
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
private func fetchMarketDataWithRetry(currency: String, retries: Int, completion: @escaping (MarketData) -> ()) {
var attempt = 0
func attemptFetch() {
attempt += 1
print("Attempt \(attempt) to fetch market data.")
MarketAPI.fetchMarketData(currency: currency) { result in
switch result {
case .success(let marketData):
print("Successfully fetched market data on attempt \(attempt).")
completion(marketData)
case .failure(let error):
print("Fetch market data failed (attempt \(attempt)): \(error.localizedDescription)")
if attempt < retries {
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
attemptFetch()
}
} else {
print("Failed to fetch market data after \(retries) attempts.")
let fallbackData = MarketData(nextBlock: "...", sats: "...", price: "...", rate: 0)
completion(fallbackData)
}
}
}
}
attemptFetch()
}
}
struct MarketWidgetEntry: TimelineEntry {
let date: Date
let marketData: MarketData
let date: Date
var marketData: MarketData
}
struct MarketWidgetEntryView : View {
var entry: MarketWidgetProvider.Entry
var MarketStack: some View {
MarketView(marketData: entry.marketData).padding(EdgeInsets(top: 18, leading: 11, bottom: 18, trailing: 11))
struct MarketWidgetEntryView: View {
var entry: MarketWidgetEntry
var MarketStack: some View {
MarketView(marketData: entry.marketData)
}
var body: some View {
VStack(content: {
MarketStack.background(Color.widgetBackground)
MarketStack.containerBackground(Color.widgetBackground, for: .widget)
})
}
}
struct MarketWidget: Widget {
let kind: String = "MarketWidget"
var body: some WidgetConfiguration {
if #available(iOSApplicationExtension 16.0, *) {
return StaticConfiguration(kind: kind, provider: MarketWidgetProvider()) { entry in
MarketWidgetEntryView(entry: entry)
}
.configurationDisplayName("Market")
.description("View the current market information.").supportedFamilies([.systemSmall])
.contentMarginsDisabledIfAvailable()
} else {
return StaticConfiguration(kind: kind, provider: MarketWidgetProvider()) { entry in
MarketWidgetEntryView(entry: entry)
}
.configurationDisplayName("Market")
.description("View the current market information.").supportedFamilies([.systemSmall])
.contentMarginsDisabledIfAvailable()
let kind: String = "MarketWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: MarketWidgetProvider()) { entry in
MarketWidgetEntryView(entry: entry)
}
.configurationDisplayName("Market")
.description("View the current market information.").supportedFamilies([.systemSmall])
}
}
}
struct MarketWidget_Previews: PreviewProvider {
static var previews: some View {
MarketWidgetEntryView(entry: MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "26", sats: "9 134", price: "$10,000", rate: 0)))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
static var previews: some View {
MarketWidgetEntryView(entry: MarketWidgetEntry(date: Date(), marketData: MarketData(nextBlock: "26", sats: "9,134", price: "$10,000", rate: 0)))
.previewContext(WidgetPreviewContext(family: .systemSmall))
}
}

View file

@ -0,0 +1,56 @@
import Foundation
actor HostManager {
var availableHosts: [(host: String, port: UInt16, useSSL: Bool)]
var hostFailureCounts: [String: Int] = [:]
let maxRetriesPerHost: Int
init(hosts: [(host: String, port: UInt16, useSSL: Bool)], maxRetriesPerHost: Int) {
self.availableHosts = hosts
self.maxRetriesPerHost = maxRetriesPerHost
print("Initialized HostManager with \(hosts.count) hosts.")
}
func getNextHost() -> (host: String, port: UInt16, useSSL: Bool)? {
guard !availableHosts.isEmpty else {
print("No available hosts to retrieve.")
return nil
}
var attempts = availableHosts.count
while attempts > 0 {
let currentHost = availableHosts.removeFirst()
if !shouldSkipHost(currentHost.host) {
availableHosts.append(currentHost)
print("Selected host: \(currentHost.host):\(currentHost.port) (SSL: \(currentHost.useSSL))")
return currentHost
} else {
availableHosts.append(currentHost)
attempts -= 1
print("Host \(currentHost.host) is skipped due to max retries.")
}
}
print("All hosts have been exhausted after max retries.")
return nil
}
func shouldSkipHost(_ host: String) -> Bool {
if let failureCount = hostFailureCounts[host], failureCount >= maxRetriesPerHost {
print("Host \(host) has reached max retries (\(failureCount)). It will be skipped.")
return true
}
return false
}
func resetFailureCount(for host: String) {
hostFailureCounts[host] = 0
print("Reset failure count for host \(host).")
}
func incrementFailureCount(for host: String) {
hostFailureCounts[host, default: 0] += 1
let newCount = hostFailureCounts[host]!
print("Incremented failure count for host \(host). New count: \(newCount)")
}
}

View file

@ -24,16 +24,63 @@ enum SwiftTCPClientError: Error, LocalizedError {
}
}
actor HostManager {
var availableHosts: [(host: String, port: UInt16, useSSL: Bool)]
var hostFailureCounts: [String: Int] = [:]
let maxRetriesPerHost: Int
init(hosts: [(host: String, port: UInt16, useSSL: Bool)], maxRetriesPerHost: Int) {
self.availableHosts = hosts
self.maxRetriesPerHost = maxRetriesPerHost
}
func getNextHost() -> (host: String, port: UInt16, useSSL: Bool)? {
guard !availableHosts.isEmpty else {
return nil
}
// Rotate the first host to the end
let currentHost = availableHosts.removeFirst()
availableHosts.append(currentHost)
return currentHost
}
func shouldSkipHost(_ host: String) -> Bool {
if let failureCount = hostFailureCounts[host], failureCount >= maxRetriesPerHost {
return true
}
return false
}
func resetFailureCount(for host: String) {
hostFailureCounts[host] = 0
}
func incrementFailureCount(for host: String) {
hostFailureCounts[host, default: 0] += 1
}
}
class SwiftTCPClient {
private var connection: NWConnection?
private let queue = DispatchQueue(label: "SwiftTCPClientQueue", qos: .userInitiated)
private let readTimeout: TimeInterval = 5.0
let maxRetries = 3
private let hostManager: HostManager
init(hosts: [(host: String, port: UInt16, useSSL: Bool)] = [], maxRetriesPerHost: Int = 3) {
self.hostManager = HostManager(hosts: hosts, maxRetriesPerHost: maxRetriesPerHost)
}
func connect(to host: String, port: UInt16, useSSL: Bool = false, validateCertificates: Bool = true, retries: Int = 0) async -> Bool {
// Skip host if it has failed too many times
if await hostManager.shouldSkipHost(host) {
print("Skipping host \(host) after \(hostManager.maxRetriesPerHost) retries.")
return false
}
func connect(to host: String, port: UInt16, useSSL: Bool = false, retries: Int = 0) async -> Bool {
let parameters: NWParameters
if useSSL {
parameters = NWParameters(tls: createTLSOptions(), tcp: .init())
parameters = NWParameters(tls: createTLSOptions(validateCertificates: validateCertificates), tcp: .init())
} else {
parameters = NWParameters.tcp
}
@ -45,66 +92,120 @@ class SwiftTCPClient {
connection = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: parameters)
connection?.start(queue: queue)
let serialQueue = DispatchQueue(label: "SwiftTCPClient.connect.serialQueue", qos: .userInitiated)
var hasResumed = false
print("Attempting to connect to \(host):\(port) (SSL: \(useSSL))")
do {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
try await withCheckedThrowingContinuation { continuation in
connection?.stateUpdateHandler = { [weak self] state in
guard let self = self else { return }
serialQueue.async {
if !hasResumed {
switch state {
case .ready:
self.connection?.stateUpdateHandler = nil
hasResumed = true
continuation.resume()
case .failed(let error):
self.connection?.stateUpdateHandler = nil
hasResumed = true
continuation.resume(throwing: error)
case .cancelled:
self.connection?.stateUpdateHandler = nil
hasResumed = true
continuation.resume(throwing: SwiftTCPClientError.connectionCancelled)
default:
break
}
switch state {
case .ready:
print("Successfully connected to \(host):\(port)")
self.connection?.stateUpdateHandler = nil
continuation.resume()
case .failed(let error):
if let nwError = error as? NWError, self.isTLSError(nwError) {
print("SSL Error while connecting to \(host):\(port) - \(error.localizedDescription)")
}
print("Connection to \(host):\(port) failed with error: \(error.localizedDescription)")
self.connection?.stateUpdateHandler = nil
continuation.resume(throwing: error)
case .cancelled:
print("Connection to \(host):\(port) was cancelled.")
self.connection?.stateUpdateHandler = nil
continuation.resume(throwing: SwiftTCPClientError.connectionCancelled)
default:
break
}
}
}
// Reset failure count on successful connection
await hostManager.resetFailureCount(for: host)
return true
} catch {
print("Connection failed with error: \(error.localizedDescription)")
if retries < maxRetries {
print("Retrying connection (\(retries + 1)/\(maxRetries))...")
return await connect(to: host, port: port, useSSL: useSSL, retries: retries + 1)
print("Connection to \(host) failed with error: \(error.localizedDescription)")
await hostManager.incrementFailureCount(for: host)
if retries < maxRetries - 1 {
print("Retrying connection to \(host) (\(retries + 1)/\(maxRetries))...")
return await connect(to: host, port: port, useSSL: useSSL, validateCertificates: validateCertificates, retries: retries + 1)
} else {
print("Host \(host) failed after \(maxRetries) retries. Skipping.")
return false
}
}
}
private func isTLSError(_ error: NWError) -> Bool {
let nsError = error as NSError
let code = nsError.code
if #available(iOS 16.4, *) {
switch code {
case 20, 21, 22:
return true
case 1, 2, 3, 4:
return false
default:
return false
}
} else {
switch code {
case 20, 21, 22:
return true
default:
return false
}
}
}
func connectToNextAvailable(validateCertificates: Bool = true) async -> Bool {
while true {
guard let currentHost = await hostManager.getNextHost() else {
print("No available hosts to connect.")
return false
}
if await hostManager.shouldSkipHost(currentHost.host) {
print("Skipping host \(currentHost.host) after \(hostManager.maxRetriesPerHost) retries.")
continue
}
print("Attempting to connect to next available host: \(currentHost.host):\(currentHost.port) (SSL: \(currentHost.useSSL))")
if await connect(to: currentHost.host, port: currentHost.port, useSSL: currentHost.useSSL, validateCertificates: validateCertificates) {
print("Connected to host \(currentHost.host):\(currentHost.port)")
await hostManager.resetFailureCount(for: currentHost.host)
return true
} else {
print("Failed to connect to host \(currentHost.host):\(currentHost.port)")
await hostManager.incrementFailureCount(for: currentHost.host)
}
return false
}
}
func send(data: Data) async -> Bool {
guard let connection = connection else {
print("Send failed: No active connection.")
print("Send failed: No active connection.")
return false
}
do {
print("Sending data: \(data)")
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
connection.send(content: data, completion: .contentProcessed { error in
if let error = error {
print("Send error: \(error.localizedDescription)")
print("Send error: \(error.localizedDescription)")
continuation.resume(throwing: error)
} else {
print("Data sent successfully.")
continuation.resume()
}
})
}
return true
} catch {
print("Send failed with error: \(error.localizedDescription)")
print("Send failed with error: \(error.localizedDescription)")
return false
}
}
@ -114,21 +215,27 @@ class SwiftTCPClient {
throw SwiftTCPClientError.connectionNil
}
print("Attempting to receive data...")
return try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, error in
if let error = error {
print("Receive error: \(error.localizedDescription)")
continuation.resume(throwing: SwiftTCPClientError.unknown(error))
return
}
if let data = data, !data.isEmpty {
print("Received data: \(data)")
continuation.resume(returning: data)
} else if isComplete {
print("Connection closed by peer.")
self.close()
continuation.resume(throwing: SwiftTCPClientError.noDataReceived)
} else {
print("Read timed out.")
continuation.resume(throwing: SwiftTCPClientError.readTimedOut)
}
}
@ -137,25 +244,35 @@ class SwiftTCPClient {
group.addTask {
try await Task.sleep(nanoseconds: UInt64(self.readTimeout * 1_000_000_000))
print("Receive operation timed out after \(self.readTimeout) seconds.")
throw SwiftTCPClientError.readTimedOut
}
if let firstResult = try await group.next() {
group.cancelAll()
print("Receive operation completed successfully.")
return firstResult
} else {
print("Receive operation timed out.")
throw SwiftTCPClientError.readTimedOut
}
}
}
func close() {
print("Closing connection.")
connection?.cancel()
connection = nil
}
private func createTLSOptions() -> NWProtocolTLS.Options {
private func createTLSOptions(validateCertificates: Bool = true) -> NWProtocolTLS.Options {
let tlsOptions = NWProtocolTLS.Options()
if (!validateCertificates) {
sec_protocol_options_set_verify_block(tlsOptions.securityProtocolOptions, { _, _, completion in
completion(true)
}, DispatchQueue.global())
print("SSL certificate validation is disabled.")
}
return tlsOptions
}
}

View file

@ -30,7 +30,7 @@ struct MarketView: View {
HStack(alignment: .center, spacing: 0, content: {
Text("Sats/\(Currency.getUserPreferredCurrency())").bold().lineLimit(1).font(Font.system(size:11, weight: .medium, design: .default)).foregroundColor(.textColor)
Spacer()
Text(marketData.sats == "..." ? "..." : marketData.sats).padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)).lineLimit(1).minimumScaleFactor(0.1).foregroundColor(.widgetBackground).font(Font.system(size:11, weight: .semibold, design: .default)).background(Color(red: 0.97, green: 0.21, blue: 0.38)).overlay(
Text( marketData.sats).padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)).lineLimit(1).minimumScaleFactor(0.1).foregroundColor(.widgetBackground).font(Font.system(size:11, weight: .semibold, design: .default)).background(Color(red: 0.97, green: 0.21, blue: 0.38)).overlay(
RoundedRectangle(cornerRadius: 4.0)
.stroke(Color.containerRed, lineWidth: 4.0))
})
@ -38,7 +38,7 @@ struct MarketView: View {
HStack(alignment: .center, spacing: 0, content: {
Text("Price").bold().lineLimit(1).font(Font.system(size:11, weight: . medium, design: .default)).foregroundColor(.textColor)
Spacer()
Text(marketData.price == "..." ? "..." : marketData.price).padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)).lineLimit(1).minimumScaleFactor(0.1).foregroundColor(.widgetBackground).font(Font.system(size:11, weight: .semibold, design: .default)).background(Color(red: 0.29, green: 0.86, blue: 0.73)).overlay(
Text( marketData.price).padding(EdgeInsets(top: 2, leading: 4, bottom: 2, trailing: 4)).lineLimit(1).minimumScaleFactor(0.1).foregroundColor(.widgetBackground).font(Font.system(size:11, weight: .semibold, design: .default)).background(Color(red: 0.29, green: 0.86, blue: 0.73)).overlay(
RoundedRectangle(cornerRadius:4.0)
.stroke(Color.containerGreen, lineWidth: 4.0))
})

View file

@ -11,7 +11,20 @@ import SwiftUI
struct WalletInformationAndMarketWidgetProvider: TimelineProvider {
typealias Entry = WalletInformationAndMarketWidgetEntry
static var lastSuccessfulEntries: [WalletInformationAndMarketWidgetEntry] = []
actor LastSuccessfulEntryStore {
private var lastSuccessfulEntry: WalletInformationAndMarketWidgetEntry?
func getLastSuccessfulEntry() -> WalletInformationAndMarketWidgetEntry? {
return lastSuccessfulEntry
}
func setLastSuccessfulEntry(_ entry: WalletInformationAndMarketWidgetEntry) {
lastSuccessfulEntry = entry
}
}
let entryStore = LastSuccessfulEntryStore()
func placeholder(in context: Context) -> WalletInformationAndMarketWidgetEntry {
return WalletInformationAndMarketWidgetEntry.placeholder
@ -36,30 +49,54 @@ struct WalletInformationAndMarketWidgetProvider: TimelineProvider {
completion(timeline)
} else {
let userPreferredCurrency = Currency.getUserPreferredCurrency()
let allwalletsBalance = WalletData(balance: UserDefaultsGroup.getAllWalletsBalance(), latestTransactionTime: UserDefaultsGroup.getAllWalletsLatestTransactionTime())
let allWalletsBalance = WalletData(balance: UserDefaultsGroup.getAllWalletsBalance(), latestTransactionTime: UserDefaultsGroup.getAllWalletsLatestTransactionTime())
MarketAPI.fetchMarketData(currency: userPreferredCurrency) { (result, error) in
let entry: WalletInformationAndMarketWidgetEntry
if let result = result {
entry = WalletInformationAndMarketWidgetEntry(date: Date(), marketData: result, allWalletsBalance: allwalletsBalance)
WalletInformationAndMarketWidgetProvider.lastSuccessfulEntries.append(entry)
if WalletInformationAndMarketWidgetProvider.lastSuccessfulEntries.count > 5 {
WalletInformationAndMarketWidgetProvider.lastSuccessfulEntries.removeFirst()
}
} else {
if let lastEntry = WalletInformationAndMarketWidgetProvider.lastSuccessfulEntries.last {
entry = lastEntry
} else {
entry = WalletInformationAndMarketWidgetEntry.placeholder
}
fetchMarketDataWithRetry(currency: userPreferredCurrency, retries: 3) { marketData in
let entry = WalletInformationAndMarketWidgetEntry(date: Date(), marketData: marketData, allWalletsBalance: allWalletsBalance)
Task {
await entryStore.setLastSuccessfulEntry(entry)
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
entries.append(entry)
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
}
private func fetchMarketDataWithRetry(currency: String, retries: Int, completion: @escaping (MarketData) -> ()) {
var attempt = 0
func attemptFetch() {
attempt += 1
print("Attempt \(attempt) to fetch market data.")
MarketAPI.fetchMarketData(currency: currency) { result in
switch result {
case .success(let marketData):
print("Successfully fetched market data on attempt \(attempt).")
completion(marketData)
case .failure(let error):
print("Error fetching market data: \(error.localizedDescription). Retry \(attempt)/\(retries)")
if attempt < retries {
DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
attemptFetch()
}
} else {
print("Max retries reached.")
Task {
if let lastEntry = await entryStore.getLastSuccessfulEntry() {
completion(lastEntry.marketData)
} else {
completion(WalletInformationAndMarketWidgetEntry.placeholder.marketData)
}
}
}
}
}
}
attemptFetch()
}
}
struct WalletInformationAndMarketWidgetEntry: TimelineEntry {