craft-native 0.0.278 → 0.0.293
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.d.ts +1 -1
- package/dist/android/src/index.js +17 -6
- package/dist/android/templates/CraftNativeHealth.kt.template +77 -1
- 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/mobile.d.ts +48 -4
- 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 +103 -23
- package/dist/index.cjs +100 -23
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +103 -23
- package/dist/ios/templates/CraftApp.swift +64 -5
- package/dist/ios/templates/CraftHealth.swift +76 -0
- package/dist/ios/templates/CraftHybrid.swift +21 -0
- package/dist/ios/templates/CraftNativeScreen.swift +116 -8
- package/dist/mobile.js +6 -0
- package/dist/package.d.ts +10 -0
- package/dist/types.d.ts +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -668,3 +668,19 @@ Browser audio assigns one owner per sound ID. A newer play or stop invalidates
|
|
|
668
668
|
pending media resolution for that ID; stopping all sounds, changing accounts,
|
|
669
669
|
aborting a call or disposing the host also releases active audio. Superseded
|
|
670
670
|
play calls reject with `AbortError`.
|
|
671
|
+
|
|
672
|
+
### Packaging sandboxed macOS apps
|
|
673
|
+
|
|
674
|
+
Pass `macos.appBundlePath` to package a complete STX app without discarding its
|
|
675
|
+
resources. Distribution options also apply to prebuilt bundles: `buildNumber`,
|
|
676
|
+
`provisioningProfile`, `privacyManifest`, and the icon are embedded before signing.
|
|
677
|
+
Craft signs nested Mach-O code before the main bundle and verifies the result.
|
|
678
|
+
Use `helperEntitlements` for a compiled backend's runtime permissions; App Store
|
|
679
|
+
helpers inherit the main sandbox by default. `signingKeychain` selects the
|
|
680
|
+
keychain for both application and installer identities.
|
|
681
|
+
|
|
682
|
+
Craft's bundled-backend host reads `backend: { executable: 'stx-backend' }` from
|
|
683
|
+
`Contents/Resources/craft.json`. The native app remains the main process, starts
|
|
684
|
+
the helper with `--serve --managed`, and consumes its authenticated loopback URL.
|
|
685
|
+
The helper's standard input is a lifetime lease: it must stop when that pipe
|
|
686
|
+
closes. `craft --capabilities` advertises `bundleBackend` on macOS.
|
|
@@ -10,6 +10,10 @@ export interface AndroidEmulatorOptions {
|
|
|
10
10
|
acceptLicenses?: boolean;
|
|
11
11
|
/** Defaults to 10 minutes; hardware is capped there, software cold provisioning at 20 minutes. */
|
|
12
12
|
bootTimeoutMs?: number;
|
|
13
|
+
/** Requested guest RAM, 1536–8192 MB (Android emulator limits); defaults to 2048. */
|
|
14
|
+
memoryMb?: number;
|
|
15
|
+
/** Requested guest CPU count, 1–8; defaults to 2. */
|
|
16
|
+
virtualCpus?: number;
|
|
13
17
|
env?: Record<string, string | undefined>;
|
|
14
18
|
}
|
|
15
19
|
export interface AndroidEmulatorSession {
|
|
@@ -177,7 +177,7 @@ export interface CraftAndroidConfig {
|
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
179
|
/** The shared health data types (the iOS builder's HealthKit names). */
|
|
180
|
-
export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat';
|
|
180
|
+
export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat' | 'bloodGlucose' | 'insulinDelivery';
|
|
181
181
|
/**
|
|
182
182
|
* The Health Connect permissions behind each type, for reading and for
|
|
183
183
|
* writing. CraftNativeHealth.kt asks for the same ones; a permission the
|
|
@@ -198,6 +198,12 @@ async function createAndroidEmulator(options) {
|
|
|
198
198
|
const maximumBootTimeoutMs = options.acceleration === "software" ? 1200000 : 600000;
|
|
199
199
|
if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1000 || bootTimeoutMs > maximumBootTimeoutMs)
|
|
200
200
|
throw new Error(`Android bootTimeoutMs must be an integer between 1000 and ${maximumBootTimeoutMs} milliseconds`);
|
|
201
|
+
const memoryMb = options.memoryMb ?? 2048;
|
|
202
|
+
if (!Number.isSafeInteger(memoryMb) || memoryMb < 1536 || memoryMb > 8192)
|
|
203
|
+
throw new Error("Android memoryMb must be an integer between 1536 and 8192 megabytes");
|
|
204
|
+
const virtualCpus = options.virtualCpus ?? 2;
|
|
205
|
+
if (!Number.isSafeInteger(virtualCpus) || virtualCpus < 1 || virtualCpus > 8)
|
|
206
|
+
throw new Error("Android virtualCpus must be an integer between 1 and 8");
|
|
201
207
|
const port = options.port ?? 5558;
|
|
202
208
|
if (!Number.isInteger(port) || port < 5554 || port > 5682 || port % 2 !== 0)
|
|
203
209
|
throw new Error("Android console ports must be even and between 5554 and 5682");
|
|
@@ -299,7 +305,7 @@ async function createAndroidEmulator(options) {
|
|
|
299
305
|
const hostCpus = cpus();
|
|
300
306
|
cpuStart = hostCpus.reduce((total, cpu) => total + Object.values(cpu.times).reduce((sum, value) => sum + value, 0), 0);
|
|
301
307
|
idleStart = hostCpus.reduce((total, cpu) => total + cpu.times.idle, 0);
|
|
302
|
-
child = Bun.spawn([executable, "-avd", name, "-port", String(port), "-no-window", "-no-snapshot", "-no-audio", "-no-boot-anim", "-gpu", "software", "-memory",
|
|
308
|
+
child = Bun.spawn([executable, "-avd", name, "-port", String(port), "-no-window", "-no-snapshot", "-no-audio", "-no-boot-anim", "-gpu", "software", "-memory", String(memoryMb), "-cores", String(virtualCpus), "-accel", options.acceleration === "software" ? "off" : "on"], { env, stdout: log, stderr: log });
|
|
303
309
|
identity = {
|
|
304
310
|
image: options.image,
|
|
305
311
|
acceleration: options.acceleration,
|
|
@@ -307,8 +313,8 @@ async function createAndroidEmulator(options) {
|
|
|
307
313
|
bootTimeoutMs,
|
|
308
314
|
bootStartedAt: new Date(bootStartedAt).toISOString(),
|
|
309
315
|
host: { cpuModel: hostCpus[0]?.model, logicalCpus: hostCpus.length, availableParallelism: availableParallelism(), memoryBytes: totalmem() },
|
|
310
|
-
virtualCpus
|
|
311
|
-
memoryMb
|
|
316
|
+
virtualCpus,
|
|
317
|
+
memoryMb,
|
|
312
318
|
serial,
|
|
313
319
|
avdDirectory: env.ANDROID_AVD_HOME,
|
|
314
320
|
emulatorPid: child.pid
|
|
@@ -335,11 +341,13 @@ async function createAndroidEmulator(options) {
|
|
|
335
341
|
const packages = await run([adb, "-s", serial, "shell", "pm", "path", "android"], undefined, probeTimeout(), true);
|
|
336
342
|
const playStore = requiresPlayStore ? await run([adb, "-s", serial, "shell", "pm", "path", "com.android.vending"], undefined, probeTimeout(), true) : undefined;
|
|
337
343
|
const systemUi = await run([adb, "-s", serial, "shell", "pidof", "com.android.systemui"], undefined, probeTimeout(), true);
|
|
338
|
-
const events = await run([adb, "-s", serial, "logcat", "-b", "events", "-d", "-
|
|
344
|
+
const events = await run([adb, "-s", serial, "logcat", "-b", "events", "-d", "-v", "threadtime"], undefined, probeTimeout(), true);
|
|
339
345
|
const home = await run([adb, "-s", serial, "shell", "cmd", "package", "resolve-activity", "--brief", "-a", "android.intent.action.MAIN", "-c", "android.intent.category.HOME"], undefined, probeTimeout(), true);
|
|
340
346
|
const homeActivity = home.stdout.trim().split(/\r?\n/).at(-1) ?? "";
|
|
341
347
|
const temporaryHome = homeActivity.startsWith("com.android.sdksetup/") || homeActivity.startsWith("com.google.android.googlesdksetup/") || /^com\.android\.settings\/(?:\.|com\.android\.settings\.)FallbackHome$/.test(homeActivity) || /^android\/.*(?:Resolver|Chooser)Activity$/.test(homeActivity);
|
|
342
|
-
|
|
348
|
+
const windows = await run([adb, "-s", serial, "shell", "dumpsys", "window"], undefined, probeTimeout(), true);
|
|
349
|
+
const focusedPackage = windows.stdout.match(/^\s*mCurrentFocus\s*=\s*Window\{[^}\r\n]*\bu\d+\s+([\w.]+)\//m)?.[1];
|
|
350
|
+
booted = settings.code === 0 && packages.code === 0 && systemUi.code === 0 && events.code === 0 && /^package:\/\S+/m.test(packages.stdout) && /^\d+(?:\s+\d+)*$/.test(systemUi.stdout.trim()) && (!requiresPlayStore || playStore?.code === 0 && /^package:\/\S+/m.test(playStore.stdout)) && home.code === 0 && /^[\w.]+\/[\w.$]+$/.test(homeActivity) && !temporaryHome && windows.code === 0 && focusedPackage === homeActivity.split("/")[0];
|
|
343
351
|
}
|
|
344
352
|
}
|
|
345
353
|
const checkedAt = Date.now();
|
|
@@ -472,7 +480,9 @@ var HEALTH_CONNECT_PERMISSIONS = {
|
|
|
472
480
|
dietaryEnergy: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
473
481
|
dietaryProtein: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
474
482
|
dietaryCarbohydrates: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
475
|
-
dietaryFat: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] }
|
|
483
|
+
dietaryFat: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
|
|
484
|
+
bloodGlucose: { read: ["READ_BLOOD_GLUCOSE"], write: [] },
|
|
485
|
+
insulinDelivery: { read: [], write: [] }
|
|
476
486
|
};
|
|
477
487
|
var DEFAULT_HEALTH_CONNECT = { read: ["steps", "heartRate", "activeEnergy", "distance", "workouts"], write: ["workouts"] };
|
|
478
488
|
function healthConnectPermissions(config) {
|
|
@@ -845,6 +855,7 @@ function writeNativeShellSources(output, packagePath, packageName, config) {
|
|
|
845
855
|
};
|
|
846
856
|
write("CraftBluetooth.kt.template", "CraftBluetooth.kt");
|
|
847
857
|
write("CraftNativeShell.kt.template", "CraftNativeShell.kt");
|
|
858
|
+
write("CraftSnapshotFile.kt.template", "CraftSnapshotFile.kt");
|
|
848
859
|
write("CraftNativeImageCache.kt.template", "CraftNativeImageCache.kt");
|
|
849
860
|
write("CraftNativeHaptics.kt.template", "CraftNativeHaptics.kt");
|
|
850
861
|
write("CraftNativeBackground.kt.template", "CraftNativeBackground.kt");
|
|
@@ -17,6 +17,7 @@ import androidx.health.connect.client.aggregate.AggregationResult
|
|
|
17
17
|
import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi
|
|
18
18
|
import androidx.health.connect.client.permission.HealthPermission
|
|
19
19
|
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
20
|
+
import androidx.health.connect.client.records.BloodGlucoseRecord
|
|
20
21
|
import androidx.health.connect.client.records.DistanceRecord
|
|
21
22
|
import androidx.health.connect.client.records.ElevationGainedRecord
|
|
22
23
|
import androidx.health.connect.client.records.ExerciseSessionRecord
|
|
@@ -162,6 +163,10 @@ internal class CraftNativeHealth(
|
|
|
162
163
|
getDailyStatistics(type, options(1))
|
|
163
164
|
}
|
|
164
165
|
"getHeartRateSamples" -> answer(completion) { getHeartRateSamples(options(0)) }
|
|
166
|
+
"getSamples" -> answer(completion) {
|
|
167
|
+
val type = args.opt(0) as? String ?: throw invalid("getSamples needs a type")
|
|
168
|
+
getSamples(type, options(1))
|
|
169
|
+
}
|
|
165
170
|
"getSleepIntervals" -> answer(completion) { getSleepIntervals(options(0)) }
|
|
166
171
|
"saveMindfulSession" -> answer(completion) { saveMindfulSession(options(0)) }
|
|
167
172
|
"getMindfulSessions" -> answer(completion) { getMindfulSessions(options(0)) }
|
|
@@ -438,7 +443,8 @@ internal class CraftNativeHealth(
|
|
|
438
443
|
/** The types whose every read permission is granted, in the order of TYPES. */
|
|
439
444
|
private suspend fun grantedTypes(): Any {
|
|
440
445
|
val granted = client().permissionController.getGrantedPermissions()
|
|
441
|
-
|
|
446
|
+
// A type Health Connect cannot hold (insulin) is never granted.
|
|
447
|
+
return JSONArray(TYPES.filter { type -> readPermissions(type).let { it.isNotEmpty() && granted.containsAll(it) } })
|
|
442
448
|
}
|
|
443
449
|
|
|
444
450
|
/**
|
|
@@ -783,6 +789,53 @@ internal class CraftNativeHealth(
|
|
|
783
789
|
return rows
|
|
784
790
|
}
|
|
785
791
|
|
|
792
|
+
/**
|
|
793
|
+
* Every glucose reading or carb entry between two instants, oldest first,
|
|
794
|
+
* as the writing app saved it: its record id (so a sync stores it once),
|
|
795
|
+
* value, source and device. Glucose comes in mg/dL with its meal relation
|
|
796
|
+
* and specimen when the source said. Health Connect has no insulin type,
|
|
797
|
+
* so `insulinDelivery` answers an empty list rather than failing a sync
|
|
798
|
+
* that asks for all three.
|
|
799
|
+
*/
|
|
800
|
+
private suspend fun getSamples(type: String, options: JSONObject): Any {
|
|
801
|
+
if (type !in SAMPLE_TYPES) throw invalid("getSamples reads bloodGlucose, insulinDelivery or dietaryCarbohydrates")
|
|
802
|
+
val end = options.instant("endDate") ?: Instant.now()
|
|
803
|
+
val start = options.instant("startDate") ?: end.minus(Duration.ofDays(7))
|
|
804
|
+
val cap = (options.int("limit") ?: 10_000).coerceIn(1, 50_000)
|
|
805
|
+
val rows = JSONArray()
|
|
806
|
+
if (type == "insulinDelivery") return rows
|
|
807
|
+
val client = client()
|
|
808
|
+
fun row(id: String, value: Double, unit: String, from: Instant, to: Instant, metadata: Metadata): JSONObject {
|
|
809
|
+
val origin = metadata.dataOrigin.packageName
|
|
810
|
+
val item = JSONObject()
|
|
811
|
+
.put("id", id)
|
|
812
|
+
.put("value", value)
|
|
813
|
+
.put("unit", unit)
|
|
814
|
+
.put("startDate", from.ms())
|
|
815
|
+
.put("endDate", to.ms())
|
|
816
|
+
.put("sourceName", sourceName(origin))
|
|
817
|
+
.put("sourceBundleId", origin)
|
|
818
|
+
.put("ownApp", origin == context.packageName)
|
|
819
|
+
putDevice(item, metadata.device, withModel = false)
|
|
820
|
+
return item
|
|
821
|
+
}
|
|
822
|
+
if (type == "bloodGlucose") {
|
|
823
|
+
for (record in readAll(client, BloodGlucoseRecord::class, range(start, end), cap = cap)) {
|
|
824
|
+
val item = row(record.metadata.id, record.level.inMilligramsPerDeciliter, "mg/dL", record.time, record.time, record.metadata)
|
|
825
|
+
mealTimeName(record.relationToMeal)?.let { item.put("mealTime", it) }
|
|
826
|
+
specimenName(record.specimenSource)?.let { item.put("specimen", it) }
|
|
827
|
+
rows.put(item)
|
|
828
|
+
}
|
|
829
|
+
return rows
|
|
830
|
+
}
|
|
831
|
+
for (record in readAll(client, NutritionRecord::class, range(start, end), cap = cap)) {
|
|
832
|
+
val grams = record.totalCarbohydrate?.inGrams ?: continue
|
|
833
|
+
if (grams <= 0) continue
|
|
834
|
+
rows.put(row(record.metadata.id, grams, "g", record.startTime, record.endTime, record.metadata))
|
|
835
|
+
}
|
|
836
|
+
return rows
|
|
837
|
+
}
|
|
838
|
+
|
|
786
839
|
/**
|
|
787
840
|
* Sleep between two instants, oldest first, as the sources wrote it: each
|
|
788
841
|
* stage with its name and whether it counts as asleep. A session with no
|
|
@@ -996,7 +1049,28 @@ internal class CraftNativeHealth(
|
|
|
996
1049
|
"steps", "heartRate", "activeEnergy", "distance", "workouts", "restingHeartRate",
|
|
997
1050
|
"heartRateVariability", "bodyMass", "sleep", "mindful",
|
|
998
1051
|
"dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat",
|
|
1052
|
+
"bloodGlucose", "insulinDelivery",
|
|
999
1053
|
)
|
|
1054
|
+
/** What `getSamples` reads one record at a time. */
|
|
1055
|
+
private val SAMPLE_TYPES = setOf("bloodGlucose", "insulinDelivery", "dietaryCarbohydrates")
|
|
1056
|
+
|
|
1057
|
+
private fun mealTimeName(relation: Int): String? = when (relation) {
|
|
1058
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_BEFORE_MEAL -> "beforeMeal"
|
|
1059
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_AFTER_MEAL -> "afterMeal"
|
|
1060
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_FASTING -> "fasting"
|
|
1061
|
+
BloodGlucoseRecord.RELATION_TO_MEAL_GENERAL -> "general"
|
|
1062
|
+
else -> null
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
private fun specimenName(source: Int): String? = when (source) {
|
|
1066
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_CAPILLARY_BLOOD -> "capillaryBlood"
|
|
1067
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_INTERSTITIAL_FLUID -> "interstitialFluid"
|
|
1068
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_PLASMA -> "plasma"
|
|
1069
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_SERUM -> "serum"
|
|
1070
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_TEARS -> "tears"
|
|
1071
|
+
BloodGlucoseRecord.SPECIMEN_SOURCE_WHOLE_BLOOD -> "wholeBlood"
|
|
1072
|
+
else -> null
|
|
1073
|
+
}
|
|
1000
1074
|
private val DIETARY_TYPES = setOf("dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat")
|
|
1001
1075
|
|
|
1002
1076
|
private val AWAKE_STAGES = setOf(
|
|
@@ -1142,6 +1216,8 @@ internal class CraftNativeHealth(
|
|
|
1142
1216
|
"sleep" -> setOf(HealthPermission.getReadPermission(SleepSessionRecord::class))
|
|
1143
1217
|
"mindful" -> setOf(HealthPermission.getReadPermission(MindfulnessSessionRecord::class))
|
|
1144
1218
|
in DIETARY_TYPES -> setOf(HealthPermission.getReadPermission(NutritionRecord::class))
|
|
1219
|
+
"bloodGlucose" -> setOf(HealthPermission.getReadPermission(BloodGlucoseRecord::class))
|
|
1220
|
+
// Health Connect has no insulin record: nothing to ask for.
|
|
1145
1221
|
else -> emptySet()
|
|
1146
1222
|
}
|
|
1147
1223
|
|
|
@@ -270,7 +270,12 @@ internal object CraftNativeStorage {
|
|
|
270
270
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
internal class CraftNativeStores private constructor(
|
|
273
|
+
internal class CraftNativeStores private constructor(
|
|
274
|
+
context: Context,
|
|
275
|
+
private val secureEnabled: Boolean,
|
|
276
|
+
private val snapshotFiles: CraftSnapshotFile,
|
|
277
|
+
) {
|
|
278
|
+
private constructor(context: Context, secureEnabled: Boolean) : this(context, secureEnabled, CraftSnapshotFile())
|
|
274
279
|
private val appContext = context.applicationContext
|
|
275
280
|
private val storage: SharedPreferences = appContext.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
|
|
276
281
|
private val snapshotsDir = File(appContext.filesDir, "craft-snapshots").apply { mkdirs() }
|
|
@@ -355,20 +360,22 @@ internal class CraftNativeStores private constructor(context: Context, private v
|
|
|
355
360
|
fun setSnapshot(name: String, json: String?, from: String?, done: (Boolean) -> Unit) {
|
|
356
361
|
writer.execute {
|
|
357
362
|
val file = snapshotFile(name)
|
|
363
|
+
val previous = snapshotCache[name]
|
|
358
364
|
val ok = runCatching {
|
|
359
365
|
if (json == null) {
|
|
360
366
|
!file.exists() || file.delete()
|
|
361
367
|
} else {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
temporary.renameTo(file) || run { file.writeText(json); temporary.delete(); true }
|
|
368
|
+
snapshotFiles.set(file, json)
|
|
369
|
+
true
|
|
365
370
|
}
|
|
366
371
|
}.getOrDefault(false)
|
|
367
372
|
if (ok) {
|
|
368
373
|
if (json == null) snapshotCache.remove(name) else snapshotCache[name] = json
|
|
369
374
|
}
|
|
370
375
|
main.post {
|
|
371
|
-
|
|
376
|
+
// Isolates optimistically display their write. Restore the
|
|
377
|
+
// confirmed value before rejecting an unsuccessful checkpoint.
|
|
378
|
+
changed("snapshots", name, if (ok) json else previous, null)
|
|
372
379
|
done(ok)
|
|
373
380
|
}
|
|
374
381
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.FileOutputStream
|
|
5
|
+
import java.nio.file.Files
|
|
6
|
+
import java.nio.file.StandardCopyOption
|
|
7
|
+
import java.util.UUID
|
|
8
|
+
|
|
9
|
+
/** One checkpoint replacement. Filesystem failures must leave the previous checkpoint intact. */
|
|
10
|
+
internal class CraftSnapshotFile(
|
|
11
|
+
private val write: (File, ByteArray) -> Unit = { file, bytes ->
|
|
12
|
+
FileOutputStream(file).use { stream -> stream.write(bytes); stream.fd.sync() }
|
|
13
|
+
},
|
|
14
|
+
private val replace: (File, File) -> Unit = { source, destination ->
|
|
15
|
+
Files.move(source.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING)
|
|
16
|
+
},
|
|
17
|
+
) {
|
|
18
|
+
fun set(destination: File, json: String) {
|
|
19
|
+
val temporary = File(destination.parentFile, ".craft-snapshot-${UUID.randomUUID()}.tmp")
|
|
20
|
+
val bytes = json.toByteArray(Charsets.UTF_8)
|
|
21
|
+
try {
|
|
22
|
+
write(temporary, bytes)
|
|
23
|
+
replace(temporary, destination)
|
|
24
|
+
} finally { temporary.delete() }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -4661,6 +4661,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
4661
4661
|
capabilities: ${capabilityNames()},
|
|
4662
4662
|
initialAppState: ${JSONObject.quote(nativeAppState)},
|
|
4663
4663
|
colorScheme: ${JSONObject.quote(colorScheme())},
|
|
4664
|
+
viewportWidth: ${resources.configuration.screenWidthDp},
|
|
4664
4665
|
appearance: ${appearance()},
|
|
4665
4666
|
appURL: ${JSONObject.quote(config.optString("devServerURL"))},
|
|
4666
4667
|
appVersion: ${JSONObject.quote(appVersion().first)},
|
|
@@ -4909,6 +4910,21 @@ class MainActivity : AppCompatActivity() {
|
|
|
4909
4910
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
4910
4911
|
super.onConfigurationChanged(newConfig)
|
|
4911
4912
|
publishAppearance()
|
|
4913
|
+
publishViewport(newConfig)
|
|
4914
|
+
}
|
|
4915
|
+
|
|
4916
|
+
/// The width the screens last heard, in dp (a web page's CSS pixels).
|
|
4917
|
+
private var reportedViewportWidth = -1
|
|
4918
|
+
|
|
4919
|
+
/// Rotation, split screen or a resized window: the bundle's width classes
|
|
4920
|
+
/// (`md:`, `lg:`) follow the window's new width.
|
|
4921
|
+
private fun publishViewport(config: Configuration) {
|
|
4922
|
+
val width = config.screenWidthDp
|
|
4923
|
+
if (width <= 0 || width == reportedViewportWidth) return
|
|
4924
|
+
reportedViewportWidth = width
|
|
4925
|
+
if (!::root.isInitialized) return
|
|
4926
|
+
val payload = JSONObject().put("width", width).put("height", config.screenHeightDp)
|
|
4927
|
+
allScreens().forEach { screen -> if (!screen.headless) send(screen, "VIEWPORT", payload) }
|
|
4912
4928
|
}
|
|
4913
4929
|
|
|
4914
4930
|
private fun publishAppearance() {
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -1546,11 +1546,48 @@ export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance'
|
|
|
1546
1546
|
/** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat. */
|
|
1547
1547
|
| 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat'
|
|
1548
1548
|
/** Mindful minutes (meditation, breathing): read with getMindfulSessions, written with saveMindfulSession. */
|
|
1549
|
-
| 'mindful'
|
|
1549
|
+
| 'mindful'
|
|
1550
|
+
/** Blood glucose from a CGM or meter, in mg/dL: read with getSamples. Read only. */
|
|
1551
|
+
| 'bloodGlucose'
|
|
1552
|
+
/**
|
|
1553
|
+
* Insulin from a pump, pen or closed loop, in international units, each
|
|
1554
|
+
* marked basal or bolus: read with getSamples. Read only. HealthKit only:
|
|
1555
|
+
* Health Connect has no insulin type, so Android answers none.
|
|
1556
|
+
*/
|
|
1557
|
+
| 'insulinDelivery';
|
|
1550
1558
|
/** The types with one value per day. `sleep` is hours asleep, counted on the day you woke. */
|
|
1551
|
-
export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful'>;
|
|
1559
|
+
export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful' | 'bloodGlucose' | 'insulinDelivery'>;
|
|
1552
1560
|
/** Quantity types that can be written with `health.saveData()`. */
|
|
1553
|
-
export type HealthQuantityType = Exclude<HealthDataType, 'workouts' | 'sleep' | 'mindful'>;
|
|
1561
|
+
export type HealthQuantityType = Exclude<HealthDataType, 'workouts' | 'sleep' | 'mindful' | 'bloodGlucose' | 'insulinDelivery'>;
|
|
1562
|
+
/** The types `health.getSamples()` reads one sample at a time: glucose, insulin and carbs, as a diabetes app needs them. */
|
|
1563
|
+
export type HealthSampleType = 'bloodGlucose' | 'insulinDelivery' | 'dietaryCarbohydrates';
|
|
1564
|
+
export interface HealthSamplesQuery extends HealthSampleQuery {
|
|
1565
|
+
/** At most this many, oldest first. Defaults to 10000, capped at 50000. Start defaults to 7 days ago. */
|
|
1566
|
+
limit?: number;
|
|
1567
|
+
}
|
|
1568
|
+
/** One glucose reading, insulin dose or carb entry as the writing app saved it. */
|
|
1569
|
+
export interface HealthQuantitySample {
|
|
1570
|
+
/** HealthKit's UUID or Health Connect's record id: stable across reads. */
|
|
1571
|
+
id: string;
|
|
1572
|
+
/** In `unit`: mg/dL for glucose, IU for insulin, g for carbs. */
|
|
1573
|
+
value: number;
|
|
1574
|
+
unit: 'mg/dL' | 'IU' | 'g';
|
|
1575
|
+
/** Epoch milliseconds. */
|
|
1576
|
+
startDate: number;
|
|
1577
|
+
/** Epoch milliseconds; the same as `startDate` for an instant reading. A basal delivery spans its segment. */
|
|
1578
|
+
endDate: number;
|
|
1579
|
+
sourceName: string;
|
|
1580
|
+
sourceBundleId: string;
|
|
1581
|
+
/** True when this app wrote it. */
|
|
1582
|
+
ownApp: boolean;
|
|
1583
|
+
deviceName?: string;
|
|
1584
|
+
/** Insulin only: what the dose was for. */
|
|
1585
|
+
insulinReason?: 'basal' | 'bolus';
|
|
1586
|
+
/** Glucose only: when it was taken relative to a meal, when the source said. */
|
|
1587
|
+
mealTime?: 'beforeMeal' | 'afterMeal' | 'fasting' | 'general';
|
|
1588
|
+
/** Glucose only (Health Connect): what was sampled, when the source said. */
|
|
1589
|
+
specimen?: 'capillaryBlood' | 'interstitialFluid' | 'plasma' | 'serum' | 'tears' | 'wholeBlood';
|
|
1590
|
+
}
|
|
1554
1591
|
export interface HealthAuthorizationOptions {
|
|
1555
1592
|
/** `false` asks to read only. Default: also allowed to save workouts. */
|
|
1556
1593
|
write?: boolean;
|
|
@@ -1737,7 +1774,7 @@ export interface HealthLiveWorkoutEvent {
|
|
|
1737
1774
|
message?: string;
|
|
1738
1775
|
}
|
|
1739
1776
|
/** The health reads and writes a host may or may not have, depending on the app build it runs in. */
|
|
1740
|
-
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveData' | 'saveMindfulSession' | 'getMindfulSessions' | 'startHeartRateUpdates' | 'stopHeartRateUpdates' | 'startWorkout' | 'pauseWorkout' | 'resumeWorkout' | 'endWorkout';
|
|
1777
|
+
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSamples' | 'getSleepIntervals' | 'saveData' | 'saveMindfulSession' | 'getMindfulSessions' | 'startHeartRateUpdates' | 'stopHeartRateUpdates' | 'startWorkout' | 'pauseWorkout' | 'resumeWorkout' | 'endWorkout';
|
|
1741
1778
|
export declare const health: {
|
|
1742
1779
|
/**
|
|
1743
1780
|
* Whether this app build answers `method`. An app installed before a read
|
|
@@ -1760,6 +1797,13 @@ export declare const health: {
|
|
|
1760
1797
|
getDailyStatistics(type: HealthDailyType, options?: HealthDataOptions): Promise<HealthDailyValue[]>;
|
|
1761
1798
|
/** Every heart-rate reading between two instants, oldest first. iOS only for now. */
|
|
1762
1799
|
getHeartRateSamples(options?: HealthHeartRateQuery): Promise<HealthHeartRateSample[]>;
|
|
1800
|
+
/**
|
|
1801
|
+
* Every glucose reading, insulin dose or carb entry between two instants,
|
|
1802
|
+
* oldest first, each with its own id so a sync can store it once. Needs
|
|
1803
|
+
* the type's read grant. Health Connect has no insulin, so Android answers
|
|
1804
|
+
* an empty list for `insulinDelivery`.
|
|
1805
|
+
*/
|
|
1806
|
+
getSamples(type: HealthSampleType, options?: HealthSamplesQuery): Promise<HealthQuantitySample[]>;
|
|
1763
1807
|
/** Sleep between two instants, oldest first: time in bed and each asleep or awake stage. iOS only for now. */
|
|
1764
1808
|
getSleepIntervals(options?: HealthSampleQuery): Promise<HealthSleepInterval[]>;
|
|
1765
1809
|
/**
|
|
@@ -17,8 +17,10 @@ export interface BrowserStorageOptions {
|
|
|
17
17
|
* or in another tab: called once the new account's storage is in place.
|
|
18
18
|
*/
|
|
19
19
|
sessionChanged?: (token: string | null, previous: string | null) => void;
|
|
20
|
-
/** A host lock primitive;
|
|
21
|
-
lock?: (<T>(name: string, operation: () => T | Promise<T
|
|
20
|
+
/** A host lock primitive; honor signal cancellation while queued, as native Web Locks do. */
|
|
21
|
+
lock?: (<T>(name: string, operation: () => T | Promise<T>, options?: {
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}) => Promise<T>) | null;
|
|
22
24
|
}
|
|
23
25
|
export interface BrowserStorage {
|
|
24
26
|
atomicStorage: boolean;
|
|
@@ -37,7 +39,7 @@ export interface BrowserStorage {
|
|
|
37
39
|
keys: () => string[];
|
|
38
40
|
};
|
|
39
41
|
};
|
|
40
|
-
call: (module: string, method: string, args: unknown[]) => Promise<unknown>;
|
|
42
|
+
call: (module: string, method: string, args: unknown[], signal?: AbortSignal) => Promise<unknown>;
|
|
41
43
|
accountChanged: () => Promise<void>;
|
|
42
44
|
scope: () => string;
|
|
43
45
|
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface BrowserPlatformOptions {
|
|
|
18
18
|
export interface BrowserPlatform {
|
|
19
19
|
craft: Record<string, unknown>;
|
|
20
20
|
capabilities: string[];
|
|
21
|
+
/** Queued storage calls abort without writing; cancellation cannot undo an already committed synchronous write. */
|
|
21
22
|
call: (module: string, method: string, args: unknown[], signal: AbortSignal) => Promise<unknown>;
|
|
22
23
|
navigate: (path: string, replace?: boolean) => void;
|
|
23
24
|
emit: (listener: (type: string, payload: unknown) => void) => () => void;
|
package/dist/browser.js
CHANGED
|
@@ -63,12 +63,35 @@ function secureUUID() {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// src/browser/storage.ts
|
|
66
|
+
async function waitForAccount(operation, signal) {
|
|
67
|
+
if (!signal)
|
|
68
|
+
return operation;
|
|
69
|
+
let abort;
|
|
70
|
+
try {
|
|
71
|
+
return await Promise.race([operation, new Promise((resolve, reject) => {
|
|
72
|
+
abort = () => reject(signal.reason);
|
|
73
|
+
if (signal.aborted)
|
|
74
|
+
abort();
|
|
75
|
+
else
|
|
76
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
77
|
+
})]);
|
|
78
|
+
} finally {
|
|
79
|
+
signal.removeEventListener("abort", abort);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
66
82
|
async function createBrowserStorage(options) {
|
|
67
83
|
const { storage, namespace, tokenKey, changed } = options;
|
|
68
84
|
const sessionTokenKey = options.sessionTokenKey || "auth.token";
|
|
69
85
|
const prefix = `${namespace}:`;
|
|
70
|
-
const lock = options.lock === null ? undefined : options.lock ?? (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function" ? (name, operation) => navigator.locks.request(name, operation) : undefined);
|
|
71
|
-
const exclusive = (operation) =>
|
|
86
|
+
const lock = options.lock === null ? undefined : options.lock ?? (typeof navigator !== "undefined" && typeof navigator.locks?.request === "function" ? (name, operation, settings) => navigator.locks.request(name, settings ?? {}, operation) : undefined);
|
|
87
|
+
const exclusive = (operation, signal) => {
|
|
88
|
+
const guarded = () => {
|
|
89
|
+
signal?.throwIfAborted();
|
|
90
|
+
return operation();
|
|
91
|
+
};
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
return lock ? lock(`${namespace}:storage`, guarded, { signal }) : Promise.resolve().then(guarded);
|
|
94
|
+
};
|
|
72
95
|
const scopeChanged = () => Object.assign(new Error("The storage account changed"), { code: "STORAGE_SCOPE_CHANGED" });
|
|
73
96
|
const pending = new Map;
|
|
74
97
|
let token = storage.getItem(tokenKey);
|
|
@@ -215,10 +238,11 @@ async function createBrowserStorage(options) {
|
|
|
215
238
|
keys: () => available() ? keys(key("snapshot:")).map((name) => name.slice(key("snapshot:").length)) : []
|
|
216
239
|
}
|
|
217
240
|
},
|
|
218
|
-
async call(module, method, args) {
|
|
241
|
+
async call(module, method, args, signal) {
|
|
242
|
+
signal?.throwIfAborted();
|
|
219
243
|
const captured = capture();
|
|
220
244
|
const wasAvailable = available();
|
|
221
|
-
await accountChanged();
|
|
245
|
+
await waitForAccount(accountChanged(), signal);
|
|
222
246
|
const name = String(args[0] ?? "");
|
|
223
247
|
if (module === "Storage") {
|
|
224
248
|
if (!wasAvailable)
|
|
@@ -259,7 +283,7 @@ async function createBrowserStorage(options) {
|
|
|
259
283
|
return true;
|
|
260
284
|
}
|
|
261
285
|
throw Object.assign(new Error(`Unsupported browser storage operation ${method}`), { code: "UNSUPPORTED" });
|
|
262
|
-
});
|
|
286
|
+
}, signal);
|
|
263
287
|
}
|
|
264
288
|
if (module === "SecureStorage") {
|
|
265
289
|
if (!wasAvailable)
|
|
@@ -284,7 +308,7 @@ async function createBrowserStorage(options) {
|
|
|
284
308
|
return true;
|
|
285
309
|
}
|
|
286
310
|
throw Object.assign(new Error(`Unsupported browser storage operation ${module}.${method}`), { code: "UNSUPPORTED" });
|
|
287
|
-
});
|
|
311
|
+
}, signal);
|
|
288
312
|
if (method === "clear" || name === sessionTokenKey && (method === "set" || method === "remove"))
|
|
289
313
|
await accountChanged();
|
|
290
314
|
return result;
|
|
@@ -608,7 +632,7 @@ async function createBrowserPlatform(options = {}) {
|
|
|
608
632
|
const first = object(args[0]);
|
|
609
633
|
if (module === "Storage" || module === "SecureStorage") {
|
|
610
634
|
const before = persistence.craft.secureStorage.getSync(sessionTokenKey);
|
|
611
|
-
const answer = await persistence.call(module, method, args);
|
|
635
|
+
const answer = await persistence.call(module, method, args, signal);
|
|
612
636
|
if (before !== persistence.craft.secureStorage.getSync(sessionTokenKey))
|
|
613
637
|
await resetAccount();
|
|
614
638
|
return answer;
|