craft-native 0.0.276 → 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.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.276";
120
+ var version = "0.0.290";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -241,18 +241,21 @@ 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
253
256
  } from "fs";
254
257
  import { homedir } from "os";
255
- import { basename, join as join2 } from "path";
258
+ import { basename, join as join2, resolve } from "path";
256
259
  import { deflateRawSync } from "zlib";
257
260
  var DEFAULT_DEB_DEPENDENCIES = ["libgtk-3-0", "libwebkit2gtk-4.1-37", "libnotify-bin"];
258
261
  var CRC32_TABLE = (() => {
@@ -365,8 +368,10 @@ 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
- const appBundle = createMacOSAppBundle({
374
+ const appBundle = opts.appBundlePath ? copyMacOSAppBundle(opts.appBundlePath, appBundlePath) : createMacOSAppBundle({
370
375
  name,
371
376
  version,
372
377
  bundleId,
@@ -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,
@@ -449,19 +472,21 @@ async function packageMacOS(config, outDir) {
449
472
  async function notarizeIfRequested(opts, artifactPath, format) {
450
473
  if (!opts.notarize)
451
474
  return [];
452
- if (!opts.appleId || !opts.applePassword || !opts.teamId) {
475
+ if (!opts.notaryKeychainProfile && !opts.notaryApiKey && (!opts.appleId || !opts.applePassword || !opts.teamId)) {
453
476
  return [{
454
477
  success: false,
455
478
  platform: "macos",
456
479
  format: `${format} (notarize)`,
457
- error: "Notarization requires macos.appleId, macos.applePassword and macos.teamId"
480
+ error: "Notarization requires a keychain profile, API key, or Apple ID/password/team ID"
458
481
  }];
459
482
  }
460
483
  const submitted = await runTool("xcrun", notarytoolArguments({
461
484
  artifactPath,
462
485
  appleId: opts.appleId,
463
486
  applePassword: opts.applePassword,
464
- teamId: opts.teamId
487
+ teamId: opts.teamId,
488
+ keychainProfile: opts.notaryKeychainProfile,
489
+ apiKey: opts.notaryApiKey
465
490
  }));
466
491
  if (!submitted.success)
467
492
  return [{ success: false, platform: "macos", format: `${format} (notarize)`, error: submitted.error }];
@@ -671,6 +696,59 @@ ${body}
671
696
  </dict>
672
697
  </plist>`;
673
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
+ }
674
752
  function codesignArguments(opts) {
675
753
  return [
676
754
  "--force",
@@ -679,7 +757,9 @@ function codesignArguments(opts) {
679
757
  ...opts.identity === "-" ? [] : ["--timestamp"],
680
758
  ...opts.hardenedRuntime ? ["--options", "runtime"] : [],
681
759
  ...opts.entitlements ? ["--entitlements", opts.entitlements] : [],
682
- "--deep",
760
+ ...opts.identifier ? ["--identifier", opts.identifier] : [],
761
+ ...opts.keychain ? ["--keychain", opts.keychain] : [],
762
+ ...opts.deep === false ? [] : ["--deep"],
683
763
  opts.path
684
764
  ];
685
765
  }
@@ -690,10 +770,18 @@ function productbuildArguments(opts) {
690
770
  "/Applications",
691
771
  "--sign",
692
772
  opts.identity,
773
+ ...opts.keychain ? ["--keychain", opts.keychain] : [],
693
774
  opts.outputPath
694
775
  ];
695
776
  }
696
777
  function notarytoolArguments(opts) {
778
+ const base = ["notarytool", "submit", opts.artifactPath, "--wait"];
779
+ if (opts.keychainProfile)
780
+ return [...base, "--keychain-profile", opts.keychainProfile];
781
+ if (opts.apiKey)
782
+ return [...base, "--key", opts.apiKey.path, "--key-id", opts.apiKey.keyId, "--issuer", opts.apiKey.issuer];
783
+ if (!opts.appleId || !opts.applePassword || !opts.teamId)
784
+ throw new Error("Notarization credentials are incomplete");
697
785
  return [
698
786
  "notarytool",
699
787
  "submit",
@@ -707,6 +795,20 @@ function notarytoolArguments(opts) {
707
795
  "--wait"
708
796
  ];
709
797
  }
798
+ function copyMacOSAppBundle(source, destination) {
799
+ try {
800
+ const info = join2(source, "Contents", "Info.plist");
801
+ if (!existsSync3(info) || !existsSync3(join2(source, "Contents", "MacOS")))
802
+ throw new Error(`Invalid prebuilt macOS application: ${source}`);
803
+ if (resolve(source) !== resolve(destination)) {
804
+ rmSync2(destination, { recursive: true, force: true });
805
+ cpSync(source, destination, { recursive: true });
806
+ }
807
+ return { success: true };
808
+ } catch (error) {
809
+ return { success: false, error: error.message };
810
+ }
811
+ }
710
812
  function createMacOSAppBundle(opts) {
711
813
  try {
712
814
  const { name, binaryPath, iconPath, provisioningProfile, additionalExecutables, outputPath } = opts;
@@ -788,7 +890,7 @@ function runTool(tool, args) {
788
890
  });
789
891
  proc.on("close", (code) => {
790
892
  if (code === 0)
791
- resolve({ success: true });
893
+ resolve({ success: true, stdout });
792
894
  else
793
895
  resolve({ success: false, error: formatPackagingCommandError(tool, code, stdout, stderr) });
794
896
  });
@@ -863,7 +965,8 @@ async function createPKG(opts) {
863
965
  const built = await runTool("productbuild", productbuildArguments({
864
966
  appBundlePath: opts.appBundlePath,
865
967
  outputPath: opts.outputPath,
866
- identity: opts.installerIdentity
968
+ identity: opts.installerIdentity,
969
+ keychain: opts.signingKeychain
867
970
  }));
868
971
  return built.success ? { success: true, outputPath: opts.outputPath } : { success: false, error: built.error };
869
972
  }
@@ -4861,6 +4964,55 @@ var permissions = {
4861
4964
  }
4862
4965
  }
4863
4966
  };
4967
+ var files = {
4968
+ async pickFile(options = {}) {
4969
+ const craft = getCraftMobile();
4970
+ if (typeof window !== "undefined" && craft?.files) {
4971
+ return craft.files.pickFile(options);
4972
+ }
4973
+ if (typeof document === "undefined") {
4974
+ throw new Error("File picker not available. Must run in Craft or a browser.");
4975
+ }
4976
+ return new Promise((resolve, reject) => {
4977
+ const input = document.createElement("input");
4978
+ input.type = "file";
4979
+ if (options.contentTypes?.length)
4980
+ input.accept = options.contentTypes.join(",");
4981
+ const requested = Number(options.maxSelections);
4982
+ const maxSelections = Number.isFinite(requested) && requested > 0 ? Math.min(20, Math.floor(requested)) : 1;
4983
+ input.multiple = maxSelections > 1;
4984
+ input.style.display = "none";
4985
+ let settled = false;
4986
+ const finish = (result) => {
4987
+ if (settled)
4988
+ return;
4989
+ settled = true;
4990
+ input.remove();
4991
+ result();
4992
+ };
4993
+ input.addEventListener("cancel", () => finish(() => resolve([])), { once: true });
4994
+ input.onchange = () => {
4995
+ const chosen = Array.from(input.files ?? []).slice(0, maxSelections);
4996
+ const limit = Number(options.maxBytes);
4997
+ if (Number.isFinite(limit) && limit > 0) {
4998
+ const oversized = chosen.find((file) => file.size > limit);
4999
+ if (oversized) {
5000
+ finish(() => reject(Object.assign(new Error("The selected file is too large"), { code: "FILE_TOO_LARGE" })));
5001
+ return;
5002
+ }
5003
+ }
5004
+ finish(() => resolve(chosen.map((file) => ({
5005
+ path: URL.createObjectURL(file),
5006
+ name: file.name,
5007
+ type: file.type || "application/octet-stream",
5008
+ size: file.size
5009
+ }))));
5010
+ };
5011
+ document.body.append(input);
5012
+ input.click();
5013
+ });
5014
+ }
5015
+ };
4864
5016
  var camera = {
4865
5017
  async takePicture(options = {}) {
4866
5018
  const craft = getCraftMobile();
@@ -11403,6 +11555,7 @@ export {
11403
11555
  extractBundle,
11404
11556
  extractBundleFromDmg,
11405
11557
  extractBundleFromZip,
11558
+ files,
11406
11559
  focus,
11407
11560
  focusFilters,
11408
11561
  foregroundService,
@@ -776,12 +776,15 @@ function renderWatchEntitlements(config) {
776
776
  <array>
777
777
  ${plistArray(config.appGroups)}
778
778
  </array>
779
+ ` : "";
780
+ const healthKit = config.enableHealthKit ? ` <key>com.apple.developer.healthkit</key>
781
+ <true/>
779
782
  ` : "";
780
783
  return `<?xml version="1.0" encoding="UTF-8"?>
781
784
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
782
785
  <plist version="1.0">
783
786
  <dict>
784
- ${appGroups}</dict>
787
+ ${appGroups}${healthKit}</dict>
785
788
  </plist>
786
789
  `;
787
790
  }
@@ -1195,7 +1198,7 @@ function writeShell(output, config) {
1195
1198
  if (!enabled)
1196
1199
  continue;
1197
1200
  mkdirSync2(join3(output, folder), { recursive: true });
1198
- const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
1201
+ const source = readFileSync2(join3(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{HEALTH_KIT_ENABLED\}\}/g, config.enableHealthKit ? "true" : "false");
1199
1202
  writeFileSync2(join3(output, folder, filename), source);
1200
1203
  }
1201
1204
  }
@@ -1328,7 +1331,8 @@ ${nativeDependencies.join(`
1328
1331
  const watchInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1329
1332
  writeFileSync2(join3(output, "WatchApp", "Info.plist"), watchInfo);
1330
1333
  writeFileSync2(join3(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
1331
- const watchExtensionInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
1334
+ const watchExtensionInfo = readFileSync2(join3(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{HEALTH_USAGE_DESCRIPTIONS\}\}/g, config.enableHealthKit ? `${plistString("NSHealthShareUsageDescription", `${displayName} reads health data you choose to share.`)}
1335
+ ${plistString("NSHealthUpdateUsageDescription", `${displayName} writes health data only with your permission.`)}` : "");
1332
1336
  writeFileSync2(join3(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
1333
1337
  writeFileSync2(join3(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
1334
1338
  }
@@ -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
 
@@ -253,6 +253,12 @@ enum CraftNativeActions {
253
253
  CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
254
254
  }
255
255
  return
256
+ case ("Files", "pickFile"):
257
+ let options = args.first as? [String: Any] ?? [:]
258
+ DispatchQueue.main.async {
259
+ CraftNativeFiles.pickFile(options) { complete(requestToken, $0, completion) }
260
+ }
261
+ return
256
262
  case ("Files", "download"):
257
263
  download(requestToken: requestToken, args: args, completion: completion)
258
264
  return
@@ -328,6 +328,142 @@ enum CraftNativeFiles {
328
328
  }
329
329
  }
330
330
 
331
+ // MARK: Picking a general file
332
+
333
+ /// Pick one or more documents and copy them into app-owned storage while
334
+ /// the security-scoped URLs are valid. `contentTypes` accepts MIME types,
335
+ /// UTIs, or filename extensions; an empty list means any document.
336
+ static func pickFile(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
337
+ guard activePicker == nil else {
338
+ done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
339
+ return
340
+ }
341
+ let limit = craftNativeNumber(options["maxBytes"])
342
+ .map { $0.doubleValue }
343
+ .flatMap { value in
344
+ guard value.isFinite else { return nil }
345
+ return Int(max(1, min(Double(512 * 1024 * 1024), value)))
346
+ } ?? 512 * 1024 * 1024
347
+ let selectionLimit = craftNativeNumber(options["maxSelections"])
348
+ .map { $0.intValue }
349
+ .map { max(1, min(20, $0)) } ?? 1
350
+ let types: [UTType]
351
+ do {
352
+ types = try documentTypes(options["contentTypes"])
353
+ } catch let error as CraftNativeActionError {
354
+ done(.failure(error))
355
+ return
356
+ } catch {
357
+ done(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes are invalid")))
358
+ return
359
+ }
360
+ let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
361
+ DispatchQueue.main.async { activePicker = nil; done(result) }
362
+ }
363
+ let delegate = FileDocumentDelegate(limit: limit, done: finish)
364
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
365
+ picker.allowsMultipleSelection = selectionLimit > 1
366
+ picker.delegate = delegate
367
+ activePicker = delegate
368
+ if !CraftPresenter.present(picker, from: nil) {
369
+ activePicker = nil
370
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
371
+ }
372
+ }
373
+
374
+ /// Resolve an app-supplied content filter without silently broadening it.
375
+ /// A MIME type, UTI, or extension is enough for `UTType`; applications can
376
+ /// therefore ask for `application/pdf`, `com.adobe.pdf`, or `pdf` alike.
377
+ private static func documentTypes(_ value: Any?) throws -> [UTType] {
378
+ guard let value else { return [.item] }
379
+ guard let entries = value as? [Any] else {
380
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be an array")
381
+ }
382
+ guard !entries.isEmpty else { return [.item] }
383
+ var types: [UTType] = []
384
+ for entry in entries {
385
+ guard let raw = entry as? String else {
386
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentTypes must be strings")
387
+ }
388
+ let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)
389
+ guard !name.isEmpty,
390
+ let type = UTType(mimeType: name) ?? UTType(name) ?? UTType(filenameExtension: name.trimmingCharacters(in: CharacterSet(charactersIn: "."))) else {
391
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.pickFile contentType is not a MIME type, UTI, or extension: \(raw)")
392
+ }
393
+ types.append(type)
394
+ }
395
+ return types
396
+ }
397
+
398
+ /// Copy an arbitrary selected document into Craft's private file area and
399
+ /// return the same descriptor used by downloads, writes, and uploads.
400
+ static func copyFile(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
401
+ do {
402
+ let values = try source.resourceValues(forKeys: [.fileSizeKey, .contentTypeKey])
403
+ let size: Int
404
+ if let fileSize = values.fileSize {
405
+ size = fileSize
406
+ } else {
407
+ size = ((try? FileManager.default.attributesOfItem(atPath: source.path))?[.size] as? NSNumber)?.intValue ?? 0
408
+ }
409
+ guard size > 0, size <= limit else {
410
+ return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty file under \(limit / 1024 / 1024)MB."))
411
+ }
412
+ let destination = try newFile(named: source.lastPathComponent)
413
+ do { try FileManager.default.copyItem(at: source, to: destination) }
414
+ catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
415
+ let type = mimeType(for: source, contentType: values.contentType)
416
+ return .success(describe(destination, type: type))
417
+ } catch {
418
+ return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
419
+ }
420
+ }
421
+
422
+ private static func mimeType(for source: URL, contentType: UTType?) -> String {
423
+ let generic = "application/octet-stream"
424
+ if let preferred = contentType?.preferredMIMEType, preferred != generic { return preferred }
425
+ if let preferred = UTType(filenameExtension: source.pathExtension)?.preferredMIMEType, preferred != generic {
426
+ return preferred
427
+ }
428
+ switch source.pathExtension.lowercased() {
429
+ case "gpx": return "application/gpx+xml"
430
+ case "tcx": return "application/vnd.garmin.tcx+xml"
431
+ case "fit": return "application/vnd.ant.fit"
432
+ case "csv": return "text/csv"
433
+ case "json": return "application/json"
434
+ case "txt": return "text/plain"
435
+ default: return generic
436
+ }
437
+ }
438
+
439
+ private final class FileDocumentDelegate: NSObject, UIDocumentPickerDelegate {
440
+ let limit: Int
441
+ let done: (Result<Any, CraftNativeActionError>) -> Void
442
+ init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
443
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success([])) }
444
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
445
+ guard !urls.isEmpty else { done(.success([])); return }
446
+ let (limit, done) = (self.limit, self.done)
447
+ DispatchQueue.global(qos: .userInitiated).async {
448
+ var results: [[String: Any]] = []
449
+ for url in urls {
450
+ let granted = url.startAccessingSecurityScopedResource()
451
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
452
+ switch CraftNativeFiles.copyFile(url, limit: limit) {
453
+ case .success(let value):
454
+ guard let info = value as? [String: Any] else { done(.failure(CraftNativeActionError(code: "READ_ERROR", message: "The selected file could not be described"))); return }
455
+ results.append(info)
456
+ case .failure(let error):
457
+ results.compactMap { $0["path"] as? String }.forEach { CraftNativeFiles.remove($0) }
458
+ done(.failure(error))
459
+ return
460
+ }
461
+ }
462
+ done(.success(results))
463
+ }
464
+ }
465
+ }
466
+
331
467
  static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
332
468
  let ext = source.pathExtension.lowercased()
333
469
  let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]