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/README.md +16 -0
- package/dist/android/src/emulator.d.ts +4 -0
- package/dist/android/src/index.js +15 -6
- package/dist/android/templates/CraftNativeMutation.kt.template +35 -0
- package/dist/android/templates/CraftNativeShell.kt.template +12 -5
- package/dist/android/templates/CraftSnapshotFile.kt.template +26 -0
- package/dist/android/templates/MainActivityNative.kt.template +16 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/mobile.d.ts +25 -0
- package/dist/browser/storage.d.ts +5 -3
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +31 -7
- package/dist/cli.js +131 -28
- package/dist/index.cjs +177 -27
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +181 -28
- package/dist/ios/src/index.js +7 -3
- package/dist/ios/templates/CraftApp.swift +44 -5
- package/dist/ios/templates/CraftHybrid.swift +21 -0
- package/dist/ios/templates/CraftNativeActions.swift +6 -0
- package/dist/ios/templates/CraftNativeFiles.swift +136 -0
- package/dist/ios/templates/CraftNativeMutation.swift +51 -0
- package/dist/ios/templates/CraftNativeScreen.swift +116 -8
- package/dist/ios/templates/CraftWatchApp.swift.template +303 -9
- package/dist/ios/templates/WatchExtension.Info.plist.template +1 -0
- package/dist/macos/templates/CraftNativeMutation.swift +51 -0
- package/dist/mobile.js +50 -0
- package/dist/package.d.ts +29 -3
- package/dist/types.d.ts +32 -0
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
if (
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
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
|
-
"--
|
|
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,
|
package/dist/ios/src/index.js
CHANGED
|
@@ -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
|
-
|
|
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"]
|