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 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", "2048", "-cores", "2", "-accel", options.acceleration === "software" ? "off" : "on"], { env, stdout: log, stderr: log });
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: 2,
311
- memoryMb: 2048,
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", "-t", "1"], undefined, probeTimeout(), true);
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
- 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;
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
- return JSONArray(TYPES.filter { type -> granted.containsAll(readPermissions(type)) })
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(context: Context, private val secureEnabled: Boolean) {
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
- val temporary = File(file.parentFile, "${file.name}.tmp")
363
- temporary.writeText(json)
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
- if (ok) changed("snapshots", name, json, null)
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() {
@@ -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; browsers use their native Web Locks implementation. */
21
- lock?: (<T>(name: string, operation: () => T | Promise<T>) => Promise<T>) | null;
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) => lock ? lock(`${namespace}:storage`, operation) : Promise.resolve().then(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;