craft-native 0.0.290 → 0.0.296

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.
@@ -45,6 +45,10 @@ export interface CraftAndroidConfig {
45
45
  renderer?: 'web' | 'native';
46
46
  /** "system" follows the device; light/dark pin the generated host. */
47
47
  appearance?: 'light' | 'dark' | 'system';
48
+ /** Static native tab-bar styling applied before the page's own tabBar.set call. */
49
+ tabBar?: CraftTabBarConfig;
50
+ /** Runs from the generated project root before Gradle compiles the app. */
51
+ postGenerate?: CraftPostGenerateHook;
48
52
  version?: string;
49
53
  versionCode?: number;
50
54
  darkMode?: boolean;
@@ -176,8 +180,21 @@ export interface CraftAndroidConfig {
176
180
  sessions?: boolean;
177
181
  };
178
182
  }
183
+ export interface CraftTabBarConfig {
184
+ selection?: {
185
+ /** The selected tab indicator's corner radius in dp; omitted keeps the default. */
186
+ cornerRadius?: number;
187
+ /** The selected tab indicator fill as #RRGGBB. */
188
+ color?: string;
189
+ };
190
+ }
191
+ /** A declared command Craft runs after the native project has been generated. */
192
+ export interface CraftPostGenerateHook {
193
+ command: string;
194
+ args?: string[];
195
+ }
179
196
  /** 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';
197
+ export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat' | 'bloodGlucose' | 'insulinDelivery';
181
198
  /**
182
199
  * The Health Connect permissions behind each type, for reading and for
183
200
  * writing. CraftNativeHealth.kt asks for the same ones; a permission the
@@ -227,6 +244,13 @@ export interface BuildOptions {
227
244
  /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
228
245
  runtimeDir?: string | null;
229
246
  }
247
+ /**
248
+ * Run the app's declared generator hook without invoking a shell.
249
+ *
250
+ * A hook gets the generated project as its cwd and explicit metadata so a
251
+ * patch can target the right platform and phase without guessing from paths.
252
+ */
253
+ export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
230
254
  export interface OpenOptions {
231
255
  output: string;
232
256
  }
@@ -480,7 +480,9 @@ var HEALTH_CONNECT_PERMISSIONS = {
480
480
  dietaryEnergy: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
481
481
  dietaryProtein: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
482
482
  dietaryCarbohydrates: { read: ["READ_NUTRITION"], write: ["WRITE_NUTRITION"] },
483
- 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: [] }
484
486
  };
485
487
  var DEFAULT_HEALTH_CONNECT = { read: ["steps", "heartRate", "activeEnergy", "distance", "workouts"], write: ["workouts"] };
486
488
  function healthConnectPermissions(config) {
@@ -505,6 +507,30 @@ function healthConnectPermissions(config) {
505
507
  permissions.add("android.permission.health.READ_HEALTH_DATA_HISTORY");
506
508
  return [...permissions];
507
509
  }
510
+ function runPostGenerateHook(hook, output, phase = "build") {
511
+ if (!hook)
512
+ return;
513
+ if (typeof hook.command !== "string" || !hook.command.trim())
514
+ throw new Error("Android postGenerate.command must be a non-empty executable");
515
+ if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
516
+ throw new Error("Android postGenerate.args must be an array of strings");
517
+ const command = [hook.command, ...hook.args ?? []];
518
+ let result;
519
+ try {
520
+ result = Bun.spawnSync(command, {
521
+ cwd: output,
522
+ env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "android", CRAFT_GENERATE_PHASE: phase },
523
+ stdout: "inherit",
524
+ stderr: "pipe"
525
+ });
526
+ } catch (error) {
527
+ throw new Error(`Android postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
528
+ }
529
+ if (result.exitCode !== 0) {
530
+ const stderr = result.stderr?.toString().trim();
531
+ throw new Error(`Android postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
532
+ }
533
+ }
508
534
  var DEFAULT_CONFIG = {
509
535
  renderer: "web",
510
536
  version: "1.0.0",
@@ -632,6 +658,11 @@ function validateAndroidConfig(config) {
632
658
  if (config.appearance !== undefined && !["light", "dark", "system"].includes(config.appearance)) {
633
659
  throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`);
634
660
  }
661
+ const selection = config.tabBar?.selection;
662
+ if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
663
+ throw new Error("Android tabBar.selection.cornerRadius must be a finite non-negative number");
664
+ if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
665
+ throw new Error("Android tabBar.selection.color must be a #RRGGBB color");
635
666
  const packageSegments = config.packageName.split(".");
636
667
  if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
637
668
  throw new Error(`Invalid Android package name: ${config.packageName}`);
@@ -1396,6 +1427,7 @@ async function build(options) {
1396
1427
  console.log(` Synced: ${htmlPath} \u2192 assets/`);
1397
1428
  }
1398
1429
  syncNativeFonts(config.nativeFonts, output);
1430
+ runPostGenerateHook(config.postGenerate, output);
1399
1431
  if (!compile)
1400
1432
  return;
1401
1433
  const gradlewPath = join3(output, "gradlew");
@@ -1484,6 +1516,7 @@ export {
1484
1516
  resolveRuntimeDir,
1485
1517
  run,
1486
1518
  runKotlinFixture,
1519
+ runPostGenerateHook,
1487
1520
  syncAndroidWebAssets,
1488
1521
  syncNativeFonts
1489
1522
  };
@@ -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
 
@@ -86,6 +86,11 @@ internal data class CraftNativeTab(
86
86
  val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null,
87
87
  )
88
88
 
89
+ internal data class CraftNativeTabBarAppearance(
90
+ val selectionColor: String? = null,
91
+ val selectionCornerRadius: Float? = null,
92
+ )
93
+
89
94
  internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean)
90
95
 
91
96
  /** The app's paths (`nativeScreens`), its tabs and its site. */
@@ -122,6 +127,16 @@ internal class CraftNativeRoutes(config: JSONObject) {
122
127
  }
123
128
  }
124
129
 
130
+ val tabBarAppearance: CraftNativeTabBarAppearance = config.optJSONObject("tabBar")
131
+ ?.optJSONObject("selection")
132
+ ?.let { selection ->
133
+ CraftNativeTabBarAppearance(
134
+ selection.optString("color").takeIf { it.isNotBlank() },
135
+ (selection.opt("cornerRadius") as? Number)?.toFloat()?.takeIf { it.isFinite() && it >= 0 },
136
+ )
137
+ }
138
+ ?: CraftNativeTabBarAppearance()
139
+
125
140
  /** The app's site (`devServerURL`): its origin answers paths no screen draws. */
126
141
  val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
127
142
  val siteOrigin: String? = site?.let { uri ->
@@ -755,7 +770,11 @@ internal object CraftNativePrelude {
755
770
  * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
756
771
  * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
757
772
  */
758
- internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) {
773
+ internal class CraftNativeChrome(
774
+ context: Context,
775
+ private val tabs: List<CraftNativeTab>,
776
+ private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
777
+ ) : LinearLayout(context) {
759
778
  val content = FrameLayout(context)
760
779
  private val bar = LinearLayout(context)
761
780
  private val backButton = ImageView(context)
@@ -1099,6 +1118,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
1099
1118
  accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
1100
1119
  tabViews.forEachIndexed { index, (icon, label) ->
1101
1120
  tabBar.getChildAt(index).isSelected = index == selectedTab
1121
+ tabBar.getChildAt(index).background = tabCellBackground(index, dark)
1102
1122
  val color = if (index == selectedTab) accent else muted
1103
1123
  val id = tabs[index].id
1104
1124
  val number = tabIconText[id]
@@ -1188,6 +1208,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
1188
1208
 
1189
1209
  private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1190
1210
 
1211
+ private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1212
+ if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1213
+ val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() }
1214
+ ?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0)
1215
+ val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat()
1216
+ return GradientDrawable().apply {
1217
+ setColor(color)
1218
+ cornerRadius = radius
1219
+ }
1220
+ }
1221
+
1191
1222
  private fun rippleBackground(): android.graphics.drawable.Drawable? {
1192
1223
  val value = TypedValue()
1193
1224
  return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) {
@@ -4198,7 +4198,7 @@ class MainActivity : AppCompatActivity() {
4198
4198
  }
4199
4199
 
4200
4200
  private fun installChrome() {
4201
- val bars = CraftNativeChrome(this, routes.tabs)
4201
+ val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance)
4202
4202
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
4203
4203
  bars.onBack = { onBackPressedDispatcherCompat() }
4204
4204
  bars.onAccessory = { path -> navigateOpen(path) }
package/dist/api/app.d.ts CHANGED
@@ -361,11 +361,11 @@ declare class AppManager {
361
361
  */
362
362
  isShortcutRegistered(accelerator: string): Promise<boolean>;
363
363
  /**
364
- * Set app appearance (macOS)
364
+ * Set the app appearance on native hosts.
365
365
  */
366
366
  setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>;
367
367
  /**
368
- * Get current appearance
368
+ * Get the current effective Light/Dark appearance.
369
369
  */
370
370
  getAppearance(): Promise<'light' | 'dark'>;
371
371
  /**
@@ -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
  /**
package/dist/cli.js CHANGED
@@ -2945,7 +2945,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2945
2945
  `);
2946
2946
  }
2947
2947
  // package.json
2948
- var version = "0.0.290";
2948
+ var version = "0.0.296";
2949
2949
 
2950
2950
  // bin/cli.ts
2951
2951
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -374,7 +374,7 @@ function craftBinaryNotFoundMessage(triedPath) {
374
374
  `);
375
375
  }
376
376
  // package.json
377
- var version = "0.0.290";
377
+ var version = "0.0.296";
378
378
 
379
379
  // src/sdk-version.ts
380
380
  var SDK_VERSION = version;
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.290";
120
+ var version = "0.0.296";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -9,6 +9,19 @@ export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], outpu
9
9
  /** This package's version, which the app names in its user agent (`Craft/<version>`). */
10
10
  export declare function resolveCraftVersion(startDir?: string): string;
11
11
  export type CraftAppearance = 'light' | 'dark' | 'system';
12
+ /** A declared command Craft runs after the native project has been generated. */
13
+ export interface CraftPostGenerateHook {
14
+ command: string;
15
+ args?: string[];
16
+ }
17
+ export interface CraftTabBarConfig {
18
+ selection?: {
19
+ /** The selected tab indicator's corner radius in points; omitted keeps a capsule. */
20
+ cornerRadius?: number;
21
+ /** The selected tab indicator fill as #RRGGBB. */
22
+ color?: string;
23
+ };
24
+ }
12
25
  export interface CraftConfig {
13
26
  appShortcuts?: CraftAppShortcut[];
14
27
  /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
@@ -29,6 +42,10 @@ export interface CraftConfig {
29
42
  * and `dark` pin one. Unset keeps the older `darkMode` switch.
30
43
  */
31
44
  appearance?: CraftAppearance;
45
+ /** Static native tab-bar styling applied before the page's own tabBar.set call. */
46
+ tabBar?: CraftTabBarConfig;
47
+ /** Runs from the generated project root after `craft ios build` writes it. */
48
+ postGenerate?: CraftPostGenerateHook;
32
49
  backgroundColor?: string;
33
50
  /** The launch and webview background while the phone is in Dark Mode. */
34
51
  backgroundColorDark?: string;
@@ -409,6 +426,13 @@ export interface BuildOptions {
409
426
  */
410
427
  runtimeDir?: string | null;
411
428
  }
429
+ /**
430
+ * Run the app's declared generator hook without invoking a shell.
431
+ *
432
+ * A hook gets the generated project as its cwd and explicit metadata so a
433
+ * patch can target the right platform and phase without guessing from paths.
434
+ */
435
+ export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
412
436
  export interface OpenOptions {
413
437
  output: string;
414
438
  }
@@ -512,6 +512,30 @@ function installNativeBundle(source, output) {
512
512
  function hasInstalledNativeBundle(output) {
513
513
  return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
514
514
  }
515
+ function runPostGenerateHook(hook, output, phase = "build") {
516
+ if (!hook)
517
+ return;
518
+ if (typeof hook.command !== "string" || !hook.command.trim())
519
+ throw new Error("iOS postGenerate.command must be a non-empty executable");
520
+ if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
521
+ throw new Error("iOS postGenerate.args must be an array of strings");
522
+ const command = [hook.command, ...hook.args ?? []];
523
+ let result;
524
+ try {
525
+ result = Bun.spawnSync(command, {
526
+ cwd: output,
527
+ env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "ios", CRAFT_GENERATE_PHASE: phase },
528
+ stdout: "inherit",
529
+ stderr: "pipe"
530
+ });
531
+ } catch (error) {
532
+ throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
533
+ }
534
+ if (result.exitCode !== 0) {
535
+ const stderr = result.stderr?.toString().trim();
536
+ throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
537
+ }
538
+ }
515
539
  var DEFAULT_CONFIG = {
516
540
  renderer: "web",
517
541
  version: "1.0.0",
@@ -1230,6 +1254,11 @@ async function init(options) {
1230
1254
  if (config.renderer !== "web" && config.renderer !== "native") {
1231
1255
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1232
1256
  }
1257
+ const selection = config.tabBar?.selection;
1258
+ if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1259
+ throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
1260
+ if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
1261
+ throw new Error("iOS tabBar.selection.color must be a #RRGGBB color");
1233
1262
  if (config.enableBackgroundLocation)
1234
1263
  config.enableGeolocation = true;
1235
1264
  const problems = hybridConfigProblems(config);
@@ -1425,6 +1454,7 @@ async function build(options) {
1425
1454
  cause: error
1426
1455
  });
1427
1456
  }
1457
+ runPostGenerateHook(config.postGenerate, output);
1428
1458
  console.log("");
1429
1459
  }
1430
1460
  async function open(options) {
@@ -1590,6 +1620,7 @@ export {
1590
1620
  resolveRuntimeDir,
1591
1621
  restoreShell,
1592
1622
  run,
1623
+ runPostGenerateHook,
1593
1624
  serializeSimulatorSteps,
1594
1625
  showSimulator,
1595
1626
  syncNativeFonts,
@@ -102,6 +102,96 @@ enum CraftPageAppearance {
102
102
  }
103
103
  }
104
104
 
105
+ /// The app-wide UIKit appearance override. SwiftUI's `preferredColorScheme`
106
+ /// only controls the SwiftUI environment; native screen controllers need the
107
+ /// UIKit override as well so their trait collection, controls and navigation
108
+ /// chrome resolve the same way. A runtime choice is kept here so screens
109
+ /// created after the choice inherit it instead of reverting to the bundle
110
+ /// config.
111
+ enum CraftInterfaceStyleController {
112
+ /// The app's bundled choice is the default for newly-created screens.
113
+ /// A runtime choice is separate: it is the only value that should
114
+ /// override a screen's own generated config.
115
+ private static var configured: UIUserInterfaceStyle = .unspecified
116
+ private static var runtimeSelection: UIUserInterfaceStyle?
117
+
118
+ static func configuredStyle(for config: CraftConfig) -> UIUserInterfaceStyle {
119
+ switch config.appearance {
120
+ case "system": return .unspecified
121
+ case "light": return .light
122
+ case "dark": return .dark
123
+ default: return config.darkMode ? .dark : .light
124
+ }
125
+ }
126
+
127
+ static func configure(_ config: CraftConfig) {
128
+ configured = configuredStyle(for: config)
129
+ runtimeSelection = nil
130
+ applyToWindows()
131
+ }
132
+
133
+ static func style(for config: CraftConfig) -> UIUserInterfaceStyle {
134
+ runtimeSelection ?? configuredStyle(for: config)
135
+ }
136
+
137
+ static func colorScheme(for config: CraftConfig) -> ColorScheme? {
138
+ switch style(for: config) {
139
+ case .light: return .light
140
+ case .dark: return .dark
141
+ default: return nil
142
+ }
143
+ }
144
+
145
+ /// Returns false rather than silently accepting a typo from JavaScript.
146
+ @discardableResult
147
+ static func set(_ value: String) -> Bool {
148
+ let style: UIUserInterfaceStyle
149
+ switch value {
150
+ case "system": style = .unspecified
151
+ case "light": style = .light
152
+ case "dark": style = .dark
153
+ default: return false
154
+ }
155
+ runtimeSelection = style
156
+ applyToWindows()
157
+ NotificationCenter.default.post(name: .craftColorSchemeChanged, object: nil)
158
+ return true
159
+ }
160
+
161
+ static func currentName() -> String {
162
+ let requested = runtimeSelection ?? UIApplication.shared.connectedScenes
163
+ .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
164
+ .first ?? .unspecified
165
+ let style = requested == .unspecified
166
+ ? (UIApplication.shared.connectedScenes
167
+ .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow })?.traitCollection.userInterfaceStyle }
168
+ .first { $0 != .unspecified } ?? UITraitCollection.current.userInterfaceStyle)
169
+ : requested
170
+ return style == .dark ? "dark" : "light"
171
+ }
172
+
173
+ static func apply(to controller: UIViewController, config: CraftConfig) {
174
+ controller.overrideUserInterfaceStyle = style(for: config)
175
+ }
176
+
177
+ private static func applyToWindows() {
178
+ let style = runtimeSelection ?? configured
179
+ for scene in UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }) {
180
+ for window in scene.windows {
181
+ window.overrideUserInterfaceStyle = style
182
+ apply(style, to: window.rootViewController)
183
+ }
184
+ }
185
+ }
186
+
187
+ private static func apply(_ style: UIUserInterfaceStyle, to controller: UIViewController?) {
188
+ guard let controller else { return }
189
+ controller.overrideUserInterfaceStyle = style
190
+ controller.children.forEach { apply(style, to: $0) }
191
+ if let presented = controller.presentedViewController { apply(style, to: presented) }
192
+ }
193
+ }
194
+
105
195
  /// Tells the shell when SwiftUI's colour scheme changes: the one appearance
106
196
  /// setting with no notification of its own.
107
197
  struct CraftColorSchemeObserver: View {
@@ -195,6 +285,7 @@ struct CraftRecoveryBudget {
195
285
  final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
196
286
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
197
287
  let config = CraftConfig.bundled()
288
+ CraftInterfaceStyleController.configure(config)
198
289
  // First, so a crash anywhere in the rest of launch is reported too.
199
290
  CraftCrashReporter.shared.start(config)
200
291
  UNUserNotificationCenter.current().delegate = self
@@ -606,7 +697,7 @@ struct CraftApp: App {
606
697
  .ignoresSafeArea(.keyboard, edges: .bottom)
607
698
  }
608
699
  }
609
- .preferredColorScheme(appState.config.colorScheme)
700
+ .preferredColorScheme(appState.appearanceScheme)
610
701
  .environmentObject(appState)
611
702
  .onOpenURL { url in
612
703
  // Handle deep links and universal links
@@ -689,6 +780,10 @@ final class CraftChrome: ObservableObject {
689
780
  /// The selected tab highlight's corner radius. Nil preserves the default
690
781
  /// capsule, while a finite non-negative value opts into a rounded rect.
691
782
  private(set) var tabSelectionCornerRadius: CGFloat?
783
+ /// The configured static shape, retained when the page updates only its
784
+ /// tabs or selected item at runtime.
785
+ private var configuredTabSelectionCornerRadius: CGFloat?
786
+ private(set) var tabSelectionColor: UIColor?
692
787
 
693
788
  weak var webView: WKWebView?
694
789
  /// What the tab bar covers at the bottom of the screen, in points.
@@ -723,9 +818,13 @@ final class CraftChrome: ObservableObject {
723
818
  /// The tab bar from `tabs` in craft.config.json, there before the page
724
819
  /// loads; the page's own `tabBar` message replaces it later. `splash`
725
820
  /// false when the first screen is native and draws itself.
726
- func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
821
+ func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool, appearance: CraftConfig.TabBar? = nil) {
727
822
  splashVisible = splash
728
823
  tabSwipeEnabled = true
824
+ configuredTabSelectionCornerRadius = appearance?.selection?.cornerRadius
825
+ .flatMap { $0.isFinite && $0 >= 0 ? CGFloat($0) : nil }
826
+ tabSelectionCornerRadius = configuredTabSelectionCornerRadius
827
+ tabSelectionColor = appearance?.selection?.color.flatMap { UIColor(hex: $0) }
729
828
  guard !configured.isEmpty else { return }
730
829
  tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil, selectedSymbol: Tab.normalizedSelectedSymbol($0.selectedSymbol), selectedIconTint: $0.selectedIconTint, selectedIconPulse: $0.selectedIconPulse) }
731
830
  self.selected = selected ?? configured.first?.id
@@ -856,6 +955,7 @@ final class CraftChrome: ObservableObject {
856
955
  tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
857
956
  }
858
957
  if let swipeEnabled = body["swipeEnabled"] as? Bool { tabSwipeEnabled = swipeEnabled }
958
+ if !body.keys.contains("selection") { tabSelectionCornerRadius = configuredTabSelectionCornerRadius }
859
959
  withAnimation(Self.motion(.easeOut(duration: 0.2))) { tabBarVisible = !tabs.isEmpty && !hiddenByScreen }
860
960
  publishLayout()
861
961
  case "selectTab":
@@ -1312,6 +1412,7 @@ private struct CraftTabSelectionShape: Shape {
1312
1412
  }
1313
1413
 
1314
1414
  private struct CraftTabSelectionIndicator: View {
1415
+ @ObservedObject private var chrome = CraftChrome.shared
1315
1416
  @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
1316
1417
  let selected: Int?
1317
1418
  let count: Int
@@ -1323,7 +1424,7 @@ private struct CraftTabSelectionIndicator: View {
1323
1424
  let width = geometry.size.width / CGFloat(max(1, count))
1324
1425
  CraftTabSelectionShape(cornerRadius: cornerRadius)
1325
1426
  .frame(width: width, height: geometry.size.height)
1326
- .foregroundStyle(Color.primary.opacity(0.09))
1427
+ .foregroundStyle(chrome.tabSelectionColor.map { Color($0) } ?? Color.primary.opacity(0.09))
1327
1428
  .offset(x: position * width)
1328
1429
  }
1329
1430
  }
@@ -1557,6 +1658,8 @@ class DeepLinkManager {
1557
1658
  // MARK: - App State
1558
1659
  class AppState: ObservableObject {
1559
1660
  @Published var config: CraftConfig
1661
+ @Published private(set) var appearanceScheme: ColorScheme?
1662
+ private var appearanceObserver: NSObjectProtocol?
1560
1663
 
1561
1664
  init() {
1562
1665
  if let configURL = Bundle.main.url(forResource: "craft.config", withExtension: "json"),
@@ -1565,11 +1668,26 @@ class AppState: ObservableObject {
1565
1668
  } else {
1566
1669
  self.config = CraftConfig()
1567
1670
  }
1671
+ self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: config)
1672
+ appearanceObserver = NotificationCenter.default.addObserver(
1673
+ forName: .craftColorSchemeChanged,
1674
+ object: nil,
1675
+ queue: .main,
1676
+ ) { [weak self] _ in
1677
+ guard let self else { return }
1678
+ self.appearanceScheme = CraftInterfaceStyleController.colorScheme(for: self.config)
1679
+ }
1568
1680
  // Before the first frame: the hybrid start screen, and the tab bar
1569
1681
  // from the config, are decided here, if the app delegate has not
1570
1682
  // already (it does at launch; the first call decides).
1571
1683
  CraftHybrid.shared.configure(config)
1572
1684
  }
1685
+
1686
+ deinit {
1687
+ if let appearanceObserver {
1688
+ NotificationCenter.default.removeObserver(appearanceObserver)
1689
+ }
1690
+ }
1573
1691
  }
1574
1692
 
1575
1693
  extension CraftConfig {
@@ -1706,6 +1824,9 @@ struct CraftConfig: Codable {
1706
1824
  var nativeScreens: [String: String]? = nil
1707
1825
  /// The tab bar to show before the page describes its own.
1708
1826
  var tabs: [Tab]? = nil
1827
+ /// Static styling for the selected tab indicator; the page may still
1828
+ /// override its shape through `tabBar.set({ selection })`.
1829
+ var tabBar: TabBar? = nil
1709
1830
  /// Hybrid: web-storage keys the page's own code writes, mirrored into the
1710
1831
  /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
1711
1832
  var shareStorage: [String: String]? = nil
@@ -1733,6 +1854,15 @@ struct CraftConfig: Codable {
1733
1854
  var selectedIconPulse: Bool? = nil
1734
1855
  }
1735
1856
 
1857
+ struct TabBar: Codable {
1858
+ var selection: TabSelection? = nil
1859
+ }
1860
+
1861
+ struct TabSelection: Codable {
1862
+ var cornerRadius: Double? = nil
1863
+ var color: String? = nil
1864
+ }
1865
+
1736
1866
  struct BackgroundRefresh: Codable {
1737
1867
  var enabled: Bool = false
1738
1868
  /// The BGTaskScheduler identifier; `<bundleId>.refresh` if unset.
@@ -2535,6 +2665,18 @@ struct CraftWebView: UIViewRepresentable {
2535
2665
  resolveCallback(callbackId, result: CraftSymbols.image(named: body["name"] as? String ?? "", options: body) ?? NSNull())
2536
2666
  case "statusBarSetStyle":
2537
2667
  resolveCallback(callbackId, result: CraftStatusBar.setStyle(body["style"] as? String ?? "default"))
2668
+ case "setAppearance":
2669
+ guard let appearance = body["appearance"] as? String else {
2670
+ rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2671
+ return
2672
+ }
2673
+ guard CraftInterfaceStyleController.set(appearance) else {
2674
+ rejectCallback(callbackId, error: "setAppearance needs light, dark or system", code: "INVALID_ARGUMENT")
2675
+ return
2676
+ }
2677
+ resolveCallback(callbackId, result: true)
2678
+ case "getAppearance":
2679
+ resolveCallback(callbackId, result: CraftInterfaceStyleController.currentName())
2538
2680
  case "chromeSetUnderPageColor":
2539
2681
  // What shows past the page's edges when it rubber-bands.
2540
2682
  if #available(iOS 15.0, *), let webView, let css = body["color"] as? String, let color = UIColor(css: css) {
@@ -3116,6 +3258,22 @@ struct CraftWebView: UIViewRepresentable {
3116
3258
  } else {
3117
3259
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3118
3260
  }
3261
+ // Glucose, insulin and carbs, one sample at a time (CraftHealth.swift).
3262
+ case "getHealthSamples":
3263
+ if config.enableHealthKit {
3264
+ if let dataType = body["type"] as? String {
3265
+ CraftHealth.shared.getSamples(
3266
+ type: dataType,
3267
+ startDate: body["startDate"] as? Double,
3268
+ endDate: body["endDate"] as? Double,
3269
+ limit: body["limit"] as? Int
3270
+ ) { [weak self] in self?.answer(callbackId, $0) }
3271
+ } else {
3272
+ rejectCallback(callbackId, error: "getHealthSamples was called without a type", code: "INVALID_ARGUMENT")
3273
+ }
3274
+ } else {
3275
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
3276
+ }
3119
3277
  case "getHealthSleepIntervals":
3120
3278
  if config.enableHealthKit {
3121
3279
  getHealthSleepIntervals(
@@ -5580,6 +5738,14 @@ struct CraftWebView: UIViewRepresentable {
5580
5738
  getInfo: function() { return craft.getDeviceInfo(); },
5581
5739
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
5582
5740
  };
5741
+ craft.app = Object.assign(craft.app || {}, {
5742
+ setAppearance: function(appearance) {
5743
+ return craft._invoke('setAppearance', {appearance: appearance});
5744
+ },
5745
+ getAppearance: function() {
5746
+ return craft._invoke('getAppearance', {});
5747
+ }
5748
+ });
5583
5749
  // Feedback, as on Android and the web, where there is no
5584
5750
  // motor to fire: an app that left enableHaptics off gets
5585
5751
  // nothing played and a settled promise, so `await
@@ -5749,6 +5915,10 @@ struct CraftWebView: UIViewRepresentable {
5749
5915
  options = options || {};
5750
5916
  return craft._invoke('getHealthHeartRateSamples', {startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5751
5917
  },
5918
+ getSamples: function(type, options) {
5919
+ options = options || {};
5920
+ return craft._invoke('getHealthSamples', {type: type, startDate: options.startDate, endDate: options.endDate, limit: options.limit});
5921
+ },
5752
5922
  getSleepIntervals: function(options) {
5753
5923
  options = options || {};
5754
5924
  return craft._invoke('getHealthSleepIntervals', {startDate: options.startDate, endDate: options.endDate});
@@ -106,6 +106,9 @@ final class CraftHealth {
106
106
  getDailyStatistics(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
107
107
  case "getHeartRateSamples":
108
108
  getHeartRateSamples(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, limit: options(0)["limit"] as? Int, completion: completion)
109
+ case "getSamples":
110
+ guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getSamples needs a type", code: "INVALID_ARGUMENT"))) }
111
+ getSamples(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, limit: options(1)["limit"] as? Int, completion: completion)
109
112
  case "getSleepIntervals":
110
113
  getSleepIntervals(startDate: options(0)["startDate"] as? Double, endDate: options(0)["endDate"] as? Double, completion: completion)
111
114
  case "saveMindfulSession":
@@ -196,6 +199,12 @@ final class CraftHealth {
196
199
  readTypes.insert(mindfulType)
197
200
  shareTypes.insert(mindfulType)
198
201
  }
202
+ case "bloodGlucose", "insulinDelivery":
203
+ // A CGM, meter, pump or closed loop's own writes: read
204
+ // only, never written back, so the sheet only asks to read.
205
+ if let diabetesType = Self.healthQuantity(type)?.type {
206
+ readTypes.insert(diabetesType)
207
+ }
199
208
  case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
200
209
  // What a food log (MyFitnessPal, Lose It!, Cronometer...)
201
210
  // writes to Apple Health. Request the write grant too unless
@@ -404,6 +413,8 @@ final class CraftHealth {
404
413
  case "dietaryProtein": return (HKQuantityType.quantityType(forIdentifier: .dietaryProtein), .gram(), .cumulativeSum)
405
414
  case "dietaryCarbohydrates": return (HKQuantityType.quantityType(forIdentifier: .dietaryCarbohydrates), .gram(), .cumulativeSum)
406
415
  case "dietaryFat": return (HKQuantityType.quantityType(forIdentifier: .dietaryFatTotal), .gram(), .cumulativeSum)
416
+ case "bloodGlucose": return (HKQuantityType.quantityType(forIdentifier: .bloodGlucose), HKUnit(from: "mg/dL"), .discreteAverage)
417
+ case "insulinDelivery": return (HKQuantityType.quantityType(forIdentifier: .insulinDelivery), .internationalUnit(), .cumulativeSum)
407
418
  default: return nil
408
419
  }
409
420
  }
@@ -613,6 +624,71 @@ final class CraftHealth {
613
624
  healthStore.execute(query)
614
625
  }
615
626
 
627
+ /// The types `getSamples` reads, and the unit each answers in.
628
+ private static let sampleUnits: [String: String] = ["bloodGlucose": "mg/dL", "insulinDelivery": "IU", "dietaryCarbohydrates": "g"]
629
+
630
+ /// Every glucose reading, insulin dose or carb entry between two
631
+ /// instants, oldest first, as the writing app saved it: its UUID (so a
632
+ /// sync stores it once), value, source and device, plus what a diabetes
633
+ /// app needs to tell readings and doses apart: an insulin dose's reason
634
+ /// (basal or bolus) and a glucose reading's meal time.
635
+ func getSamples(type: String, startDate: Double?, endDate: Double?, limit: Int?, completion rawCompletion: @escaping Completion) {
636
+ let completion = completeOnMain(rawCompletion)
637
+ guard let healthStore = healthStore else {
638
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
639
+ return
640
+ }
641
+ guard let unitName = Self.sampleUnits[type], let spec = Self.healthQuantity(type), let sampleType = spec.type else {
642
+ completion(.failure(CraftHealthError(message: "getSamples reads bloodGlucose, insulinDelivery or dietaryCarbohydrates", code: "INVALID_ARGUMENT")))
643
+ return
644
+ }
645
+
646
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
647
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Calendar.current.date(byAdding: .day, value: -7, to: end)!
648
+ let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
649
+ let oldestFirst = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
650
+ let cap = max(1, min(limit ?? 10_000, 50_000))
651
+ let ownBundle = Bundle.main.bundleIdentifier
652
+
653
+ let query = HKSampleQuery(sampleType: sampleType, predicate: predicate, limit: cap, sortDescriptors: [oldestFirst]) { _, samples, error in
654
+ if let error = error, (error as? HKError)?.code != .errorNoData {
655
+ completion(.failure(CraftHealthError(message: error.localizedDescription)))
656
+ return
657
+ }
658
+ let rows: [[String: Any]] = (samples as? [HKQuantitySample] ?? []).map { sample in
659
+ let source = sample.sourceRevision.source
660
+ var item: [String: Any] = [
661
+ "id": sample.uuid.uuidString,
662
+ "value": sample.quantity.doubleValue(for: spec.unit),
663
+ "unit": unitName,
664
+ "startDate": sample.startDate.timeIntervalSince1970 * 1000,
665
+ "endDate": sample.endDate.timeIntervalSince1970 * 1000,
666
+ "sourceName": source.name,
667
+ "sourceBundleId": source.bundleIdentifier,
668
+ "ownApp": ownBundle != nil && source.bundleIdentifier == ownBundle,
669
+ ]
670
+ if let device = sample.device?.name { item["deviceName"] = device }
671
+ if let reason = sample.metadata?[HKMetadataKeyInsulinDeliveryReason] as? NSNumber {
672
+ switch HKInsulinDeliveryReason(rawValue: reason.intValue) {
673
+ case .basal: item["insulinReason"] = "basal"
674
+ case .bolus: item["insulinReason"] = "bolus"
675
+ default: break
676
+ }
677
+ }
678
+ if let meal = sample.metadata?[HKMetadataKeyBloodGlucoseMealTime] as? NSNumber {
679
+ switch HKBloodGlucoseMealTime(rawValue: meal.intValue) {
680
+ case .preprandial: item["mealTime"] = "beforeMeal"
681
+ case .postprandial: item["mealTime"] = "afterMeal"
682
+ default: break
683
+ }
684
+ }
685
+ return item
686
+ }
687
+ completion(.success(rows))
688
+ }
689
+ healthStore.execute(query)
690
+ }
691
+
616
692
  /// One heart-rate sample as a page sees it, from a read or the stream
617
693
  /// alike: bpm, when, the app and device that wrote it, a series' count,
618
694
  /// lowest and highest, and what the wearer was doing when the watch says.
@@ -380,7 +380,7 @@ final class CraftHybrid: NSObject {
380
380
  }
381
381
  startsNative = isEnabled && routes.match(startPath) != nil
382
382
  // The splash waits for a page; an all-native app's first screen draws itself.
383
- CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
383
+ CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative, appearance: config.tabBar)
384
384
 
385
385
  let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
386
386
  appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil
@@ -2983,7 +2983,10 @@ private class CraftNativeFlowView: UIStackView {
2983
2983
  // The screen reports onLayout from its own layout pass, which a
2984
2984
  // change deep in a scroll view's content never runs: a view that
2985
2985
  // went from 240 to 180 wide kept saying 240.
2986
- if moved { craftScreenController?.scheduleLayoutEvents() }
2986
+ if moved {
2987
+ craftScreenController?.scheduleLayoutEvents()
2988
+ craftScreenController?.scheduleKeyboardInsets()
2989
+ }
2987
2990
  }
2988
2991
  for (child, frame) in result.frames {
2989
2992
  var viewportFrame = frame
@@ -5112,6 +5115,11 @@ private final class CraftNativeTextView: UITextView {
5112
5115
  isScrollEnabled = overflows
5113
5116
  if overflows, isFirstResponder { scrollRangeToVisible(selectedRange) }
5114
5117
  }
5118
+ // Showing all its text, it has nothing to scroll. An offset left from
5119
+ // UIKit keeping the caret in view while the view was a layout short
5120
+ // of its text moved every line down inside it, and the caret of a
5121
+ // growing comment sat below the part of the screen it was kept in.
5122
+ if !isScrollEnabled, contentOffset != .zero { contentOffset = .zero }
5115
5123
  let inset = textContainerInset
5116
5124
  let padding = textContainer.lineFragmentPadding
5117
5125
  placeholderLabel.frame = CGRect(
@@ -5492,6 +5500,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5492
5500
  private var keyboardSafeAreaInset: CGFloat = 0
5493
5501
  /// Where the keyboard is on the screen while it is up, in screen coordinates.
5494
5502
  private var keyboardScreenFrame: CGRect?
5503
+ private var keyboardInsetsScheduled = false
5495
5504
  /// The hybrid shell (CraftHybrid.swift), when this screen is one of a web
5496
5505
  /// app's native screens: told of the first frame, of a first frame that
5497
5506
  /// could not be drawn, and of `craft.navigation.open(path)`.
@@ -5536,6 +5545,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5536
5545
 
5537
5546
  override func viewDidLoad() {
5538
5547
  super.viewDidLoad()
5548
+ CraftInterfaceStyleController.apply(to: self, config: config)
5539
5549
  navigationItem.title = routeName ?? config.appName
5540
5550
  view.backgroundColor = config.resolvedBackgroundColor
5541
5551
  rootStack.axis = .vertical
@@ -5802,6 +5812,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5802
5812
  positionModals()
5803
5813
  emitLayoutEvents()
5804
5814
  sendViewportIfChanged()
5815
+ scheduleKeyboardInsets()
5805
5816
  }
5806
5817
 
5807
5818
  /// The width the bundle last heard, so a layout pass at the same size says nothing.
@@ -6240,6 +6251,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6240
6251
  let secureGet: @convention(block) (String) -> String? = { key in
6241
6252
  config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
6242
6253
  }
6254
+ let setAppearance: @convention(block) (String) -> Bool = { value in
6255
+ if Thread.isMainThread { return CraftInterfaceStyleController.set(value) }
6256
+ var accepted = false
6257
+ DispatchQueue.main.sync { accepted = CraftInterfaceStyleController.set(value) }
6258
+ return accepted
6259
+ }
6260
+ let getAppearance: @convention(block) () -> String = {
6261
+ if Thread.isMainThread { return CraftInterfaceStyleController.currentName() }
6262
+ var value = "light"
6263
+ DispatchQueue.main.sync { value = CraftInterfaceStyleController.currentName() }
6264
+ return value
6265
+ }
6243
6266
  let setOptions: @convention(block) (String) -> Void = { [weak self] json in
6244
6267
  guard let data = json.data(using: .utf8),
6245
6268
  let options = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
@@ -6252,6 +6275,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6252
6275
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
6253
6276
  jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
6254
6277
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
6278
+ jsContext.setObject(setAppearance, forKeyedSubscript: "craftNativeSetAppearance" as NSString)
6279
+ jsContext.setObject(getAppearance, forKeyedSubscript: "craftNativeGetAppearance" as NSString)
6255
6280
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
6256
6281
  // Older native bundles call the page-shaped `craft.dialog` API
6257
6282
  // directly. Keep that API backed by the same capability protocol as
@@ -6494,6 +6519,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6494
6519
  return value === undefined ? null : value;
6495
6520
  }
6496
6521
  });
6522
+ keep('app', {
6523
+ setAppearance: function(value) {
6524
+ value = String(value);
6525
+ if (value !== 'light' && value !== 'dark' && value !== 'system') {
6526
+ return Promise.reject(new TypeError('craft.app.setAppearance needs light, dark or system'));
6527
+ }
6528
+ if (!craftNativeSetAppearance(value)) {
6529
+ return Promise.reject(new Error('craft.app.setAppearance was rejected'));
6530
+ }
6531
+ return Promise.resolve();
6532
+ },
6533
+ getAppearance: function() { return Promise.resolve(craftNativeGetAppearance()); }
6534
+ });
6497
6535
  // The signed-in account (CraftSession): the token under
6498
6536
  // `sessionTokenKey`, and each change of it, which the host
6499
6537
  // reports through `__craftSessionChanged` after the Keychain
@@ -7148,6 +7186,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7148
7186
  }
7149
7187
  }
7150
7188
 
7189
+ /// A layout moved a view while the keyboard is up: once that pass is
7190
+ /// over, each scroll view's keyboard inset is measured again where the
7191
+ /// scroll view now ends. Measured only when the keyboard moved, a form
7192
+ /// that grew down under the keyboard (a footer below it shrank, a
7193
+ /// banner above it went away) kept the inset of where it used to end,
7194
+ /// and its last lines stayed under the keyboard.
7195
+ fileprivate func scheduleKeyboardInsets() {
7196
+ guard keyboardScreenFrame != nil, !keyboardInsetsScheduled else { return }
7197
+ keyboardInsetsScheduled = true
7198
+ DispatchQueue.main.async { [weak self] in
7199
+ guard let self else { return }
7200
+ self.keyboardInsetsScheduled = false
7201
+ if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
7202
+ }
7203
+ }
7204
+
7151
7205
  /// The focused field, scrolled into the part of its scroll view the
7152
7206
  /// keyboard leaves, with a little room around it.
7153
7207
  func scrollFocusedInputIntoView(animated: Bool) {
@@ -7159,6 +7213,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7159
7213
  /// Internal for XCTest.
7160
7214
  func scrollIntoView(_ input: UIView, animated: Bool) {
7161
7215
  guard let scroll = enclosingVerticalScroll(of: input) else { return }
7216
+ // Measured against the keyboard where the scroll view is now.
7217
+ if keyboardScreenFrame != nil { insetScrollViewsForKeyboard() }
7162
7218
  var rect = input.convert(input.bounds, to: scroll).insetBy(dx: 0, dy: -16)
7163
7219
  // A field taller than the part the keyboard leaves (a long comment
7164
7220
  // with no max height) shows its caret rather than its top: scrolled
@@ -9951,6 +10007,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9951
10007
  textView.invalidateIntrinsicContentSize()
9952
10008
  CraftNativeFlexLayout.invalidateAncestors(of: textView)
9953
10009
  self.view.layoutIfNeeded()
10010
+ // That layout can move the scroll view: its keyboard inset is
10011
+ // measured where it now ends before the caret is.
10012
+ if self.keyboardScreenFrame != nil { self.insetScrollViewsForKeyboard() }
9954
10013
  guard let scroll = self.enclosingVerticalScroll(of: textView),
9955
10014
  let rect = self.caretRect(of: textView, in: scroll) else { return }
9956
10015
  let visible = scroll.bounds.inset(by: scroll.adjustedContentInset)
@@ -117,6 +117,10 @@ final class CraftWatchSession: NSObject, ObservableObject {
117
117
  }
118
118
 
119
119
  private func apply(_ payload: [String: Any]) {
120
+ if let incomingVersion = payload["stateVersion"] as? Int {
121
+ guard incomingVersion >= stateVersion else { return }
122
+ stateVersion = incomingVersion
123
+ }
120
124
  if let plan = payload["plan"] as? [String: Any], payload["type"] as? String == "planned-session" {
121
125
  plannedSession = plan
122
126
  status = "Plan ready"
package/dist/mobile.js CHANGED
@@ -1715,6 +1715,12 @@ var health = {
1715
1715
  throw new Error("Reading heart-rate samples is unavailable");
1716
1716
  return craft.health.getHeartRateSamples(options);
1717
1717
  },
1718
+ async getSamples(type, options = {}) {
1719
+ const craft = getCraftRoot();
1720
+ if (!craft?.health?.getSamples)
1721
+ throw new Error("Reading health samples is unavailable");
1722
+ return craft.health.getSamples(type, options);
1723
+ },
1718
1724
  async getSleepIntervals(options = {}) {
1719
1725
  const craft = getCraftRoot();
1720
1726
  if (!craft?.health?.getSleepIntervals)
package/dist/types.d.ts CHANGED
@@ -569,6 +569,17 @@ export interface AppConfig {
569
569
  * Craft System Tray API (available as window.craft.tray in WebView)
570
570
  */
571
571
  export interface CraftTrayAPI {
572
+ /** Read the rendered macOS status item, including its decoded image. */
573
+ getState(): Promise<{
574
+ title: string;
575
+ tooltip: string;
576
+ visible: boolean;
577
+ hasIcon: boolean;
578
+ template: boolean;
579
+ imageWidth: number;
580
+ imageHeight: number;
581
+ imagePosition: number;
582
+ }>;
572
583
  /** SF Symbol name, image path, or base64 image data URL. */
573
584
  setIcon(icon: string): Promise<void>;
574
585
  /**
@@ -649,6 +660,10 @@ export interface CraftAppAPI {
649
660
  * Get application information
650
661
  */
651
662
  getInfo(): Promise<AppInfo>;
663
+ /** Pin every native surface to Light, Dark, or the system setting. */
664
+ setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>;
665
+ /** Read the app's effective Light/Dark appearance. */
666
+ getAppearance(): Promise<'light' | 'dark'>;
652
667
  }
653
668
  /**
654
669
  * Tray click event details
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.290",
3
+ "version": "0.0.296",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",