@sigx/lynx-updates 0.6.1 → 0.7.0
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/LICENSE +21 -21
- package/README.md +162 -162
- package/android/com/sigx/updates/UpdateDownloader.kt +154 -154
- package/android/com/sigx/updates/UpdateStore.kt +367 -367
- package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -25
- package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -18
- package/android/com/sigx/updates/UpdatesEventBus.kt +54 -54
- package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -42
- package/android/com/sigx/updates/UpdatesModule.kt +235 -235
- package/dist/controller.js +1 -1
- package/dist/controller.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/updates.d.ts +20 -11
- package/dist/updates.d.ts.map +1 -1
- package/dist/updates.js +22 -13
- package/dist/updates.js.map +1 -1
- package/ios/UpdateDownloader.swift +152 -152
- package/ios/UpdateStore.swift +286 -286
- package/ios/UpdatesBundleResolver.swift +15 -15
- package/ios/UpdatesEventBus.swift +59 -59
- package/ios/UpdatesLifecyclePublisher.swift +48 -48
- package/ios/UpdatesModule.swift +178 -178
- package/package.json +3 -3
- package/signalx-module.json +35 -35
package/dist/updates.js
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public
|
|
3
|
-
*
|
|
2
|
+
* Public API — `defineUpdates()` (the boot-time declaration, in the
|
|
3
|
+
* `defineApp`/`defineRoutes` family) and the `Updates` runtime object (a
|
|
4
|
+
* thin facade over the controller, the store and the native module, in the
|
|
5
|
+
* `Haptics`/`Storage` native-module family). See the package README.
|
|
4
6
|
*/
|
|
5
7
|
import * as controller from './controller.js';
|
|
6
8
|
import { getCurrentUpdate, nativeAvailable } from './native.js';
|
|
7
9
|
import { addListener, getStateSnapshot } from './state.js';
|
|
10
|
+
/**
|
|
11
|
+
* Declare the OTA update behavior for this app. Call once in `main.tsx`
|
|
12
|
+
* before `defineApp` — idempotent and synchronous; re-declaring updates the
|
|
13
|
+
* config but never re-runs the boot work (markReady / launch check).
|
|
14
|
+
* Kicks off the configured mode's automatic behavior on a deferred task
|
|
15
|
+
* (never blocks first paint). No-ops gracefully (with one warning) when the
|
|
16
|
+
* native module is absent (web preview, tests).
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```tsx
|
|
20
|
+
* defineUpdates({ provider: { url: 'https://cdn.example.com/updates.json' } });
|
|
21
|
+
* defineApp(<App />).mount(null);
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function defineUpdates(config) {
|
|
25
|
+
controller.configure(config);
|
|
26
|
+
}
|
|
8
27
|
export const Updates = {
|
|
9
|
-
/**
|
|
10
|
-
* Configure the updates client. Idempotent and synchronous; must run
|
|
11
|
-
* before any other call — typically in `main.tsx` before `defineApp`.
|
|
12
|
-
* Kicks off the configured mode's automatic behavior on a deferred task
|
|
13
|
-
* (never blocks first paint). No-ops gracefully (with one warning) when
|
|
14
|
-
* the native module is absent (web preview, tests).
|
|
15
|
-
*/
|
|
16
|
-
configure(config) {
|
|
17
|
-
controller.configure(config);
|
|
18
|
-
},
|
|
19
28
|
/** Ask the provider for the best available update. Works in every mode. */
|
|
20
29
|
checkForUpdate() {
|
|
21
30
|
return controller.checkForUpdate();
|
|
@@ -37,7 +46,7 @@ export const Updates = {
|
|
|
37
46
|
},
|
|
38
47
|
/**
|
|
39
48
|
* Health signal: commits the pending update so native stops counting
|
|
40
|
-
* launch attempts against it. Called automatically after
|
|
49
|
+
* launch attempts against it. Called automatically after defineUpdates()
|
|
41
50
|
* unless `autoMarkReady: false`. Safe to call repeatedly.
|
|
42
51
|
*/
|
|
43
52
|
markReady() {
|
package/dist/updates.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"updates.js","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"updates.js","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,UAAU,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAU3D;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,aAAa,CAAC,MAAqB;IAC/C,UAAU,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;AACjC,CAAC;AAED,MAAM,CAAC,MAAM,OAAO,GAAG;IACnB,2EAA2E;IAC3E,cAAc;QACV,OAAO,UAAU,CAAC,cAAc,EAAE,CAAC;IACvC,CAAC;IAED;;;OAGG;IACH,QAAQ,CAAC,QAAyB;QAC9B,OAAO,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC;IAED;;;;OAIG;IACH,KAAK;QACD,OAAO,UAAU,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;IAED;;;;OAIG;IACH,SAAS;QACL,OAAO,UAAU,CAAC,SAAS,EAAE,CAAC;IAClC,CAAC;IAED,uEAAuE;IACvE,mBAAmB;QACf,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,0EAA0E;IAC1E,YAAY;QACR,OAAO,UAAU,CAAC,YAAY,EAAE,CAAC;IACrC,CAAC;IAED,6EAA6E;IAC7E,QAAQ;QACJ,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,uEAAuE;IACvE,WAAW,CAAC,EAAiC;QACzC,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,CAAC;IAED,sEAAsE;IACtE,WAAW;QACP,OAAO,eAAe,EAAE,CAAC;IAC7B,CAAC;CACK,CAAC"}
|
|
@@ -1,152 +1,152 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import CryptoKit
|
|
3
|
-
|
|
4
|
-
/// Streaming bundle downloader: bytes go straight to `tmp/<id>.partial`
|
|
5
|
-
/// with an incremental SHA-256, then atomically move into `updates/<id>/`
|
|
6
|
-
/// once the hash matches. Single-flight — concurrent calls beyond the first
|
|
7
|
-
/// fail fast.
|
|
8
|
-
final class UpdateDownloader: NSObject, URLSessionDataDelegate {
|
|
9
|
-
|
|
10
|
-
private static let inFlightLock = NSLock()
|
|
11
|
-
private static var inFlight = false
|
|
12
|
-
|
|
13
|
-
private let partialURL: URL
|
|
14
|
-
private var output: FileHandle?
|
|
15
|
-
private var hasher = SHA256()
|
|
16
|
-
private var receivedBytes: Int64 = 0
|
|
17
|
-
private var totalBytes: Int64?
|
|
18
|
-
private var lastProgressAt = Date.distantPast
|
|
19
|
-
private var result: String? = "Download did not complete"
|
|
20
|
-
private let done = DispatchSemaphore(value: 0)
|
|
21
|
-
|
|
22
|
-
private init(partialURL: URL) {
|
|
23
|
-
self.partialURL = partialURL
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/// Synchronous (call off the main thread). Returns nil on success or an
|
|
27
|
-
/// error message (prefixed with E_* codes the module maps to the bridge).
|
|
28
|
-
static func download(
|
|
29
|
-
url: String,
|
|
30
|
-
expectedSha256: String,
|
|
31
|
-
updateId: String,
|
|
32
|
-
headers: [String: String],
|
|
33
|
-
manifestJson: String,
|
|
34
|
-
) -> String? {
|
|
35
|
-
let store = UpdateStore.shared
|
|
36
|
-
|
|
37
|
-
// Already on disk and intact → success without a byte transferred.
|
|
38
|
-
if FileManager.default.fileExists(atPath: store.bundleFile(updateId).path),
|
|
39
|
-
store.verifySha256(updateId) {
|
|
40
|
-
return nil
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
inFlightLock.lock()
|
|
44
|
-
if inFlight {
|
|
45
|
-
inFlightLock.unlock()
|
|
46
|
-
return "E_DOWNLOAD_IN_PROGRESS: another download is running"
|
|
47
|
-
}
|
|
48
|
-
inFlight = true
|
|
49
|
-
inFlightLock.unlock()
|
|
50
|
-
defer {
|
|
51
|
-
inFlightLock.lock()
|
|
52
|
-
inFlight = false
|
|
53
|
-
inFlightLock.unlock()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
guard let requestURL = URL(string: url) else {
|
|
57
|
-
return "Download failed: invalid URL \(url)"
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let fm = FileManager.default
|
|
61
|
-
try? fm.createDirectory(at: store.tmpDir, withIntermediateDirectories: true)
|
|
62
|
-
let partial = store.tmpDir.appendingPathComponent("\(updateId).partial")
|
|
63
|
-
fm.createFile(atPath: partial.path, contents: nil)
|
|
64
|
-
|
|
65
|
-
let downloader = UpdateDownloader(partialURL: partial)
|
|
66
|
-
guard let handle = try? FileHandle(forWritingTo: partial) else {
|
|
67
|
-
return "Download failed: cannot open staging file"
|
|
68
|
-
}
|
|
69
|
-
downloader.output = handle
|
|
70
|
-
|
|
71
|
-
var request = URLRequest(url: requestURL, timeoutInterval: 30)
|
|
72
|
-
for (key, value) in headers {
|
|
73
|
-
request.setValue(value, forHTTPHeaderField: key)
|
|
74
|
-
}
|
|
75
|
-
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
|
|
76
|
-
session.dataTask(with: request).resume()
|
|
77
|
-
downloader.done.wait()
|
|
78
|
-
session.finishTasksAndInvalidate()
|
|
79
|
-
|
|
80
|
-
if let failure = downloader.result {
|
|
81
|
-
try? fm.removeItem(at: partial)
|
|
82
|
-
return failure
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let actual = downloader.hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
86
|
-
guard actual == expectedSha256.lowercased() else {
|
|
87
|
-
try? fm.removeItem(at: partial)
|
|
88
|
-
return "E_HASH_MISMATCH: expected \(expectedSha256), got \(actual)"
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Promote: metadata first, bundle move last.
|
|
92
|
-
let dir = store.updateDir(updateId)
|
|
93
|
-
try? fm.removeItem(at: dir)
|
|
94
|
-
do {
|
|
95
|
-
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
96
|
-
var meta = (try? JSONSerialization.jsonObject(with: Data(manifestJson.utf8))) as? [String: Any] ?? [:]
|
|
97
|
-
meta["sha256"] = expectedSha256.lowercased()
|
|
98
|
-
meta["sizeBytes"] = downloader.receivedBytes
|
|
99
|
-
meta["sourceUrl"] = url
|
|
100
|
-
meta["downloadedAt"] = Int(Date().timeIntervalSince1970 * 1000)
|
|
101
|
-
let metaData = try JSONSerialization.data(withJSONObject: meta)
|
|
102
|
-
try metaData.write(to: store.updateJsonFile(updateId), options: .atomic)
|
|
103
|
-
try fm.moveItem(at: partial, to: store.bundleFile(updateId))
|
|
104
|
-
} catch {
|
|
105
|
-
try? fm.removeItem(at: partial)
|
|
106
|
-
return "Download failed: \(error.localizedDescription)"
|
|
107
|
-
}
|
|
108
|
-
return nil
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// MARK: - URLSessionDataDelegate
|
|
112
|
-
|
|
113
|
-
func urlSession(
|
|
114
|
-
_ session: URLSession, dataTask: URLSessionDataTask,
|
|
115
|
-
didReceive response: URLResponse,
|
|
116
|
-
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void,
|
|
117
|
-
) {
|
|
118
|
-
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
119
|
-
result = "Download failed: HTTP \(http.statusCode)"
|
|
120
|
-
completionHandler(.cancel)
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
totalBytes = response.expectedContentLength >= 0 ? response.expectedContentLength : nil
|
|
124
|
-
completionHandler(.allow)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
128
|
-
output?.write(data)
|
|
129
|
-
hasher.update(data: data)
|
|
130
|
-
receivedBytes += Int64(data.count)
|
|
131
|
-
let now = Date()
|
|
132
|
-
if now.timeIntervalSince(lastProgressAt) >= 0.15 {
|
|
133
|
-
lastProgressAt = now
|
|
134
|
-
UpdatesEventBus.shared.emitProgress(receivedBytes: receivedBytes, totalBytes: totalBytes)
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
139
|
-
try? output?.close()
|
|
140
|
-
if let error {
|
|
141
|
-
// A cancel from didReceive(response:) already set a specific message.
|
|
142
|
-
if result == "Download did not complete" || result == nil {
|
|
143
|
-
result = "Download failed: \(error.localizedDescription)"
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
result = nil
|
|
147
|
-
UpdatesEventBus.shared.emitProgress(
|
|
148
|
-
receivedBytes: receivedBytes, totalBytes: totalBytes ?? receivedBytes)
|
|
149
|
-
}
|
|
150
|
-
done.signal()
|
|
151
|
-
}
|
|
152
|
-
}
|
|
1
|
+
import Foundation
|
|
2
|
+
import CryptoKit
|
|
3
|
+
|
|
4
|
+
/// Streaming bundle downloader: bytes go straight to `tmp/<id>.partial`
|
|
5
|
+
/// with an incremental SHA-256, then atomically move into `updates/<id>/`
|
|
6
|
+
/// once the hash matches. Single-flight — concurrent calls beyond the first
|
|
7
|
+
/// fail fast.
|
|
8
|
+
final class UpdateDownloader: NSObject, URLSessionDataDelegate {
|
|
9
|
+
|
|
10
|
+
private static let inFlightLock = NSLock()
|
|
11
|
+
private static var inFlight = false
|
|
12
|
+
|
|
13
|
+
private let partialURL: URL
|
|
14
|
+
private var output: FileHandle?
|
|
15
|
+
private var hasher = SHA256()
|
|
16
|
+
private var receivedBytes: Int64 = 0
|
|
17
|
+
private var totalBytes: Int64?
|
|
18
|
+
private var lastProgressAt = Date.distantPast
|
|
19
|
+
private var result: String? = "Download did not complete"
|
|
20
|
+
private let done = DispatchSemaphore(value: 0)
|
|
21
|
+
|
|
22
|
+
private init(partialURL: URL) {
|
|
23
|
+
self.partialURL = partialURL
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Synchronous (call off the main thread). Returns nil on success or an
|
|
27
|
+
/// error message (prefixed with E_* codes the module maps to the bridge).
|
|
28
|
+
static func download(
|
|
29
|
+
url: String,
|
|
30
|
+
expectedSha256: String,
|
|
31
|
+
updateId: String,
|
|
32
|
+
headers: [String: String],
|
|
33
|
+
manifestJson: String,
|
|
34
|
+
) -> String? {
|
|
35
|
+
let store = UpdateStore.shared
|
|
36
|
+
|
|
37
|
+
// Already on disk and intact → success without a byte transferred.
|
|
38
|
+
if FileManager.default.fileExists(atPath: store.bundleFile(updateId).path),
|
|
39
|
+
store.verifySha256(updateId) {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
inFlightLock.lock()
|
|
44
|
+
if inFlight {
|
|
45
|
+
inFlightLock.unlock()
|
|
46
|
+
return "E_DOWNLOAD_IN_PROGRESS: another download is running"
|
|
47
|
+
}
|
|
48
|
+
inFlight = true
|
|
49
|
+
inFlightLock.unlock()
|
|
50
|
+
defer {
|
|
51
|
+
inFlightLock.lock()
|
|
52
|
+
inFlight = false
|
|
53
|
+
inFlightLock.unlock()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
guard let requestURL = URL(string: url) else {
|
|
57
|
+
return "Download failed: invalid URL \(url)"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let fm = FileManager.default
|
|
61
|
+
try? fm.createDirectory(at: store.tmpDir, withIntermediateDirectories: true)
|
|
62
|
+
let partial = store.tmpDir.appendingPathComponent("\(updateId).partial")
|
|
63
|
+
fm.createFile(atPath: partial.path, contents: nil)
|
|
64
|
+
|
|
65
|
+
let downloader = UpdateDownloader(partialURL: partial)
|
|
66
|
+
guard let handle = try? FileHandle(forWritingTo: partial) else {
|
|
67
|
+
return "Download failed: cannot open staging file"
|
|
68
|
+
}
|
|
69
|
+
downloader.output = handle
|
|
70
|
+
|
|
71
|
+
var request = URLRequest(url: requestURL, timeoutInterval: 30)
|
|
72
|
+
for (key, value) in headers {
|
|
73
|
+
request.setValue(value, forHTTPHeaderField: key)
|
|
74
|
+
}
|
|
75
|
+
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
|
|
76
|
+
session.dataTask(with: request).resume()
|
|
77
|
+
downloader.done.wait()
|
|
78
|
+
session.finishTasksAndInvalidate()
|
|
79
|
+
|
|
80
|
+
if let failure = downloader.result {
|
|
81
|
+
try? fm.removeItem(at: partial)
|
|
82
|
+
return failure
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let actual = downloader.hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
86
|
+
guard actual == expectedSha256.lowercased() else {
|
|
87
|
+
try? fm.removeItem(at: partial)
|
|
88
|
+
return "E_HASH_MISMATCH: expected \(expectedSha256), got \(actual)"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Promote: metadata first, bundle move last.
|
|
92
|
+
let dir = store.updateDir(updateId)
|
|
93
|
+
try? fm.removeItem(at: dir)
|
|
94
|
+
do {
|
|
95
|
+
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
96
|
+
var meta = (try? JSONSerialization.jsonObject(with: Data(manifestJson.utf8))) as? [String: Any] ?? [:]
|
|
97
|
+
meta["sha256"] = expectedSha256.lowercased()
|
|
98
|
+
meta["sizeBytes"] = downloader.receivedBytes
|
|
99
|
+
meta["sourceUrl"] = url
|
|
100
|
+
meta["downloadedAt"] = Int(Date().timeIntervalSince1970 * 1000)
|
|
101
|
+
let metaData = try JSONSerialization.data(withJSONObject: meta)
|
|
102
|
+
try metaData.write(to: store.updateJsonFile(updateId), options: .atomic)
|
|
103
|
+
try fm.moveItem(at: partial, to: store.bundleFile(updateId))
|
|
104
|
+
} catch {
|
|
105
|
+
try? fm.removeItem(at: partial)
|
|
106
|
+
return "Download failed: \(error.localizedDescription)"
|
|
107
|
+
}
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// MARK: - URLSessionDataDelegate
|
|
112
|
+
|
|
113
|
+
func urlSession(
|
|
114
|
+
_ session: URLSession, dataTask: URLSessionDataTask,
|
|
115
|
+
didReceive response: URLResponse,
|
|
116
|
+
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void,
|
|
117
|
+
) {
|
|
118
|
+
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
119
|
+
result = "Download failed: HTTP \(http.statusCode)"
|
|
120
|
+
completionHandler(.cancel)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
totalBytes = response.expectedContentLength >= 0 ? response.expectedContentLength : nil
|
|
124
|
+
completionHandler(.allow)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
128
|
+
output?.write(data)
|
|
129
|
+
hasher.update(data: data)
|
|
130
|
+
receivedBytes += Int64(data.count)
|
|
131
|
+
let now = Date()
|
|
132
|
+
if now.timeIntervalSince(lastProgressAt) >= 0.15 {
|
|
133
|
+
lastProgressAt = now
|
|
134
|
+
UpdatesEventBus.shared.emitProgress(receivedBytes: receivedBytes, totalBytes: totalBytes)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
139
|
+
try? output?.close()
|
|
140
|
+
if let error {
|
|
141
|
+
// A cancel from didReceive(response:) already set a specific message.
|
|
142
|
+
if result == "Download did not complete" || result == nil {
|
|
143
|
+
result = "Download failed: \(error.localizedDescription)"
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
result = nil
|
|
147
|
+
UpdatesEventBus.shared.emitProgress(
|
|
148
|
+
receivedBytes: receivedBytes, totalBytes: totalBytes ?? receivedBytes)
|
|
149
|
+
}
|
|
150
|
+
done.signal()
|
|
151
|
+
}
|
|
152
|
+
}
|