@craft-native/ios 0.0.241 → 0.0.243

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/dist/cli.js CHANGED
@@ -303,10 +303,25 @@ function hybridConfigProblems(config) {
303
303
  function installNativeBundle(source, output) {
304
304
  if (!existsSync(source))
305
305
  throw new Error(`Native bundle not found: ${source}`);
306
- const destination = join3(output, "dist", "native-screen.js");
307
- mkdirSync(dirname2(destination), { recursive: true });
308
- if (resolve(source) !== resolve(destination))
309
- cpSync(source, destination);
306
+ const single = join3(output, "dist", "native-screen.js");
307
+ const routes = join3(output, "dist", "native-screens");
308
+ mkdirSync(dirname2(single), { recursive: true });
309
+ if (statSync(source).isDirectory()) {
310
+ if (!readdirSync2(source).some((name) => name.endsWith(".js")))
311
+ throw new Error(`Native route bundles not found in ${source}`);
312
+ if (resolve(source) !== resolve(routes)) {
313
+ rmSync(routes, { recursive: true, force: true });
314
+ cpSync(source, routes, { recursive: true });
315
+ }
316
+ rmSync(single, { force: true });
317
+ return;
318
+ }
319
+ if (resolve(source) !== resolve(single))
320
+ cpSync(source, single);
321
+ rmSync(routes, { recursive: true, force: true });
322
+ }
323
+ function hasInstalledNativeBundle(output) {
324
+ return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
310
325
  }
311
326
  var DEFAULT_CONFIG = {
312
327
  renderer: "web",
@@ -1156,7 +1171,7 @@ async function build(options) {
1156
1171
  const bundle = nativeBundlePath ?? config.nativeBundle;
1157
1172
  if (bundle)
1158
1173
  installNativeBundle(bundle, output);
1159
- if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1174
+ if (!hasInstalledNativeBundle(output)) {
1160
1175
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
1161
1176
  }
1162
1177
  } else if (nativeBundlePath && !isHybrid(config)) {
@@ -1181,8 +1196,8 @@ async function build(options) {
1181
1196
  const bundle = nativeBundlePath ?? config.nativeBundle;
1182
1197
  if (bundle) {
1183
1198
  installNativeBundle(bundle, output);
1184
- console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
1185
- } else if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1199
+ console.log(` Native screens: ${bundle} \u2192 ${statSync(bundle).isDirectory() ? "dist/native-screens/" : "dist/native-screen.js"}`);
1200
+ } else if (!hasInstalledNativeBundle(output)) {
1186
1201
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
1187
1202
  }
1188
1203
  }
package/dist/index.d.ts CHANGED
@@ -223,7 +223,12 @@ export interface CraftConfig {
223
223
  * opens in the in-app browser on the app's site.
224
224
  */
225
225
  nativeScreens?: Record<string, string>;
226
- /** The bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
226
+ /**
227
+ * The bundle `stx native compile --format bundle` wrote, copied to
228
+ * `dist/native-screen.js`; or a directory of route bundles (stx's
229
+ * `routesDir`: `<Screen>.js` and `__app.js`), copied to
230
+ * `dist/native-screens/`, so each screen evaluates only its own file.
231
+ */
227
232
  nativeBundle?: string;
228
233
  /**
229
234
  * The bundle route of a screen-less script that runs once for the app's
@@ -307,6 +312,12 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
307
312
  export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
308
313
  /** What is wrong with a config's hybrid and tab settings, one sentence each. */
309
314
  export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
315
+ /**
316
+ * The builder installs a directory of route bundles (stx's `routesDir`) as
317
+ * well as the one-file bundle, and the app runs each screen from its own
318
+ * route's file. Stacks asks for route bundles only when this is true.
319
+ */
320
+ export declare const nativeRouteBundles = true;
310
321
  export interface CraftBackgroundRefresh {
311
322
  enabled: boolean;
312
323
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
package/dist/index.js CHANGED
@@ -484,13 +484,29 @@ function hybridConfigProblems(config) {
484
484
  }
485
485
  return problems;
486
486
  }
487
+ var nativeRouteBundles = true;
487
488
  function installNativeBundle(source, output) {
488
489
  if (!existsSync(source))
489
490
  throw new Error(`Native bundle not found: ${source}`);
490
- const destination = join3(output, "dist", "native-screen.js");
491
- mkdirSync2(dirname2(destination), { recursive: true });
492
- if (resolve(source) !== resolve(destination))
493
- cpSync(source, destination);
491
+ const single = join3(output, "dist", "native-screen.js");
492
+ const routes = join3(output, "dist", "native-screens");
493
+ mkdirSync2(dirname2(single), { recursive: true });
494
+ if (statSync(source).isDirectory()) {
495
+ if (!readdirSync2(source).some((name) => name.endsWith(".js")))
496
+ throw new Error(`Native route bundles not found in ${source}`);
497
+ if (resolve(source) !== resolve(routes)) {
498
+ rmSync2(routes, { recursive: true, force: true });
499
+ cpSync(source, routes, { recursive: true });
500
+ }
501
+ rmSync2(single, { force: true });
502
+ return;
503
+ }
504
+ if (resolve(source) !== resolve(single))
505
+ cpSync(source, single);
506
+ rmSync2(routes, { recursive: true, force: true });
507
+ }
508
+ function hasInstalledNativeBundle(output) {
509
+ return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
494
510
  }
495
511
  var DEFAULT_CONFIG = {
496
512
  renderer: "web",
@@ -1353,7 +1369,7 @@ async function build(options) {
1353
1369
  const bundle = nativeBundlePath ?? config.nativeBundle;
1354
1370
  if (bundle)
1355
1371
  installNativeBundle(bundle, output);
1356
- if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1372
+ if (!hasInstalledNativeBundle(output)) {
1357
1373
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
1358
1374
  }
1359
1375
  } else if (nativeBundlePath && !isHybrid(config)) {
@@ -1378,8 +1394,8 @@ async function build(options) {
1378
1394
  const bundle = nativeBundlePath ?? config.nativeBundle;
1379
1395
  if (bundle) {
1380
1396
  installNativeBundle(bundle, output);
1381
- console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
1382
- } else if (!existsSync(join3(output, "dist", "native-screen.js"))) {
1397
+ console.log(` Native screens: ${bundle} \u2192 ${statSync(bundle).isDirectory() ? "dist/native-screens/" : "dist/native-screen.js"}`);
1398
+ } else if (!hasInstalledNativeBundle(output)) {
1383
1399
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
1384
1400
  }
1385
1401
  }
@@ -1552,6 +1568,7 @@ export {
1552
1568
  open,
1553
1569
  normalizeNativePath,
1554
1570
  nativeStartPath,
1571
+ nativeRouteBundles,
1555
1572
  matchNativePath,
1556
1573
  isHybrid,
1557
1574
  installRuntime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.241",
3
+ "version": "0.0.243",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -251,6 +251,17 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
251
251
  CraftEventManager.shared.sendToWeb("craftSilentPush", data: ["id": id, "payload": CraftEventManager.pageData(userInfo)])
252
252
  }
253
253
 
254
+ /// iOS relaunched the app because `craft.media.keep`'s background
255
+ /// transfers finished (CraftNativeMediaTransfers): their session takes
256
+ /// the events, stores the files, then says it is done.
257
+ func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
258
+ guard identifier == CraftNativeMediaTransfers.identifier else {
259
+ completionHandler()
260
+ return
261
+ }
262
+ CraftNativeMediaTransfers.shared.backgroundCompletion = completionHandler
263
+ }
264
+
254
265
  func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
255
266
  let token = deviceToken.map { String(format: "%02x", $0) }.joined()
256
267
  NotificationCenter.default.post(name: .craftPushToken, object: token)
@@ -679,6 +690,9 @@ final class CraftChrome: ObservableObject {
679
690
  private var splashDeadline: DispatchWorkItem?
680
691
  private var tabSwipePrevious: String?
681
692
  private var tabSwipeTarget: String?
693
+ /// One generator for every tab change. A new one per tick started the
694
+ /// Taptic Engine cold each time, on the main thread, late.
695
+ private let selectionFeedback = UISelectionFeedbackGenerator()
682
696
  /// The keyboard is up and covers the tab bar, so the page need not leave
683
697
  /// room for it: the web view ends at the keyboard (CraftHybrid's webHost).
684
698
  private var keyboardUp = false
@@ -866,7 +880,7 @@ final class CraftChrome: ObservableObject {
866
880
  func swipe(to id: String) {
867
881
  guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
868
882
  withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
869
- UISelectionFeedbackGenerator().selectionChanged()
883
+ selectionFeedback.selectionChanged()
870
884
  guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
871
885
  emit("craftTabSelect", ["id": id])
872
886
  }
@@ -875,6 +889,9 @@ final class CraftChrome: ObservableObject {
875
889
  /// controller commits or restores this preview when the finger lifts.
876
890
  func beginTabSwipe(to id: String, from previous: String) {
877
891
  guard id != previous, tabs.contains(where: { $0.id == id }) else { return }
892
+ // The tick comes as the swipe commits; wake the Taptic Engine now, as
893
+ // UIFeedbackGenerator asks, so it is on time rather than late.
894
+ selectionFeedback.prepare()
878
895
  // A page's pending selection report may disagree with the native
879
896
  // stack. Cancellation belongs to the stack the finger left.
880
897
  tabSwipePrevious = previous
@@ -917,7 +934,7 @@ final class CraftChrome: ObservableObject {
917
934
  withAnimation(nil) { tabSwipePosition = nil }
918
935
  guard tabs.contains(where: { $0.id == id }) else { return }
919
936
  withAnimation(nil) { selected = id }
920
- UISelectionFeedbackGenerator().selectionChanged()
937
+ selectionFeedback.selectionChanged()
921
938
  guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
922
939
  emit("craftTabSelect", ["id": id])
923
940
  }
@@ -1183,9 +1200,20 @@ struct CraftTabBarView: View {
1183
1200
 
1184
1201
  /// The filled variant for the current tab, when the symbol has one.
1185
1202
  private func symbol(_ name: String, selected: Bool) -> String {
1186
- guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
1203
+ guard selected, !name.hasSuffix(".fill"), Self.hasFilledSymbol(name) else { return name }
1187
1204
  return "\(name).fill"
1188
1205
  }
1206
+
1207
+ /// Whether `<name>.fill` exists, asked of the symbol catalog once per
1208
+ /// name: the bar's body runs again for every selection change and asked
1209
+ /// it for every tab each time.
1210
+ private static var filledSymbols: [String: Bool] = [:]
1211
+ private static func hasFilledSymbol(_ name: String) -> Bool {
1212
+ if let known = filledSymbols[name] { return known }
1213
+ let exists = UIImage(systemName: "\(name).fill") != nil
1214
+ filledSymbols[name] = exists
1215
+ return exists
1216
+ }
1189
1217
  }
1190
1218
 
1191
1219
  private struct CraftTabSelectionIndicator: View {
@@ -1267,13 +1295,14 @@ class DeepLinkManager {
1267
1295
  // Every link that arrived while no page could receive it, in order. This
1268
1296
  // was a single slot, so a second link before the bridge was ready replaced
1269
1297
  // the first; on a cold start, that could be the link that launched the app.
1270
- private var pendingURLs: [URL] = []
1298
+ private var pendingURLs: [(url: URL, initial: Bool)] = []
1299
+ private var replayingNativeLinks = false
1271
1300
  private weak var webView: WKWebView?
1272
1301
  private var isReady = false
1273
1302
  // Replay bookkeeping only. A native screen can become ready before UIKit
1274
1303
  // delivers the launch URL; listener readiness cannot classify that URL.
1275
1304
  private var hasBeenReady = false
1276
- private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
1305
+ private var nativeListeners: [UUID: (canReceive: () -> Bool, receive: (URL, Bool) -> Void)] = [:]
1277
1306
 
1278
1307
  private init() {
1279
1308
  for name in [UIApplication.didBecomeActiveNotification, UIApplication.didEnterBackgroundNotification] {
@@ -1297,12 +1326,11 @@ class DeepLinkManager {
1297
1326
 
1298
1327
  func setReady() {
1299
1328
  isReady = true
1300
- let firstPage = !hasBeenReady
1301
1329
  hasBeenReady = true
1302
1330
  let urls = pendingURLs
1303
1331
  pendingURLs.removeAll()
1304
- for url in urls {
1305
- dispatchDeepLink(url, initial: firstPage && url == initialURL)
1332
+ for link in urls {
1333
+ dispatchDeepLink(link.url, initial: link.initial)
1306
1334
  }
1307
1335
  }
1308
1336
 
@@ -1316,21 +1344,21 @@ class DeepLinkManager {
1316
1344
  if CraftHybrid.shared.handleDeepLink(url) {
1317
1345
  dispatchNative(url, initial: false)
1318
1346
  } else if !CraftHybrid.shared.hasWeb {
1319
- if nativeListeners.isEmpty { pendingURLs.append(url) } else { dispatchNative(url, initial: false) }
1347
+ enqueueNative(url)
1320
1348
  } else if isReady && webView != nil {
1321
1349
  dispatchDeepLink(url, initial: false)
1322
1350
  } else {
1323
- pendingURLs.append(url)
1351
+ pendingURLs.append((url, !hasBeenReady && url == initialURL))
1324
1352
  }
1325
1353
  return
1326
1354
  }
1327
1355
 
1328
1356
  if !nativeListeners.isEmpty {
1329
- dispatchNative(url, initial: false)
1357
+ enqueueNative(url)
1330
1358
  } else if isReady && webView != nil {
1331
1359
  dispatchDeepLink(url, initial: false)
1332
1360
  } else {
1333
- pendingURLs.append(url)
1361
+ pendingURLs.append((url, !hasBeenReady && url == initialURL))
1334
1362
  }
1335
1363
  }
1336
1364
 
@@ -1350,20 +1378,14 @@ class DeepLinkManager {
1350
1378
  }
1351
1379
 
1352
1380
  @discardableResult
1353
- func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
1381
+ func addNativeListener(canReceive: @escaping () -> Bool = { true }, _ listener: @escaping (URL, Bool) -> Void) -> UUID {
1354
1382
  let token = UUID()
1355
- nativeListeners[token] = listener
1383
+ nativeListeners[token] = (canReceive, listener)
1356
1384
  // In a hybrid app the page is the one waiting for the links that
1357
1385
  // arrived early; a native screen hears only native ones, as they come.
1358
1386
  // An all-native app has no page, so its first screen hears them.
1359
1387
  if CraftHybrid.shared.isEnabled && CraftHybrid.shared.hasWeb { return token }
1360
- let firstNativeScreen = !hasBeenReady
1361
- hasBeenReady = true
1362
- let urls = pendingURLs
1363
- pendingURLs.removeAll()
1364
- for url in urls {
1365
- listener(url, firstNativeScreen && url == initialURL)
1366
- }
1388
+ replayNativeLinks()
1367
1389
  return token
1368
1390
  }
1369
1391
 
@@ -1371,8 +1393,31 @@ class DeepLinkManager {
1371
1393
  nativeListeners.removeValue(forKey: token)
1372
1394
  }
1373
1395
 
1396
+ private func enqueueNative(_ url: URL) {
1397
+ pendingURLs.append((url, !hasBeenReady && url == initialURL))
1398
+ replayNativeLinks()
1399
+ }
1400
+
1401
+ /// Registration can happen while a controller is being prewarmed. Keep
1402
+ /// links until a screen is visible, including across a tab switch.
1403
+ func replayNativeLinks() {
1404
+ guard !(CraftHybrid.shared.isEnabled && CraftHybrid.shared.hasWeb), !replayingNativeLinks else { return }
1405
+ replayingNativeLinks = true
1406
+ defer { replayingNativeLinks = false }
1407
+ while let link = pendingURLs.first {
1408
+ let receivers = nativeListeners.values.filter { $0.canReceive() }
1409
+ guard !receivers.isEmpty else { return }
1410
+ pendingURLs.removeFirst()
1411
+ hasBeenReady = true
1412
+ for receiver in receivers { receiver.receive(link.url, link.initial) }
1413
+ }
1414
+ }
1415
+
1374
1416
  private func dispatchNative(_ url: URL, initial: Bool) {
1375
- for listener in nativeListeners.values { listener(url, initial) }
1417
+ // The hybrid shell already opened this route; notify visible screens
1418
+ // without queuing a second navigation for its future destination.
1419
+ let receivers = nativeListeners.values.filter { $0.canReceive() }
1420
+ for receiver in receivers { receiver.receive(url, initial) }
1376
1421
  }
1377
1422
 
1378
1423
  private func dispatchDeepLink(_ url: URL, initial: Bool) {