craft-native 0.0.118 → 0.0.120

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.
@@ -3,6 +3,8 @@ import JavaScriptCore
3
3
  import SwiftUI
4
4
  import UIKit
5
5
 
6
+ private let craftNativeCapabilityTimeoutMilliseconds = 30_000
7
+
6
8
  /// A WebView-free host for the first stx-native vertical slice. The bundled
7
9
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
8
10
  /// events and Craft API replies through JavaScriptCore.
@@ -83,6 +85,11 @@ final class CraftNativeScreenController: UIViewController {
83
85
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
84
86
  private var flatListOwners: [String: String] = [:]
85
87
  private let mutationDocument = CraftNativeMutationDocument()
88
+ private let capabilityScope = UUID().uuidString
89
+ private var pendingCapabilityRequests = Set<String>()
90
+ private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
91
+ private var lifecycleObservers: [NSObjectProtocol] = []
92
+ private var deepLinkListener: UUID?
86
93
 
87
94
  init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
88
95
  self.config = config
@@ -97,6 +104,10 @@ final class CraftNativeScreenController: UIViewController {
97
104
 
98
105
  deinit {
99
106
  imageTasks.values.forEach { $0.cancel() }
107
+ pendingCapabilityDeadlines.values.forEach { $0.cancel() }
108
+ pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
109
+ lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
110
+ if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
100
111
  }
101
112
 
102
113
  override func viewDidLoad() {
@@ -116,6 +127,7 @@ final class CraftNativeScreenController: UIViewController {
116
127
  ])
117
128
  setupJavaScript()
118
129
  loadBundle()
130
+ observeNativeEvents()
119
131
  }
120
132
 
121
133
  private func setupJavaScript() {
@@ -130,10 +142,18 @@ final class CraftNativeScreenController: UIViewController {
130
142
  self?.receive(json)
131
143
  }
132
144
  jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
145
+ let capabilities = CraftNativeActions.capabilities(config: config)
146
+ let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
147
+ let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
133
148
  jsContext.evaluateScript("""
134
149
  globalThis.__stxNativeCallback = null;
135
150
  globalThis.__stxNativeBridge = {
151
+ platform: "ios",
136
152
  mutationProtocolVersion: 1,
153
+ capabilityProtocolVersion: \(craftNativeCapabilityProtocolVersion),
154
+ capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
155
+ capabilities: \(capabilityJSON),
156
+ initialAppState: "\(CraftNativeActions.currentAppState())",
137
157
  postMessage: function(message) { craftNativePostMessage(message); },
138
158
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
139
159
  };
@@ -156,6 +176,31 @@ final class CraftNativeScreenController: UIViewController {
156
176
  }
157
177
  }
158
178
 
179
+ private func observeNativeEvents() {
180
+ func observe(_ name: Notification.Name, state: String) {
181
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
182
+ forName: name,
183
+ object: nil,
184
+ queue: .main
185
+ ) { [weak self] _ in self?.sendAppState(state) })
186
+ }
187
+ observe(UIApplication.didBecomeActiveNotification, state: "active")
188
+ observe(UIApplication.willResignActiveNotification, state: "inactive")
189
+ observe(UIApplication.didEnterBackgroundNotification, state: "background")
190
+ sendAppState(CraftNativeActions.currentAppState())
191
+ deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
192
+ guard let self = self, self.navigationController?.topViewController === self else { return }
193
+ var payload = CraftNativeActions.deepLinkData(url)
194
+ payload["initial"] = initial
195
+ self.send(type: "DEEP_LINK", payload: payload)
196
+ }
197
+ }
198
+
199
+ private func sendAppState(_ state: String) {
200
+ guard navigationController?.topViewController === self else { return }
201
+ send(type: "APP_STATE", payload: ["state": state])
202
+ }
203
+
159
204
  private func showError(_ message: String) {
160
205
  let label = UILabel()
161
206
  label.numberOfLines = 0
@@ -219,30 +264,56 @@ final class CraftNativeScreenController: UIViewController {
219
264
  case "API_REQUEST":
220
265
  guard let id = message["id"] as? String else { return }
221
266
  let args = payload["args"] as? [Any] ?? []
222
- let route: (action: String, body: [String: Any])?
223
- switch (payload["module"] as? String, payload["method"] as? String) {
224
- case ("Device", "getInfo"):
225
- route = ("getDeviceInfo", [:])
226
- case ("Haptics", "impact"):
227
- route = ("haptic", ["style": args.first ?? NSNull()])
228
- case ("Clipboard", "write"):
229
- route = ("clipboardWrite", ["text": args.first ?? NSNull()])
230
- case ("Clipboard", "read"):
231
- route = ("clipboardRead", [:])
232
- default:
233
- route = nil
234
- }
235
- guard let route = route,
236
- let answer = CraftNativeActions.perform(action: route.action, body: route.body, config: config) else {
237
- send(type: "API_ERROR", payload: ["requestId": id, "code": "UNKNOWN_ACTION", "message": "Unsupported native API"], correlationId: id)
238
- return
267
+ let requestToken = "\(capabilityScope)/\(id)"
268
+ pendingCapabilityRequests.insert(requestToken)
269
+ let deadline = DispatchWorkItem { [weak self] in
270
+ guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
271
+ self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)
272
+ CraftNativeActions.cancel(requestToken: requestToken)
273
+ self.send(type: "API_ERROR", payload: [
274
+ "version": craftNativeCapabilityProtocolVersion,
275
+ "requestId": id,
276
+ "code": "TIMEOUT",
277
+ "message": "Native API request timed out",
278
+ ], correlationId: id)
239
279
  }
240
- switch answer {
241
- case .success(let data):
242
- send(type: "API_RESPONSE", payload: ["requestId": id, "data": data], correlationId: id)
243
- case .failure(let error):
244
- send(type: "API_ERROR", payload: ["requestId": id, "code": error.code, "message": error.message], correlationId: id)
280
+ pendingCapabilityDeadlines[requestToken] = deadline
281
+ DispatchQueue.main.asyncAfter(
282
+ deadline: .now() + .milliseconds(craftNativeCapabilityTimeoutMilliseconds),
283
+ execute: deadline
284
+ )
285
+ CraftNativeActions.perform(
286
+ requestToken: requestToken,
287
+ version: payload["version"] as? Int ?? craftNativeCapabilityProtocolVersion,
288
+ module: payload["module"] as? String ?? "",
289
+ method: payload["method"] as? String ?? "",
290
+ args: args,
291
+ config: config
292
+ ) { [weak self] answer in
293
+ guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
294
+ self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
295
+ switch answer {
296
+ case .success(let data):
297
+ self.send(type: "API_RESPONSE", payload: [
298
+ "version": craftNativeCapabilityProtocolVersion,
299
+ "requestId": id,
300
+ "data": data,
301
+ ], correlationId: id)
302
+ case .failure(let error):
303
+ self.send(type: "API_ERROR", payload: [
304
+ "version": craftNativeCapabilityProtocolVersion,
305
+ "requestId": id,
306
+ "code": error.code,
307
+ "message": error.message,
308
+ ], correlationId: id)
309
+ }
245
310
  }
311
+ case "API_CANCEL":
312
+ guard let id = payload["requestId"] as? String else { return }
313
+ let requestToken = "\(capabilityScope)/\(id)"
314
+ pendingCapabilityRequests.remove(requestToken)
315
+ pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
316
+ CraftNativeActions.cancel(requestToken: requestToken)
246
317
  default:
247
318
  break
248
319
  }
@@ -43,6 +43,7 @@
43
43
  <false/>
44
44
  {{USAGE_DESCRIPTIONS}}
45
45
  {{URL_TYPES}}
46
+ {{APP_BOUND_DOMAINS}}
46
47
  {{BACKGROUND_MODES}}
47
48
  {{LIVE_ACTIVITY_SUPPORT}}
48
49
  <key>NSAppTransportSecurity</key>
package/dist/mobile.js CHANGED
@@ -326,6 +326,17 @@ var secureStorage = {
326
326
  throw new Error("Craft secure storage bridge is unavailable");
327
327
  localStorage.removeItem(`secure_${key}`);
328
328
  },
329
+ async remove(key) {
330
+ const craft = getCraftMobile();
331
+ if (typeof window !== "undefined" && craft?.secureStorage) {
332
+ if (typeof craft.secureStorage.remove === "function")
333
+ return craft.secureStorage.remove(key);
334
+ return craft.secureStorage.delete(key);
335
+ }
336
+ if (typeof window !== "undefined" && window.craft)
337
+ throw new Error("Craft secure storage bridge is unavailable");
338
+ localStorage.removeItem(`secure_${key}`);
339
+ },
329
340
  async clear() {
330
341
  const craft = getCraftMobile();
331
342
  if (typeof window !== "undefined" && craft?.secureStorage) {
@@ -525,6 +536,20 @@ var notifications = {
525
536
  }
526
537
  throw new Error("Scheduled notifications not available in web");
527
538
  },
539
+ async cancel(id) {
540
+ const craft = getCraftMobile();
541
+ if (typeof window !== "undefined" && craft?.notifications) {
542
+ return craft.notifications.cancel(id);
543
+ }
544
+ throw new Error("Scheduled notifications not available in web");
545
+ },
546
+ async pending() {
547
+ const craft = getCraftMobile();
548
+ if (typeof window !== "undefined" && craft?.notifications) {
549
+ return craft.notifications.pending();
550
+ }
551
+ return [];
552
+ },
528
553
  async cancelAll() {
529
554
  const craft = getCraftMobile();
530
555
  if (typeof window !== "undefined" && craft?.notifications) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.118",
3
+ "version": "0.0.120",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",