craft-native 0.0.278 → 0.0.290

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/index.cjs CHANGED
@@ -374,7 +374,7 @@ function craftBinaryNotFoundMessage(triedPath) {
374
374
  `);
375
375
  }
376
376
  // package.json
377
- var version = "0.0.278";
377
+ var version = "0.0.290";
378
378
 
379
379
  // src/sdk-version.ts
380
380
  var SDK_VERSION = version;
@@ -611,6 +611,8 @@ async function packageMacOS(config, outDir) {
611
611
  const { name, version, bundleId = `com.myapp.${name.toLowerCase()}` } = config;
612
612
  const opts = config.macos || {};
613
613
  const hardenedRuntime = !opts.appStore;
614
+ if (opts.appStore && (!opts.entitlements || !opts.provisioningProfile))
615
+ return [{ success: false, platform: "macos", format: "app", error: "App Store packaging requires macos.entitlements and macos.provisioningProfile" }];
614
616
  const appBundlePath = import_path.join(outDir, `${name}.app`);
615
617
  const appBundle = opts.appBundlePath ? copyMacOSAppBundle(opts.appBundlePath, appBundlePath) : createMacOSAppBundle({
616
618
  name,
@@ -636,25 +638,42 @@ async function packageMacOS(config, outDir) {
636
638
  });
637
639
  return results;
638
640
  }
639
- if (opts.signIdentity) {
640
- const signed = await runTool("codesign", codesignArguments({
641
- path: appBundlePath,
642
- identity: opts.signIdentity,
643
- entitlements: opts.entitlements,
644
- hardenedRuntime
645
- }));
646
- if (!signed.success) {
647
- results.push({ success: false, platform: "macos", format: "app", error: signed.error });
648
- return results;
641
+ try {
642
+ const contents = import_path.join(appBundlePath, "Contents");
643
+ if (opts.provisioningProfile) {
644
+ const profile = import_path.join(contents, "embedded.provisionprofile");
645
+ import_fs.copyFileSync(opts.provisioningProfile, profile);
646
+ import_fs.chmodSync(profile, 420);
647
+ }
648
+ if (opts.privacyManifest)
649
+ import_fs.copyFileSync(opts.privacyManifest, import_path.join(contents, "Resources", "PrivacyInfo.xcprivacy"));
650
+ if (opts.appBundlePath) {
651
+ const metadata = {
652
+ CFBundleVersion: opts.buildNumber,
653
+ LSApplicationCategoryType: opts.category,
654
+ LSMinimumSystemVersion: opts.minimumSystemVersion,
655
+ LSUIElement: opts.menuBarOnly
656
+ };
657
+ if (config.iconPath) {
658
+ const icon = import_path.basename(config.iconPath);
659
+ import_fs.copyFileSync(config.iconPath, import_path.join(contents, "Resources", icon));
660
+ metadata.CFBundleIconFile = icon;
661
+ }
662
+ for (const [key, value] of Object.entries(metadata)) {
663
+ if (value === undefined)
664
+ continue;
665
+ const result = await runTool("plutil", ["-replace", key, typeof value === "boolean" ? "-bool" : "-string", String(value), import_path.join(contents, "Info.plist")]);
666
+ if (!result.success)
667
+ throw new Error(result.error);
668
+ }
649
669
  }
650
- } else if (opts.appStore) {
651
- results.push({
652
- success: false,
653
- platform: "macos",
654
- format: "pkg",
655
- error: 'App Store packaging requires macos.signIdentity (a "3rd Party Mac Developer Application" identity)'
656
- });
657
- return results;
670
+ if (opts.signIdentity) {
671
+ await signMacOSAppBundle(appBundlePath, bundleId, opts, hardenedRuntime);
672
+ } else if (opts.appStore) {
673
+ throw new Error('App Store packaging requires macos.signIdentity (a "3rd Party Mac Developer Application" identity)');
674
+ }
675
+ } catch (error) {
676
+ return [{ success: false, platform: "macos", format: "app", error: error.message }];
658
677
  }
659
678
  results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
660
679
  if (opts.dmg !== false) {
@@ -678,7 +697,8 @@ async function packageMacOS(config, outDir) {
678
697
  identifier: bundleId,
679
698
  version,
680
699
  appStore: opts.appStore === true,
681
- installerIdentity: opts.installerIdentity
700
+ installerIdentity: opts.installerIdentity,
701
+ signingKeychain: opts.signingKeychain
682
702
  });
683
703
  results.push({
684
704
  success: pkgResult.success,
@@ -919,6 +939,59 @@ ${body}
919
939
  </dict>
920
940
  </plist>`;
921
941
  }
942
+ async function signMacOSAppBundle(app, bundleId, opts, hardenedRuntime) {
943
+ const scratch = createTempDir("craft-sign-");
944
+ try {
945
+ let helperEntitlements = opts.helperEntitlements;
946
+ if (opts.appStore && !helperEntitlements) {
947
+ helperEntitlements = import_path.join(scratch, "inherit.plist");
948
+ import_fs.writeFileSync(helperEntitlements, '<?xml version="1.0"?><plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.inherit</key><true/></dict></plist>');
949
+ }
950
+ const extracted = await runTool("plutil", ["-extract", "CFBundleExecutable", "raw", "-o", "-", import_path.join(app, "Contents", "Info.plist")]);
951
+ if (!extracted.success)
952
+ throw new Error(extracted.error);
953
+ const main = import_path.join(app, "Contents", "MacOS", extracted.stdout.trim());
954
+ const sign = async (path, entitlements, identifier) => {
955
+ const signed = await runTool("codesign", codesignArguments({ path, identity: opts.signIdentity, entitlements, hardenedRuntime, identifier, keychain: opts.signingKeychain, deep: false }));
956
+ if (!signed.success)
957
+ throw new Error(signed.error);
958
+ };
959
+ const walk = async (directory) => {
960
+ for (const entry of import_fs.readdirSync(directory)) {
961
+ const path = import_path.join(directory, entry);
962
+ const stat = import_fs.lstatSync(path);
963
+ if (stat.isSymbolicLink())
964
+ continue;
965
+ if (stat.isDirectory()) {
966
+ await walk(path);
967
+ if (/\.(?:app|framework|xpc|appex|bundle)$/.test(entry))
968
+ await sign(path, /\.(?:app|xpc|appex)$/.test(entry) ? helperEntitlements : undefined);
969
+ } else if (path !== main && stat.isFile() && isMachO(path)) {
970
+ const executable = (stat.mode & 73) !== 0 && !/\.(?:dylib|so)$/.test(entry);
971
+ await sign(path, executable ? helperEntitlements : undefined, `${bundleId}.${entry}`);
972
+ }
973
+ }
974
+ };
975
+ await walk(import_path.join(app, "Contents"));
976
+ await sign(app, opts.entitlements);
977
+ const verified = await runTool("codesign", ["--verify", "--deep", "--strict", app]);
978
+ if (!verified.success)
979
+ throw new Error(verified.error);
980
+ } finally {
981
+ removeTempDir(scratch);
982
+ }
983
+ }
984
+ function isMachO(path) {
985
+ const fd = import_fs.openSync(path, "r");
986
+ try {
987
+ const magic = Buffer.alloc(4);
988
+ if (import_fs.readSync(fd, magic, 0, 4, 0) !== 4)
989
+ return false;
990
+ return [4277009102, 3472551422, 4277009103, 3489328638, 3405691582, 3199925962, 3405691583, 3216703178].includes(magic.readUInt32BE());
991
+ } finally {
992
+ import_fs.closeSync(fd);
993
+ }
994
+ }
922
995
  function codesignArguments(opts) {
923
996
  return [
924
997
  "--force",
@@ -927,7 +1000,9 @@ function codesignArguments(opts) {
927
1000
  ...opts.identity === "-" ? [] : ["--timestamp"],
928
1001
  ...opts.hardenedRuntime ? ["--options", "runtime"] : [],
929
1002
  ...opts.entitlements ? ["--entitlements", opts.entitlements] : [],
930
- "--deep",
1003
+ ...opts.identifier ? ["--identifier", opts.identifier] : [],
1004
+ ...opts.keychain ? ["--keychain", opts.keychain] : [],
1005
+ ...opts.deep === false ? [] : ["--deep"],
931
1006
  opts.path
932
1007
  ];
933
1008
  }
@@ -938,6 +1013,7 @@ function productbuildArguments(opts) {
938
1013
  "/Applications",
939
1014
  "--sign",
940
1015
  opts.identity,
1016
+ ...opts.keychain ? ["--keychain", opts.keychain] : [],
941
1017
  opts.outputPath
942
1018
  ];
943
1019
  }
@@ -1057,7 +1133,7 @@ function runTool(tool, args) {
1057
1133
  });
1058
1134
  proc.on("close", (code) => {
1059
1135
  if (code === 0)
1060
- resolve({ success: true });
1136
+ resolve({ success: true, stdout });
1061
1137
  else
1062
1138
  resolve({ success: false, error: formatPackagingCommandError(tool, code, stdout, stderr) });
1063
1139
  });
@@ -1132,7 +1208,8 @@ async function createPKG(opts) {
1132
1208
  const built = await runTool("productbuild", productbuildArguments({
1133
1209
  appBundlePath: opts.appBundlePath,
1134
1210
  outputPath: opts.outputPath,
1135
- identity: opts.installerIdentity
1211
+ identity: opts.installerIdentity,
1212
+ keychain: opts.signingKeychain
1136
1213
  }));
1137
1214
  return built.success ? { success: true, outputPath: opts.outputPath } : { success: false, error: built.error };
1138
1215
  }
package/dist/index.d.cts CHANGED
@@ -85,4 +85,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
85
85
  * Quick helper to load a URL
86
86
  */
87
87
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
88
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
88
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, CraftTimersAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.d.ts CHANGED
@@ -85,4 +85,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
85
85
  * Quick helper to load a URL
86
86
  */
87
87
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
88
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
88
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, CraftTimersAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.278";
120
+ var version = "0.0.290";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -241,12 +241,15 @@ import { spawn } from "child_process";
241
241
  import { createHash } from "crypto";
242
242
  import {
243
243
  chmodSync,
244
+ closeSync,
244
245
  copyFileSync,
245
246
  cpSync,
246
247
  existsSync as existsSync3,
247
248
  lstatSync,
248
249
  mkdirSync,
250
+ openSync,
249
251
  readFileSync,
252
+ readSync,
250
253
  readdirSync as readdirSync2,
251
254
  rmSync as rmSync2,
252
255
  writeFileSync as writeFileSync2
@@ -365,6 +368,8 @@ async function packageMacOS(config, outDir) {
365
368
  const { name, version, bundleId = `com.myapp.${name.toLowerCase()}` } = config;
366
369
  const opts = config.macos || {};
367
370
  const hardenedRuntime = !opts.appStore;
371
+ if (opts.appStore && (!opts.entitlements || !opts.provisioningProfile))
372
+ return [{ success: false, platform: "macos", format: "app", error: "App Store packaging requires macos.entitlements and macos.provisioningProfile" }];
368
373
  const appBundlePath = join2(outDir, `${name}.app`);
369
374
  const appBundle = opts.appBundlePath ? copyMacOSAppBundle(opts.appBundlePath, appBundlePath) : createMacOSAppBundle({
370
375
  name,
@@ -390,25 +395,42 @@ async function packageMacOS(config, outDir) {
390
395
  });
391
396
  return results;
392
397
  }
393
- if (opts.signIdentity) {
394
- const signed = await runTool("codesign", codesignArguments({
395
- path: appBundlePath,
396
- identity: opts.signIdentity,
397
- entitlements: opts.entitlements,
398
- hardenedRuntime
399
- }));
400
- if (!signed.success) {
401
- results.push({ success: false, platform: "macos", format: "app", error: signed.error });
402
- return results;
398
+ try {
399
+ const contents = join2(appBundlePath, "Contents");
400
+ if (opts.provisioningProfile) {
401
+ const profile = join2(contents, "embedded.provisionprofile");
402
+ copyFileSync(opts.provisioningProfile, profile);
403
+ chmodSync(profile, 420);
404
+ }
405
+ if (opts.privacyManifest)
406
+ copyFileSync(opts.privacyManifest, join2(contents, "Resources", "PrivacyInfo.xcprivacy"));
407
+ if (opts.appBundlePath) {
408
+ const metadata = {
409
+ CFBundleVersion: opts.buildNumber,
410
+ LSApplicationCategoryType: opts.category,
411
+ LSMinimumSystemVersion: opts.minimumSystemVersion,
412
+ LSUIElement: opts.menuBarOnly
413
+ };
414
+ if (config.iconPath) {
415
+ const icon = basename(config.iconPath);
416
+ copyFileSync(config.iconPath, join2(contents, "Resources", icon));
417
+ metadata.CFBundleIconFile = icon;
418
+ }
419
+ for (const [key, value] of Object.entries(metadata)) {
420
+ if (value === undefined)
421
+ continue;
422
+ const result = await runTool("plutil", ["-replace", key, typeof value === "boolean" ? "-bool" : "-string", String(value), join2(contents, "Info.plist")]);
423
+ if (!result.success)
424
+ throw new Error(result.error);
425
+ }
403
426
  }
404
- } else if (opts.appStore) {
405
- results.push({
406
- success: false,
407
- platform: "macos",
408
- format: "pkg",
409
- error: 'App Store packaging requires macos.signIdentity (a "3rd Party Mac Developer Application" identity)'
410
- });
411
- return results;
427
+ if (opts.signIdentity) {
428
+ await signMacOSAppBundle(appBundlePath, bundleId, opts, hardenedRuntime);
429
+ } else if (opts.appStore) {
430
+ throw new Error('App Store packaging requires macos.signIdentity (a "3rd Party Mac Developer Application" identity)');
431
+ }
432
+ } catch (error) {
433
+ return [{ success: false, platform: "macos", format: "app", error: error.message }];
412
434
  }
413
435
  results.push({ success: true, platform: "macos", format: "app", outputPath: appBundlePath });
414
436
  if (opts.dmg !== false) {
@@ -432,7 +454,8 @@ async function packageMacOS(config, outDir) {
432
454
  identifier: bundleId,
433
455
  version,
434
456
  appStore: opts.appStore === true,
435
- installerIdentity: opts.installerIdentity
457
+ installerIdentity: opts.installerIdentity,
458
+ signingKeychain: opts.signingKeychain
436
459
  });
437
460
  results.push({
438
461
  success: pkgResult.success,
@@ -673,6 +696,59 @@ ${body}
673
696
  </dict>
674
697
  </plist>`;
675
698
  }
699
+ async function signMacOSAppBundle(app, bundleId, opts, hardenedRuntime) {
700
+ const scratch = createTempDir("craft-sign-");
701
+ try {
702
+ let helperEntitlements = opts.helperEntitlements;
703
+ if (opts.appStore && !helperEntitlements) {
704
+ helperEntitlements = join2(scratch, "inherit.plist");
705
+ writeFileSync2(helperEntitlements, '<?xml version="1.0"?><plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.inherit</key><true/></dict></plist>');
706
+ }
707
+ const extracted = await runTool("plutil", ["-extract", "CFBundleExecutable", "raw", "-o", "-", join2(app, "Contents", "Info.plist")]);
708
+ if (!extracted.success)
709
+ throw new Error(extracted.error);
710
+ const main = join2(app, "Contents", "MacOS", extracted.stdout.trim());
711
+ const sign = async (path, entitlements, identifier) => {
712
+ const signed = await runTool("codesign", codesignArguments({ path, identity: opts.signIdentity, entitlements, hardenedRuntime, identifier, keychain: opts.signingKeychain, deep: false }));
713
+ if (!signed.success)
714
+ throw new Error(signed.error);
715
+ };
716
+ const walk = async (directory) => {
717
+ for (const entry of readdirSync2(directory)) {
718
+ const path = join2(directory, entry);
719
+ const stat = lstatSync(path);
720
+ if (stat.isSymbolicLink())
721
+ continue;
722
+ if (stat.isDirectory()) {
723
+ await walk(path);
724
+ if (/\.(?:app|framework|xpc|appex|bundle)$/.test(entry))
725
+ await sign(path, /\.(?:app|xpc|appex)$/.test(entry) ? helperEntitlements : undefined);
726
+ } else if (path !== main && stat.isFile() && isMachO(path)) {
727
+ const executable = (stat.mode & 73) !== 0 && !/\.(?:dylib|so)$/.test(entry);
728
+ await sign(path, executable ? helperEntitlements : undefined, `${bundleId}.${entry}`);
729
+ }
730
+ }
731
+ };
732
+ await walk(join2(app, "Contents"));
733
+ await sign(app, opts.entitlements);
734
+ const verified = await runTool("codesign", ["--verify", "--deep", "--strict", app]);
735
+ if (!verified.success)
736
+ throw new Error(verified.error);
737
+ } finally {
738
+ removeTempDir(scratch);
739
+ }
740
+ }
741
+ function isMachO(path) {
742
+ const fd = openSync(path, "r");
743
+ try {
744
+ const magic = Buffer.alloc(4);
745
+ if (readSync(fd, magic, 0, 4, 0) !== 4)
746
+ return false;
747
+ return [4277009102, 3472551422, 4277009103, 3489328638, 3405691582, 3199925962, 3405691583, 3216703178].includes(magic.readUInt32BE());
748
+ } finally {
749
+ closeSync(fd);
750
+ }
751
+ }
676
752
  function codesignArguments(opts) {
677
753
  return [
678
754
  "--force",
@@ -681,7 +757,9 @@ function codesignArguments(opts) {
681
757
  ...opts.identity === "-" ? [] : ["--timestamp"],
682
758
  ...opts.hardenedRuntime ? ["--options", "runtime"] : [],
683
759
  ...opts.entitlements ? ["--entitlements", opts.entitlements] : [],
684
- "--deep",
760
+ ...opts.identifier ? ["--identifier", opts.identifier] : [],
761
+ ...opts.keychain ? ["--keychain", opts.keychain] : [],
762
+ ...opts.deep === false ? [] : ["--deep"],
685
763
  opts.path
686
764
  ];
687
765
  }
@@ -692,6 +770,7 @@ function productbuildArguments(opts) {
692
770
  "/Applications",
693
771
  "--sign",
694
772
  opts.identity,
773
+ ...opts.keychain ? ["--keychain", opts.keychain] : [],
695
774
  opts.outputPath
696
775
  ];
697
776
  }
@@ -811,7 +890,7 @@ function runTool(tool, args) {
811
890
  });
812
891
  proc.on("close", (code) => {
813
892
  if (code === 0)
814
- resolve({ success: true });
893
+ resolve({ success: true, stdout });
815
894
  else
816
895
  resolve({ success: false, error: formatPackagingCommandError(tool, code, stdout, stderr) });
817
896
  });
@@ -886,7 +965,8 @@ async function createPKG(opts) {
886
965
  const built = await runTool("productbuild", productbuildArguments({
887
966
  appBundlePath: opts.appBundlePath,
888
967
  outputPath: opts.outputPath,
889
- identity: opts.installerIdentity
968
+ identity: opts.installerIdentity,
969
+ keychain: opts.signingKeychain
890
970
  }));
891
971
  return built.success ? { success: true, outputPath: opts.outputPath } : { success: false, error: built.error };
892
972
  }
@@ -903,6 +903,9 @@ final class CraftChrome: ObservableObject {
903
903
  func tap(_ tab: Tab) {
904
904
  let reselect = tab.id == selected
905
905
  if !reselect {
906
+ // A tab's screen not made yet is made first, so the pill moves on
907
+ // a free main thread rather than stalling behind its mount.
908
+ CraftHybrid.shared.mountTabBeforeSelecting(tab.id)
906
909
  withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
907
910
  }
908
911
  // A hybrid app shows the tab's own stack first; the page hears the
@@ -1162,11 +1165,7 @@ struct CraftTabBarView: View {
1162
1165
  VStack(spacing: 3) {
1163
1166
  ZStack(alignment: .topTrailing) {
1164
1167
  if let image = tab.image, let url = URL(string: image) {
1165
- AsyncImage(url: url) { image in
1166
- image.resizable().scaledToFill()
1167
- } placeholder: {
1168
- Image(systemName: symbol(tab.symbol, selected: isSelected))
1169
- }
1168
+ CraftTabPicture(url: url, placeholder: symbol(tab.symbol, selected: isSelected))
1170
1169
  .frame(width: iconSize + 4, height: iconSize + 4)
1171
1170
  .clipShape(Circle())
1172
1171
  .frame(height: iconSize + 6)
@@ -1258,6 +1257,46 @@ struct CraftTabBarView: View {
1258
1257
  }
1259
1258
  }
1260
1259
 
1260
+ /// A tab's picture (HQ's Me: the account's photo). It is kept once loaded,
1261
+ /// so a tab item drawn again (its screen mounting, the bar coming back) shows
1262
+ /// it at once: AsyncImage keeps nothing, so the photo went back to the
1263
+ /// person symbol, loaded again and arrived inside whatever animation was
1264
+ /// running, sliding in from the side on the first visit to Me.
1265
+ private struct CraftTabPicture: View {
1266
+ let url: URL
1267
+ let placeholder: String
1268
+ @State private var image: UIImage?
1269
+
1270
+ private static let cache: NSCache<NSURL, UIImage> = {
1271
+ let cache = NSCache<NSURL, UIImage>()
1272
+ cache.countLimit = 16
1273
+ return cache
1274
+ }()
1275
+
1276
+ var body: some View {
1277
+ Group {
1278
+ if let shown = image ?? Self.cache.object(forKey: url as NSURL) {
1279
+ Image(uiImage: shown).resizable().scaledToFill()
1280
+ } else {
1281
+ Image(systemName: placeholder)
1282
+ }
1283
+ }
1284
+ // A picture arriving never animates, whatever transaction it lands in.
1285
+ .transaction { $0.animation = nil }
1286
+ .task(id: url) {
1287
+ if let cached = Self.cache.object(forKey: url as NSURL) {
1288
+ image = cached
1289
+ return
1290
+ }
1291
+ guard let (data, response) = try? await URLSession.shared.data(from: url),
1292
+ (response as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
1293
+ let loaded = UIImage(data: data) else { return }
1294
+ Self.cache.setObject(loaded, forKey: url as NSURL)
1295
+ image = loaded
1296
+ }
1297
+ }
1298
+ }
1299
+
1261
1300
  private struct CraftTabSelectionShape: Shape {
1262
1301
  let cornerRadius: CGFloat?
1263
1302
 
@@ -880,6 +880,13 @@ final class CraftHybrid: NSObject {
880
880
  return controller.tabTapped(id, reselect: reselect)
881
881
  }
882
882
 
883
+ /// A native tab not prepared yet mounts its screen now, hidden, before
884
+ /// the bar's selection moves (CraftChrome.tap).
885
+ func mountTabBeforeSelecting(_ id: String) {
886
+ guard isEnabled else { return }
887
+ controller?.mountTabBeforeSelecting(id)
888
+ }
889
+
883
890
  /// The page chose a tab itself (its `selectTab`, or a tab link).
884
891
  func pageSelectedTab(_ id: String) {
885
892
  guard isEnabled else { return }
@@ -1731,6 +1738,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1731
1738
 
1732
1739
  private var preparation: Timer?
1733
1740
 
1741
+ /// A native tab tapped before the shell prepared it (HQ's Me, prepared
1742
+ /// last, tapped right after launch) mounts its screen hidden first. Mounted
1743
+ /// in the tap's own turn after the selection began, it held the main
1744
+ /// thread while SwiftUI's spring should have moved the pill, which then
1745
+ /// stalled and jumped in from the side.
1746
+ func mountTabBeforeSelecting(_ id: String) {
1747
+ guard stacks[id] == nil, id != currentTab, activeTabSwipeTarget == nil,
1748
+ hybrid.tabIds.contains(id), hybrid.routes.match(id) != nil, view.window != nil else { return }
1749
+ let nav = makeStack(tab: id, rootPath: id, hidden: true)
1750
+ nav.view.layoutIfNeeded()
1751
+ Self.drawContents(of: nav.view.layer)
1752
+ (visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1753
+ }
1754
+
1734
1755
  /// When the shell last moved between screens: a tab, a push or a pop.
1735
1756
  private var lastNavigation: CFTimeInterval = 0
1736
1757