craft-native 0.0.443 → 0.0.449

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
@@ -339,6 +339,29 @@ if (await updater.checkForUpdates()) {
339
339
  }
340
340
  ```
341
341
 
342
+ ### Background update lifecycle
343
+
344
+ Start and stop background checks through Craft rather than an app-owned timer:
345
+
346
+ ```ts
347
+ updater.startAutoCheck({
348
+ initialDelay: 60_000,
349
+ enabled: async () => (await readNativePreferences()).automaticUpdates,
350
+ autoDownload: true,
351
+ })
352
+ // On native backend shutdown:
353
+ updater.stopAutoCheck()
354
+ ```
355
+
356
+ `checkInterval` on the updater config controls cadence. `enabled` is re-read
357
+ at most a minute later (`policyInterval`), and again before a background download.
358
+ The host owns preference storage; Craft creates no parallel database. Starts are
359
+ idempotent, concurrent manifest checks and downloads share one operation, and a
360
+ verified download stays ready until the user chooses installation. Stopping also
361
+ invalidates policy reads already in flight. Downloads already started may finish.
362
+ `autoInstall` defaults to false; keep it false when installation requires a user
363
+ choice. Timers are unreferenced so they cannot keep a backend process alive.
364
+
342
365
  ### macOS trust
343
366
 
344
367
  The manifest's SHA-256 proves the download matches what the manifest asked for. It
package/dist/cli.js CHANGED
@@ -3228,7 +3228,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3228
3228
  `);
3229
3229
  }
3230
3230
  // package.json
3231
- var version = "0.0.443";
3231
+ var version = "0.0.449";
3232
3232
 
3233
3233
  // bin/cli.ts
3234
3234
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -376,7 +376,7 @@ function craftBinaryNotFoundMessage(triedPath) {
376
376
  `);
377
377
  }
378
378
  // package.json
379
- var version = "0.0.443";
379
+ var version = "0.0.449";
380
380
 
381
381
  // src/sdk-version.ts
382
382
  var SDK_VERSION = version;
@@ -11156,7 +11156,12 @@ class AutoUpdater extends import_events3.EventEmitter {
11156
11156
  config;
11157
11157
  updateInfo = null;
11158
11158
  downloadPath = null;
11159
+ verifiedDownloadPath = null;
11159
11160
  checkTimer = null;
11161
+ autoCheckGeneration = 0;
11162
+ autoCheckRunning = false;
11163
+ checking = null;
11164
+ downloading = null;
11160
11165
  cachedEtag = null;
11161
11166
  cachedLastModified = null;
11162
11167
  cachedManifest = null;
@@ -11193,21 +11198,63 @@ class AutoUpdater extends import_events3.EventEmitter {
11193
11198
  ...config
11194
11199
  };
11195
11200
  }
11196
- startAutoCheck() {
11197
- this.checkForUpdates();
11198
- if (this.config.checkInterval) {
11199
- this.checkTimer = setInterval(() => {
11200
- this.checkForUpdates();
11201
- }, this.config.checkInterval);
11202
- }
11201
+ startAutoCheck(options = {}) {
11202
+ if (this.autoCheckRunning)
11203
+ return;
11204
+ const initialDelay = options.initialDelay ?? 0;
11205
+ const policyInterval = options.policyInterval ?? 60000;
11206
+ const interval = this.config.checkInterval ?? 3600000;
11207
+ if (!Number.isFinite(initialDelay) || initialDelay < 0 || !Number.isFinite(policyInterval) || policyInterval <= 0 || !Number.isFinite(interval) || interval < 0)
11208
+ throw new Error("Updater: automatic check intervals must be finite and non-negative, with a positive policyInterval");
11209
+ this.autoCheckRunning = true;
11210
+ const generation = ++this.autoCheckGeneration;
11211
+ const active = () => this.autoCheckRunning && generation === this.autoCheckGeneration;
11212
+ const enabled = async () => active() && (await options.enabled?.() ?? true) && active();
11213
+ let nextCheck = Date.now() + initialDelay;
11214
+ const schedule = (delay) => {
11215
+ if (!active())
11216
+ return;
11217
+ this.checkTimer = setTimeout(() => {
11218
+ tick();
11219
+ }, delay);
11220
+ this.checkTimer.unref?.();
11221
+ };
11222
+ const tick = async () => {
11223
+ try {
11224
+ if (await enabled() && Date.now() >= nextCheck && !(this.verifiedDownloadPath && import_fs4.existsSync(this.verifiedDownloadPath)) && !this.checking && !this.downloading) {
11225
+ nextCheck = interval === 0 ? Infinity : Date.now() + interval;
11226
+ const update = await this.checkForUpdates({ autoDownload: false });
11227
+ if (update && !this.lastError && (options.autoDownload ?? this.config.autoDownload) && await enabled())
11228
+ await this.downloadUpdate();
11229
+ }
11230
+ } catch (error) {
11231
+ if (active())
11232
+ this.emitError(error);
11233
+ } finally {
11234
+ if (active() && (interval !== 0 || nextCheck !== Infinity))
11235
+ schedule(Math.min(policyInterval, nextCheck > Date.now() ? nextCheck - Date.now() : policyInterval));
11236
+ }
11237
+ };
11238
+ schedule(initialDelay);
11203
11239
  }
11204
11240
  stopAutoCheck() {
11205
- if (this.checkTimer) {
11206
- clearInterval(this.checkTimer);
11207
- this.checkTimer = null;
11241
+ this.autoCheckRunning = false;
11242
+ this.autoCheckGeneration++;
11243
+ if (this.checkTimer)
11244
+ clearTimeout(this.checkTimer);
11245
+ this.checkTimer = null;
11246
+ }
11247
+ checkForUpdates(options = {}) {
11248
+ if (!this.checking && this.downloading)
11249
+ return this.downloading.then(() => this.checkForUpdates(options));
11250
+ if (!this.checking) {
11251
+ this.checking = this.performCheck(options.autoDownload ?? this.config.autoDownload ?? true).finally(() => {
11252
+ this.checking = null;
11253
+ });
11208
11254
  }
11255
+ return this.checking;
11209
11256
  }
11210
- async checkForUpdates() {
11257
+ async performCheck(autoDownload) {
11211
11258
  this.emit("checking-for-update");
11212
11259
  this.lastError = null;
11213
11260
  try {
@@ -11223,7 +11270,7 @@ class AutoUpdater extends import_events3.EventEmitter {
11223
11270
  if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
11224
11271
  this.updateInfo = this.cachedManifest;
11225
11272
  this.emit("update-available", this.cachedManifest);
11226
- if (this.config.autoDownload) {
11273
+ if (autoDownload) {
11227
11274
  await this.downloadUpdate();
11228
11275
  }
11229
11276
  return this.cachedManifest;
@@ -11243,9 +11290,13 @@ class AutoUpdater extends import_events3.EventEmitter {
11243
11290
  if (updateInfo.minVersion && this.isNewerVersion(updateInfo.minVersion, this.config.currentVersion)) {
11244
11291
  console.log(`Update requires minimum version ${updateInfo.minVersion}`);
11245
11292
  }
11293
+ if (this.updateInfo?.version !== updateInfo.version) {
11294
+ this.downloadPath = null;
11295
+ this.verifiedDownloadPath = null;
11296
+ }
11246
11297
  this.updateInfo = updateInfo;
11247
11298
  this.emit("update-available", updateInfo);
11248
- if (this.config.autoDownload) {
11299
+ if (autoDownload) {
11249
11300
  await this.downloadUpdate();
11250
11301
  }
11251
11302
  return updateInfo;
@@ -11258,7 +11309,19 @@ class AutoUpdater extends import_events3.EventEmitter {
11258
11309
  return null;
11259
11310
  }
11260
11311
  }
11261
- async downloadUpdate() {
11312
+ downloadUpdate() {
11313
+ if (this.downloading)
11314
+ return this.downloading;
11315
+ if (this.verifiedDownloadPath && import_fs4.existsSync(this.verifiedDownloadPath))
11316
+ return Promise.resolve(this.verifiedDownloadPath);
11317
+ if (!this.downloading) {
11318
+ this.downloading = this.performDownload().finally(() => {
11319
+ this.downloading = null;
11320
+ });
11321
+ }
11322
+ return this.downloading;
11323
+ }
11324
+ async performDownload() {
11262
11325
  if (!this.updateInfo) {
11263
11326
  throw new Error("No update available");
11264
11327
  }
@@ -11366,6 +11429,7 @@ class AutoUpdater extends import_events3.EventEmitter {
11366
11429
  throw new Error("Download verification failed: invalid signature");
11367
11430
  }
11368
11431
  }
11432
+ this.verifiedDownloadPath = this.downloadPath;
11369
11433
  this.emit("update-downloaded", {
11370
11434
  path: this.downloadPath,
11371
11435
  version: this.updateInfo.version,
package/dist/index.d.cts CHANGED
@@ -36,7 +36,7 @@ export * from './styles/index.js';
36
36
  export * from './optimizations/index.js';
37
37
  export * as updater from './updater/index.js';
38
38
  export { AutoUpdater, generateUpdateManifest } from './updater/index.js';
39
- export type { UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
39
+ export type { AutoCheckOptions, CheckForUpdatesOptions, UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
40
40
  export { canReplaceBundle, clearQuarantine, dittoBundle, extractBundle, extractBundleFromDmg, extractBundleFromZip, readBundleIdentity, swapBundle, verifyBundleTrust, } from './updater/index.js';
41
41
  export type { BundleIdentity, BundleTrustFailure, BundleTrustPolicy, BundleTrustResult, StagedBundle, SwapResult, } from './updater/index.js';
42
42
  export declare class CraftApp {
package/dist/index.d.ts CHANGED
@@ -36,7 +36,7 @@ export * from './styles/index.js';
36
36
  export * from './optimizations/index.js';
37
37
  export * as updater from './updater/index.js';
38
38
  export { AutoUpdater, generateUpdateManifest } from './updater/index.js';
39
- export type { UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
39
+ export type { AutoCheckOptions, CheckForUpdatesOptions, UpdateInfo as UpdateManifest, UpdateProgress, UpdaterConfig, UpdaterEvent, PlatformUpdate, } from './updater/index.js';
40
40
  export { canReplaceBundle, clearQuarantine, dittoBundle, extractBundle, extractBundleFromDmg, extractBundleFromZip, readBundleIdentity, swapBundle, verifyBundleTrust, } from './updater/index.js';
41
41
  export type { BundleIdentity, BundleTrustFailure, BundleTrustPolicy, BundleTrustResult, StagedBundle, SwapResult, } from './updater/index.js';
42
42
  export declare class CraftApp {
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.443";
120
+ var version = "0.0.449";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -10912,7 +10912,12 @@ class AutoUpdater extends EventEmitter3 {
10912
10912
  config;
10913
10913
  updateInfo = null;
10914
10914
  downloadPath = null;
10915
+ verifiedDownloadPath = null;
10915
10916
  checkTimer = null;
10917
+ autoCheckGeneration = 0;
10918
+ autoCheckRunning = false;
10919
+ checking = null;
10920
+ downloading = null;
10916
10921
  cachedEtag = null;
10917
10922
  cachedLastModified = null;
10918
10923
  cachedManifest = null;
@@ -10949,21 +10954,63 @@ class AutoUpdater extends EventEmitter3 {
10949
10954
  ...config
10950
10955
  };
10951
10956
  }
10952
- startAutoCheck() {
10953
- this.checkForUpdates();
10954
- if (this.config.checkInterval) {
10955
- this.checkTimer = setInterval(() => {
10956
- this.checkForUpdates();
10957
- }, this.config.checkInterval);
10958
- }
10957
+ startAutoCheck(options = {}) {
10958
+ if (this.autoCheckRunning)
10959
+ return;
10960
+ const initialDelay = options.initialDelay ?? 0;
10961
+ const policyInterval = options.policyInterval ?? 60000;
10962
+ const interval = this.config.checkInterval ?? 3600000;
10963
+ if (!Number.isFinite(initialDelay) || initialDelay < 0 || !Number.isFinite(policyInterval) || policyInterval <= 0 || !Number.isFinite(interval) || interval < 0)
10964
+ throw new Error("Updater: automatic check intervals must be finite and non-negative, with a positive policyInterval");
10965
+ this.autoCheckRunning = true;
10966
+ const generation = ++this.autoCheckGeneration;
10967
+ const active = () => this.autoCheckRunning && generation === this.autoCheckGeneration;
10968
+ const enabled = async () => active() && (await options.enabled?.() ?? true) && active();
10969
+ let nextCheck = Date.now() + initialDelay;
10970
+ const schedule = (delay) => {
10971
+ if (!active())
10972
+ return;
10973
+ this.checkTimer = setTimeout(() => {
10974
+ tick();
10975
+ }, delay);
10976
+ this.checkTimer.unref?.();
10977
+ };
10978
+ const tick = async () => {
10979
+ try {
10980
+ if (await enabled() && Date.now() >= nextCheck && !(this.verifiedDownloadPath && existsSync5(this.verifiedDownloadPath)) && !this.checking && !this.downloading) {
10981
+ nextCheck = interval === 0 ? Infinity : Date.now() + interval;
10982
+ const update = await this.checkForUpdates({ autoDownload: false });
10983
+ if (update && !this.lastError && (options.autoDownload ?? this.config.autoDownload) && await enabled())
10984
+ await this.downloadUpdate();
10985
+ }
10986
+ } catch (error) {
10987
+ if (active())
10988
+ this.emitError(error);
10989
+ } finally {
10990
+ if (active() && (interval !== 0 || nextCheck !== Infinity))
10991
+ schedule(Math.min(policyInterval, nextCheck > Date.now() ? nextCheck - Date.now() : policyInterval));
10992
+ }
10993
+ };
10994
+ schedule(initialDelay);
10959
10995
  }
10960
10996
  stopAutoCheck() {
10961
- if (this.checkTimer) {
10962
- clearInterval(this.checkTimer);
10963
- this.checkTimer = null;
10997
+ this.autoCheckRunning = false;
10998
+ this.autoCheckGeneration++;
10999
+ if (this.checkTimer)
11000
+ clearTimeout(this.checkTimer);
11001
+ this.checkTimer = null;
11002
+ }
11003
+ checkForUpdates(options = {}) {
11004
+ if (!this.checking && this.downloading)
11005
+ return this.downloading.then(() => this.checkForUpdates(options));
11006
+ if (!this.checking) {
11007
+ this.checking = this.performCheck(options.autoDownload ?? this.config.autoDownload ?? true).finally(() => {
11008
+ this.checking = null;
11009
+ });
10964
11010
  }
11011
+ return this.checking;
10965
11012
  }
10966
- async checkForUpdates() {
11013
+ async performCheck(autoDownload) {
10967
11014
  this.emit("checking-for-update");
10968
11015
  this.lastError = null;
10969
11016
  try {
@@ -10979,7 +11026,7 @@ class AutoUpdater extends EventEmitter3 {
10979
11026
  if (this.isNewerVersion(this.cachedManifest.version, this.config.currentVersion)) {
10980
11027
  this.updateInfo = this.cachedManifest;
10981
11028
  this.emit("update-available", this.cachedManifest);
10982
- if (this.config.autoDownload) {
11029
+ if (autoDownload) {
10983
11030
  await this.downloadUpdate();
10984
11031
  }
10985
11032
  return this.cachedManifest;
@@ -10999,9 +11046,13 @@ class AutoUpdater extends EventEmitter3 {
10999
11046
  if (updateInfo.minVersion && this.isNewerVersion(updateInfo.minVersion, this.config.currentVersion)) {
11000
11047
  console.log(`Update requires minimum version ${updateInfo.minVersion}`);
11001
11048
  }
11049
+ if (this.updateInfo?.version !== updateInfo.version) {
11050
+ this.downloadPath = null;
11051
+ this.verifiedDownloadPath = null;
11052
+ }
11002
11053
  this.updateInfo = updateInfo;
11003
11054
  this.emit("update-available", updateInfo);
11004
- if (this.config.autoDownload) {
11055
+ if (autoDownload) {
11005
11056
  await this.downloadUpdate();
11006
11057
  }
11007
11058
  return updateInfo;
@@ -11014,7 +11065,19 @@ class AutoUpdater extends EventEmitter3 {
11014
11065
  return null;
11015
11066
  }
11016
11067
  }
11017
- async downloadUpdate() {
11068
+ downloadUpdate() {
11069
+ if (this.downloading)
11070
+ return this.downloading;
11071
+ if (this.verifiedDownloadPath && existsSync5(this.verifiedDownloadPath))
11072
+ return Promise.resolve(this.verifiedDownloadPath);
11073
+ if (!this.downloading) {
11074
+ this.downloading = this.performDownload().finally(() => {
11075
+ this.downloading = null;
11076
+ });
11077
+ }
11078
+ return this.downloading;
11079
+ }
11080
+ async performDownload() {
11018
11081
  if (!this.updateInfo) {
11019
11082
  throw new Error("No update available");
11020
11083
  }
@@ -11122,6 +11185,7 @@ class AutoUpdater extends EventEmitter3 {
11122
11185
  throw new Error("Download verification failed: invalid signature");
11123
11186
  }
11124
11187
  }
11188
+ this.verifiedDownloadPath = this.downloadPath;
11125
11189
  this.emit("update-downloaded", {
11126
11190
  path: this.downloadPath,
11127
11191
  version: this.updateInfo.version,
@@ -5524,6 +5524,22 @@ final class CraftNativeSolidSwitch: UIControl {
5524
5524
  private let naturalSize = UISwitch().intrinsicContentSize
5525
5525
  private var touchOrigin: CGPoint?
5526
5526
  private var dragPosition: CGFloat?
5527
+ /// Where the thumb is *drawn*, which is where it rests except while a
5528
+ /// change travels there. `thumbRect` keeps reporting the resting position,
5529
+ /// so hit-testing, the drag maths and tests all read one instant,
5530
+ /// authoritative value and only the drawing lags behind it.
5531
+ private var drawnPosition: CGFloat = 0
5532
+ private var travel: CADisplayLink?
5533
+ private var travelFrom: CGFloat = 0
5534
+ private var travelStart: CFTimeInterval = 0
5535
+ /// Drawing can settle independently of immediate value changes and taps.
5536
+ private static let travelDuration: CFTimeInterval = 0.38
5537
+ /// Thumb stretch while held, from 0 (released) to 1 (pressed).
5538
+ private var pressAmount: CGFloat = 0
5539
+ private var pressTarget: CGFloat = 0
5540
+ private var pressFrom: CGFloat = 0
5541
+ private var pressStart: CFTimeInterval = 0
5542
+ private static let pressDuration: CFTimeInterval = 0.14
5527
5543
  override var intrinsicContentSize: CGSize { naturalSize }
5528
5544
  override var isEnabled: Bool {
5529
5545
  didSet {
@@ -5533,6 +5549,14 @@ final class CraftNativeSolidSwitch: UIControl {
5533
5549
  }
5534
5550
  }
5535
5551
  override var canBecomeFirstResponder: Bool { isEnabled }
5552
+ override var isHidden: Bool {
5553
+ didSet { if isHidden { settleMotion(cancelTracking: true) } }
5554
+ }
5555
+ override var semanticContentAttribute: UISemanticContentAttribute {
5556
+ didSet {
5557
+ if oldValue != semanticContentAttribute { settleMotion(cancelTracking: true) }
5558
+ }
5559
+ }
5536
5560
 
5537
5561
  override init(frame: CGRect) {
5538
5562
  super.init(frame: frame)
@@ -5544,11 +5568,111 @@ final class CraftNativeSolidSwitch: UIControl {
5544
5568
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
5545
5569
 
5546
5570
  func setOn(_ value: Bool) {
5571
+ let previous = visualPosition
5572
+ let changed = isOn != value
5547
5573
  isOn = value
5548
5574
  refreshAccessibility()
5575
+ guard changed else { setNeedsDisplay(); return }
5576
+ drawnPosition = previous
5577
+ moveThumb()
5578
+ }
5579
+
5580
+ private var canAnimate: Bool {
5581
+ guard isEnabled, !UIAccessibility.isReduceMotionEnabled, let window,
5582
+ convert(bounds, to: window).intersects(window.bounds) else { return false }
5583
+ var ancestor: UIView? = self
5584
+ while let view = ancestor {
5585
+ if view.isHidden || view.alpha <= 0 { return false }
5586
+ ancestor = view.superview
5587
+ }
5588
+ return true
5589
+ }
5590
+
5591
+ private func settleMotion(cancelTracking: Bool = false) {
5592
+ travel?.invalidate()
5593
+ travel = nil
5594
+ drawnPosition = restingPosition
5595
+ pressAmount = 0
5596
+ pressTarget = 0
5597
+ if cancelTracking {
5598
+ touchOrigin = nil
5599
+ dragPosition = nil
5600
+ }
5601
+ setNeedsDisplay()
5602
+ }
5603
+
5604
+ /// Only drawing travels; value, accessibility and hit geometry change at once.
5605
+ private func moveThumb() {
5606
+ travel?.invalidate()
5607
+ travel = nil
5608
+ let target = restingPosition
5609
+ travelFrom = dragPosition ?? drawnPosition
5610
+ guard canAnimate else { settleMotion(); return }
5611
+ travelStart = CACurrentMediaTime()
5612
+ guard travelFrom != target || pressAmount != pressTarget else {
5613
+ drawnPosition = target
5614
+ setNeedsDisplay()
5615
+ return
5616
+ }
5617
+ startLink()
5618
+ }
5619
+
5620
+ /// Stretch the thumb toward where it is going, or let it relax back.
5621
+ private func press(_ amount: CGFloat) {
5622
+ guard pressTarget != amount else { return }
5623
+ pressFrom = pressAmount
5624
+ pressTarget = amount
5625
+ pressStart = CACurrentMediaTime()
5626
+ guard canAnimate else { settleMotion(); return }
5627
+ if travel == nil {
5628
+ drawnPosition = restingPosition
5629
+ travelFrom = restingPosition
5630
+ travelStart = CACurrentMediaTime()
5631
+ }
5632
+ startLink()
5633
+ }
5634
+
5635
+ private func startLink() {
5636
+ guard travel == nil else { return }
5637
+ let link = CADisplayLink(target: self, selector: #selector(stepThumb))
5638
+ link.add(to: .main, forMode: .common)
5639
+ travel = link
5640
+ }
5641
+
5642
+ @objc private func stepThumb() {
5643
+ guard canAnimate else { settleMotion(); return }
5644
+ let now = CACurrentMediaTime()
5645
+ let target = restingPosition
5646
+ let position = min(1, max(0, (now - travelStart) / Self.travelDuration))
5647
+ drawnPosition = travelFrom + (target - travelFrom) * CGFloat(Self.spring(position))
5648
+ let pressed = min(1, max(0, (now - pressStart) / Self.pressDuration))
5649
+ pressAmount = pressFrom + (pressTarget - pressFrom) * CGFloat(pressed * pressed * (3 - 2 * pressed))
5549
5650
  setNeedsDisplay()
5651
+ guard position >= 1, pressed >= 1 else { return }
5652
+ drawnPosition = target
5653
+ pressAmount = pressTarget
5654
+ travel?.invalidate()
5655
+ travel = nil
5550
5656
  }
5551
5657
 
5658
+ /// A small spring overshoot, applied only to opaque drawing geometry.
5659
+ private static func spring(_ fraction: Double) -> Double {
5660
+ guard fraction < 1 else { return 1 }
5661
+ let damping = 0.68, frequency = 15.0
5662
+ let damped = frequency * (1 - damping * damping).squareRoot()
5663
+ let decay = exp(-damping * frequency * fraction)
5664
+ return 1 - decay * (cos(damped * fraction) + (damping * frequency / damped) * sin(damped * fraction))
5665
+ }
5666
+
5667
+ /// A display link retains this control, so a switch that leaves the screen
5668
+ /// mid-travel would hold itself alive and keep drawing frames nobody sees.
5669
+ override func willMove(toWindow newWindow: UIWindow?) {
5670
+ super.willMove(toWindow: newWindow)
5671
+ guard newWindow == nil else { return }
5672
+ settleMotion(cancelTracking: true)
5673
+ }
5674
+ deinit { travel?.invalidate() }
5675
+
5552
5676
  func refreshAccessibility() {
5553
5677
  accessibilityTraits = .button
5554
5678
  if #available(iOS 17.0, *) { accessibilityTraits.insert(.toggleButton) }
@@ -5561,21 +5685,99 @@ final class CraftNativeSolidSwitch: UIControl {
5561
5685
  let size = CGSize(width: min(bounds.width, naturalSize.width), height: min(bounds.height, naturalSize.height))
5562
5686
  return CGRect(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height)
5563
5687
  }
5564
- var thumbRect: CGRect {
5688
+ private var restingPosition: CGFloat {
5689
+ (isOn == (effectiveUserInterfaceLayoutDirection != .rightToLeft)) ? 1 : 0
5690
+ }
5691
+ private var visualPosition: CGFloat {
5692
+ dragPosition ?? (travel == nil ? restingPosition : drawnPosition)
5693
+ }
5694
+ /// Where the thumb belongs now. A finger on it wins, then the state.
5695
+ var thumbRect: CGRect { thumbRect(at: dragPosition ?? restingPosition) }
5696
+ /// Where this frame draws it: the same, except part-way through a travel,
5697
+ /// and stretched toward its destination while a finger holds it down.
5698
+ private var drawnThumbRect: CGRect {
5699
+ let position = visualPosition
5700
+ let rect = thumbRect(at: position)
5701
+ guard pressAmount > 0 else { return rect }
5702
+ let track = trackRect.insetBy(dx: 2, dy: 2)
5703
+ let stretch = rect.width * 0.18 * pressAmount
5704
+ // It grows toward the end it is heading for, so the edge nearest the
5705
+ // finger stays put and the far edge reaches.
5706
+ let x = position < 0.5 ? rect.minX : rect.minX - stretch
5707
+ return CGRect(x: min(max(x, track.minX), track.maxX - rect.width - stretch),
5708
+ y: rect.minY, width: rect.width + stretch, height: rect.height)
5709
+ }
5710
+ private func thumbRect(at position: CGFloat) -> CGRect {
5565
5711
  let track = trackRect.insetBy(dx: 2, dy: 2)
5566
- let onRight = effectiveUserInterfaceLayoutDirection != .rightToLeft
5567
- let position = dragPosition ?? ((isOn == onRight) ? 1 : 0)
5568
5712
  return CGRect(x: track.minX + (track.width - track.height) * position, y: track.minY, width: track.height, height: track.height)
5569
5713
  }
5570
5714
  override func draw(_ rect: CGRect) {
5571
5715
  let track = trackRect
5572
- guard track.width > 4, track.height > 4 else { return }
5573
- (isEnabled ? (isOn ? onColor ?? .systemGreen : offColor ?? .systemGray4) : UIColor.systemGray4).setFill()
5574
- UIBezierPath(roundedRect: track, cornerRadius: track.height / 2).fill()
5716
+ guard track.width > 4, track.height > 4, let context = UIGraphicsGetCurrentContext() else { return }
5717
+ let position = visualPosition
5718
+ // The track's colour arrives *with* the thumb. Filling it from `isOn`
5719
+ // turns the whole track the instant the value changes, so the colour
5720
+ // overtakes the thumb and the two read as two separate things moving.
5721
+ let dim = isEnabled ? (offColor ?? .systemGray4) : UIColor.systemGray4
5722
+ let lit = isEnabled ? (onColor ?? .systemGreen) : UIColor.systemGray4
5723
+ let onness = effectiveUserInterfaceLayoutDirection != .rightToLeft ? position : 1 - position
5724
+ Self.blend(dim, lit, onness).setFill()
5725
+ let trackPath = UIBezierPath(roundedRect: track, cornerRadius: track.height / 2)
5726
+ trackPath.fill()
5727
+ // The well the thumb sits in: shaded under its own top rim.
5728
+ context.saveGState()
5729
+ trackPath.addClip()
5730
+ if let shade = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
5731
+ colors: [UIColor.black.withAlphaComponent(0.10).cgColor, UIColor.clear.cgColor] as CFArray,
5732
+ locations: [0, 1]) {
5733
+ context.drawLinearGradient(shade, start: CGPoint(x: track.midX, y: track.minY),
5734
+ end: CGPoint(x: track.midX, y: track.minY + track.height * 0.55), options: [])
5735
+ }
5736
+ context.restoreGState()
5737
+
5738
+ let thumb = drawnThumbRect
5739
+ let thumbPath = UIBezierPath(roundedRect: thumb, cornerRadius: thumb.height / 2)
5740
+ // Draw the shadow in the same frame, without layer animations or fades.
5741
+ context.saveGState()
5742
+ context.setShadow(offset: CGSize(width: 0, height: 1.5), blur: 3,
5743
+ color: UIColor.black.withAlphaComponent(isEnabled ? 0.28 : 0.16).cgColor)
5575
5744
  // The solid variant deliberately ignores thumb alpha. No transient
5576
5745
  // glass, scaling, fade, implicit layer animation or delayed redraw.
5577
5746
  (thumbColor ?? .white).withAlphaComponent(1).setFill()
5578
- UIBezierPath(ovalIn: thumbRect).fill()
5747
+ thumbPath.fill()
5748
+ context.restoreGState()
5749
+ // Shading stays above the opaque fill and away from its centre.
5750
+ context.saveGState()
5751
+ thumbPath.addClip()
5752
+ if let gloss = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
5753
+ colors: [UIColor.white.withAlphaComponent(0.55).cgColor, UIColor.clear.cgColor] as CFArray,
5754
+ locations: [0, 1]) {
5755
+ context.drawLinearGradient(gloss, start: CGPoint(x: thumb.midX, y: thumb.minY),
5756
+ end: CGPoint(x: thumb.midX, y: thumb.minY + thumb.height * 0.40), options: [])
5757
+ }
5758
+ if let sheen = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
5759
+ colors: [UIColor.clear.cgColor, UIColor.black.withAlphaComponent(0.07).cgColor] as CFArray,
5760
+ locations: [0, 1]) {
5761
+ context.drawLinearGradient(sheen, start: CGPoint(x: thumb.midX, y: thumb.maxY - thumb.height * 0.20),
5762
+ end: CGPoint(x: thumb.midX, y: thumb.maxY), options: [])
5763
+ }
5764
+ context.restoreGState()
5765
+ // A hairline rim, so it still separates from a pale track.
5766
+ UIColor.black.withAlphaComponent(0.06).setStroke()
5767
+ thumbPath.lineWidth = 0.5
5768
+ thumbPath.stroke()
5769
+ }
5770
+
5771
+ /// Mix two colours, for a track that changes with the thumb rather than
5772
+ /// ahead of it.
5773
+ private static func blend(_ from: UIColor, _ to: UIColor, _ amount: CGFloat) -> UIColor {
5774
+ let mix = min(1, max(0, amount))
5775
+ var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0
5776
+ var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0
5777
+ guard from.getRed(&fr, green: &fg, blue: &fb, alpha: &fa),
5778
+ to.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) else { return mix < 0.5 ? from : to }
5779
+ return UIColor(red: fr + (tr - fr) * mix, green: fg + (tg - fg) * mix,
5780
+ blue: fb + (tb - fb) * mix, alpha: fa + (ta - fa) * mix)
5579
5781
  }
5580
5782
  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
5581
5783
  super.traitCollectionDidChange(previousTraitCollection)
@@ -5585,6 +5787,7 @@ final class CraftNativeSolidSwitch: UIControl {
5585
5787
  guard isEnabled else { return false }
5586
5788
  touchOrigin = touch.location(in: self)
5587
5789
  dragPosition = nil
5790
+ press(1)
5588
5791
  return true
5589
5792
  }
5590
5793
  func updateDrag(to point: CGPoint) {
@@ -5599,7 +5802,15 @@ final class CraftNativeSolidSwitch: UIControl {
5599
5802
  return true
5600
5803
  }
5601
5804
  override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
5602
- defer { touchOrigin = nil; dragPosition = nil; setNeedsDisplay() }
5805
+ // The thumb carries on from wherever the finger left it, rather than
5806
+ // jumping to the end of the track the moment it lifts.
5807
+ defer {
5808
+ touchOrigin = nil
5809
+ if let released = dragPosition { drawnPosition = released }
5810
+ dragPosition = nil
5811
+ press(0)
5812
+ moveThumb()
5813
+ }
5603
5814
  guard isEnabled, let touch, touchOrigin != nil,
5604
5815
  bounds.insetBy(dx: -10, dy: -10).contains(touch.location(in: self)) else { return }
5605
5816
  let next = dragPosition.map { ($0 >= 0.5) == (effectiveUserInterfaceLayoutDirection != .rightToLeft) } ?? !isOn
@@ -5607,8 +5818,10 @@ final class CraftNativeSolidSwitch: UIControl {
5607
5818
  }
5608
5819
  override func cancelTracking(with event: UIEvent?) {
5609
5820
  touchOrigin = nil
5821
+ if let released = dragPosition { drawnPosition = released }
5610
5822
  dragPosition = nil
5611
- setNeedsDisplay()
5823
+ press(0)
5824
+ moveThumb()
5612
5825
  }
5613
5826
  override func accessibilityActivate() -> Bool {
5614
5827
  guard isEnabled else { return false }
@@ -193,10 +193,15 @@ final class DriverTests: XCTestCase {
193
193
  visibleScrollFrame(target.frame, in: app.frame) else { return false }
194
194
  // Hittable alone accepts controls clipped by the scroll view's edge.
195
195
  // Only an ancestor scroll view clips this target, not its siblings.
196
+ // One that does not overlap the target is not its ancestor, whatever
197
+ // the descendants query says: with a keyboard up, the query matched
198
+ // a form's Send button inside the keyboard's suggestion strip, and
199
+ // Send, in full view above the keys, could never be scrolled to.
196
200
  let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
197
- for scroll in app.scrollViews.allElementsBoundByIndex {
201
+ let frame = target.frame
202
+ for scroll in app.scrollViews.allElementsBoundByIndex where scroll.frame.intersects(frame) {
198
203
  if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
199
- !visibleScrollFrame(target.frame, in: scroll.frame.intersection(app.frame)) { return false }
204
+ !visibleScrollFrame(frame, in: scroll.frame.intersection(app.frame)) { return false }
200
205
  }
201
206
  return true
202
207
  }
@@ -287,6 +292,12 @@ final class DriverTests: XCTestCase {
287
292
 
288
293
  /// A switch reports "0" or "1". Any other value is not a switch state to
289
294
  /// wait on, so the tap stands as it would for any other control.
295
+ /// The switch answers `/exec` with 200 whatever the hook did, its first
296
+ /// word the hook's exit code: only `exit 0` is a hook that worked.
297
+ func hookSucceeded(_ answer: String) -> Bool {
298
+ answer.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("exit 0")
299
+ }
300
+
290
301
  func switchFlipped(from before: String?, to after: String?) -> Bool {
291
302
  let states: Set<String> = ["0", "1"]
292
303
  guard let before, let after, states.contains(before), states.contains(after) else { return true }
@@ -390,7 +401,12 @@ final class DriverTests: XCTestCase {
390
401
  check(false, "exec \(arg): hook failed or returned no successful response")
391
402
  return
392
403
  }
393
- note("exec \(arg): \(out.trimmingCharacters(in: .whitespacesAndNewlines))")
404
+ let answer = out.trimmingCharacters(in: .whitespacesAndNewlines)
405
+ note("exec \(arg): \(answer)")
406
+ // A failed hook (a fresh install that never reinstalled) stops the
407
+ // journey here with its reason, not three steps later at a screen
408
+ // that was never reset.
409
+ check(hookSucceeded(answer), "exec \(arg): hook failed: \(answer)")
394
410
  case "openurl":
395
411
  if let url = URL(string: arg) {
396
412
  XCUIDevice.shared.system.open(url)
@@ -43,9 +43,9 @@ import process from "process";
43
43
  var macosToolchain = {
44
44
  zig: "0.18.0-dev.252+86dc5ce25",
45
45
  sources: {
46
- "zig-js": { repository: "zig-utils/zig-js", revision: "95d835edcbad55a7456cff76a9bb7ef6cdf265fc", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
46
+ "zig-js": { repository: "zig-utils/zig-js", revision: "58885b7dad974d15b4eb410dc617da64d8c29550", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
47
47
  "zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
48
- "zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
48
+ "zig-gc": { repository: "zig-utils/zig-gc", revision: "9bdb139cb4c6cdaa37b6fa2abebcf549cfe43762", paths: ["src"] }
49
49
  }
50
50
  };
51
51
  var defaultRun = async (args, cwd) => {
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
9
9
  readonly sources: {
10
10
  readonly 'zig-js': {
11
11
  readonly repository: 'zig-utils/zig-js';
12
- readonly revision: '95d835edcbad55a7456cff76a9bb7ef6cdf265fc';
12
+ readonly revision: '58885b7dad974d15b4eb410dc617da64d8c29550';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {
@@ -19,7 +19,7 @@ export declare const macosToolchain: {
19
19
  };
20
20
  readonly 'zig-gc': {
21
21
  readonly repository: 'zig-utils/zig-gc';
22
- readonly revision: '73c802e05768a729614e9c4296abb118aa06faf9';
22
+ readonly revision: '9bdb139cb4c6cdaa37b6fa2abebcf549cfe43762';
23
23
  readonly paths: readonly ['src'];
24
24
  };
25
25
  };
@@ -304,17 +304,25 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
304
304
  private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
305
305
  private var measured: [MeasureKey: CGSize] = [:]
306
306
  /// What this box measured is stale: it or something inside it changed (CraftMacRenderer.update).
307
- func forgetMeasurements() { measured.removeAll(keepingCapacity: true); controlSize = nil }
307
+ func forgetMeasurements() { measured.removeAll(keepingCapacity: true); controlSize = nil; mergedLayoutStyle = nil }
308
308
  private var measuredGeneration = -1
309
309
  /// A control's own size (a button, a switch, a picker), asked once rather than at every width the box is
310
310
  /// measured at: a button answers through SwiftUI's layout on current macOS. Forgotten with the measurements.
311
311
  private var controlSize: CGSize?
312
312
  var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
313
+ static let componentLayoutKeys = ["width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight", "flex", "flexGrow", "flexShrink", "flexBasis", "alignSelf", "position", "zIndex", "left", "right", "top", "bottom", "margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginHorizontal", "marginVertical"]
314
+ /// A component wrapper's style with its root's layout keys merged in, kept as its measurements are (it
315
+ /// depends on the same things): a flex pass asks for each child's several times, and a wrapper's merge
316
+ /// asked its root for its own once per key, so nested wrappers rebuilt their styles many times over.
317
+ private var mergedLayoutStyle: [String: Any]?
318
+ private var mergedGeneration = -1
313
319
  var layoutStyle: [String: Any] {
314
320
  guard let child = componentRoot else { return style }
315
- let keys = ["width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight", "flex", "flexGrow", "flexShrink", "flexBasis", "alignSelf", "position", "zIndex", "left", "right", "top", "bottom", "margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginHorizontal", "marginVertical"]
321
+ if mergedGeneration == CraftMacBox.measureGeneration, let merged = mergedLayoutStyle { return merged }
322
+ let inner = child.layoutStyle
316
323
  var result = style
317
- for key in keys where result[key] == nil { result[key] = child.layoutStyle[key] }
324
+ for key in Self.componentLayoutKeys where result[key] == nil { result[key] = inner[key] }
325
+ mergedLayoutStyle = result; mergedGeneration = CraftMacBox.measureGeneration
318
326
  return result
319
327
  }
320
328
  override var isFlipped: Bool { true }
@@ -371,7 +379,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
371
379
  required init?(coder: NSCoder) { fatalError("Native controls are created from STX") }
372
380
 
373
381
  func configure(_ node: [String: Any]) {
374
- controlSize = nil
382
+ controlSize = nil; mergedLayoutStyle = nil
375
383
  props = node["props"] as? [String: Any] ?? [:]
376
384
  flow?.componentScope = props["data-stx-component-wrapper"] != nil
377
385
  style = node["style"] as? [String: Any] ?? [:]
@@ -775,11 +783,16 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
775
783
  let rect = (text.stringValue as NSString).boundingRect(with: CGSize(width: max(1, limit - 6 - textPadding.left - textPadding.right), height: 1_000_000), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: text.font ?? NSFont.systemFont(ofSize: 13)])
776
784
  size = CGSize(width: min(limit, ceil(rect.width) + 9 + textPadding.left + textPadding.right), height: max(18, ceil(rect.height) + 2))
777
785
  // The field draws only whole lines inside its cell's insets, so a height short of what the cell
778
- // asks for at the width it will draw at drops the last line. Ask the cell, not just the string.
779
- let fieldWidth = max(1, size.width - textPadding.left - textPadding.right)
780
- if let drawn = text.cell?.cellSize(forBounds: CGRect(x: 0, y: 0, width: fieldWidth, height: 1_000_000)).height { size.height = max(size.height, ceil(drawn)) }
781
- if text.maximumNumberOfLines > 0 { size.height = min(size.height, ceil((text.font?.ascender ?? 13) - (text.font?.descender ?? -3) + (text.font?.leading ?? 0)) * CGFloat(text.maximumNumberOfLines) + 2) }
782
- if text.maximumNumberOfLines == 1 { size.height = max(18, (text.font?.pointSize ?? 13) + 5) }
786
+ // asks for at the width it will draw at drops the last line. Ask the cell, not just the string,
787
+ // unless the line limit decides the height anyway: one line, or a cap the string already reaches.
788
+ let lines = text.maximumNumberOfLines
789
+ let cap = lines > 0 ? ceil((text.font?.ascender ?? 13) - (text.font?.descender ?? -3) + (text.font?.leading ?? 0)) * CGFloat(lines) + 2 : .infinity
790
+ if lines != 1, size.height < cap {
791
+ let fieldWidth = max(1, size.width - textPadding.left - textPadding.right)
792
+ if let drawn = text.cell?.cellSize(forBounds: CGRect(x: 0, y: 0, width: fieldWidth, height: 1_000_000)).height { size.height = max(size.height, ceil(drawn)) }
793
+ }
794
+ if lines > 0 { size.height = min(size.height, cap) }
795
+ if lines == 1 { size.height = max(18, (text.font?.pointSize ?? 13) + 5) }
783
796
  size.height += textPadding.top + textPadding.bottom
784
797
  } else if kind == "ScrollView" || kind == "FlatList" || kind == "Sidebar" { size = CGSize(width: limit, height: 100) }
785
798
  else if kind == "WebView" { size = CGSize(width: limit, height: 300) }
@@ -15,6 +15,8 @@ enum CraftTrace {
15
15
  return Double(info.kp_proc.p_starttime.tv_sec) + Double(info.kp_proc.p_starttime.tv_usec) / 1_000_000
16
16
  }()
17
17
  private static func since() -> Double { (Date().timeIntervalSince1970 - processStart) * 1000 }
18
+ /// Milliseconds on a monotonic clock, for timing a span from either side of the bridge.
19
+ static func now() -> Double { Double(clock_gettime_nsec_np(CLOCK_UPTIME_RAW)) / 1_000_000 }
18
20
  static func mark(_ phase: @autoclosure () -> String) {
19
21
  guard enabled else { return }
20
22
  fputs(String(format: "[craft trace] %8.1f %@\n", since(), phase()), stderr)
@@ -135,10 +137,12 @@ final class CraftMacRuntime {
135
137
  let route: String
136
138
  let network: CraftMacNetwork
137
139
  private let thread = CraftMacScriptThread()
140
+ /// Decodes what the screen posts (`receive`), in the order it was posted, and keeps its network tasks.
141
+ private let decoder = DispatchQueue(label: "craft.script.decode", qos: .userInteractive)
142
+ private var tasks: [String: URLSessionTask] = [:]
138
143
  // Script thread only: the context and what the screen asked of it.
139
144
  private var context: ZigJSContext?
140
145
  private var timers: [Int: CFRunLoopTimer] = [:]
141
- private var tasks: [String: URLSessionTask] = [:]
142
146
  private var stopped = false
143
147
  // Main thread only.
144
148
  private(set) weak var scene: CraftMacScene?
@@ -169,13 +173,25 @@ final class CraftMacRuntime {
169
173
  defer { JSGlobalContextRelease(created) }
170
174
  let context = ZigJSContext(jsGlobalContextRef: created)!
171
175
  self.context = context
176
+ // The bundle's parsed tree is kept between launches, in the app's own caches (entries are trusted,
177
+ // so never a shared folder): a screen's next start skips parsing its bundle.
178
+ if let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
179
+ let directory = caches.appendingPathComponent(Bundle.main.bundleIdentifier ?? "dev.craft.native", isDirectory: true).appendingPathComponent("zig-js-scripts", isDirectory: true)
180
+ _ = ZJSContextSetScriptCacheDirectory(created, directory.path)
181
+ }
172
182
  context.exceptionHandler = { [weak self] _, value in
173
183
  let message = value?.objectForKeyedSubscript("message")?.toString() ?? "Native script failed"
174
184
  let stack = value?.objectForKeyedSubscript("stack")?.toString() ?? ""
175
185
  self?.report(message)
176
186
  fputs("[craft native] \(message)\n\(stack)\n", stderr)
177
187
  }
178
- let post: @convention(block) (String) -> Void = { [weak self] text in self?.receive(text) }
188
+ // An NSString: bridging the text to a Swift String would copy it again on the screen's thread.
189
+ let post: @convention(block) (NSString) -> Void = { [weak self] text in self?.receive(text) }
190
+ // Under CRAFT_TRACE the screen's side of each post is timed too (`post`, below), from JavaScript.
191
+ let tracePost: @convention(block) (Double, Double) -> Void = { bytes, start in
192
+ CraftTrace.mark(String(format: " post %.0f bytes (%.2f ms on the screen's thread)", bytes, CraftTrace.now() - start))
193
+ }
194
+ let now: @convention(block) () -> Double = { CraftTrace.now() }
179
195
  let schedule: @convention(block) (Int, Double) -> Void = { [weak self] id, delay in
180
196
  guard let self, !self.stopped else { return }
181
197
  self.timers[id] = self.thread.after(max(0, min(delay.isFinite ? delay : 0, 2_147_483_647)) / 1000) { [weak self] in
@@ -205,6 +221,10 @@ final class CraftMacRuntime {
205
221
  context.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
206
222
  context.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
207
223
  context.setObject(post, forKeyedSubscript: "craftNativePostMessage" as NSString)
224
+ if CraftTrace.enabled {
225
+ context.setObject(tracePost, forKeyedSubscript: "craftNativeTracePost" as NSString)
226
+ context.setObject(now, forKeyedSubscript: "craftNativeNow" as NSString)
227
+ }
208
228
  context.setObject(schedule, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
209
229
  context.setObject(cancel, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
210
230
  context.setObject(log, forKeyedSubscript: "craftNativeLog" as NSString)
@@ -222,7 +242,9 @@ final class CraftMacRuntime {
222
242
  context.evaluateScript("""
223
243
  globalThis.__stxNativeCallback = null;
224
244
  globalThis.__stxNativeBridge = \(bridgeJSON);
225
- __stxNativeBridge.postMessage = function(text) { craftNativePostMessage(text); };
245
+ __stxNativeBridge.postMessage = typeof craftNativeTracePost === 'function'
246
+ ? function(text) { var start = craftNativeNow(); craftNativePostMessage(text); craftNativeTracePost(text.length, start); }
247
+ : function(text) { craftNativePostMessage(text); };
226
248
  __stxNativeBridge.onMessage = function(callback) { globalThis.__stxNativeCallback = callback; };
227
249
  globalThis.craft = globalThis.craft || {};
228
250
  craft.storage = {
@@ -322,12 +344,12 @@ final class CraftMacRuntime {
322
344
  CraftMacWebTabs.shared.closeAll(owner: self)
323
345
  #endif
324
346
  network.cancelStreams(owner: self)
347
+ decoder.async { self.tasks.values.forEach { $0.cancel() }; self.tasks.removeAll() }
325
348
  let done = DispatchSemaphore(value: 0)
326
349
  thread.run {
327
350
  self.context?.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
328
351
  self.stopped = true
329
352
  self.timers.values.forEach(CFRunLoopTimerInvalidate); self.timers.removeAll()
330
- self.tasks.values.forEach { $0.cancel() }; self.tasks.removeAll()
331
353
  // Released on the thread that made it.
332
354
  self.context = nil
333
355
  done.signal()
@@ -355,10 +377,19 @@ final class CraftMacRuntime {
355
377
  send("EVENT", ["handlerId": id, "nativeEvent": payload])
356
378
  }
357
379
 
358
- /// A message from the screen, on its thread. Network requests start here, so a screen's first fetch
359
- /// leaves while AppKit is still launching; whatever draws or needs AppKit goes to the main thread.
360
- private func receive(_ text: String) {
361
- guard let data = text.data(using: .utf8), let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
380
+ /// A message from the screen, on its thread: handed to a queue of its own to decode, so the screen's
381
+ /// post returns at once. Decoding a batch of a few hundred operations took the screen's thread several
382
+ /// milliseconds per post, inside the screen's render.
383
+ private func receive(_ text: NSString) {
384
+ decoder.async { [weak self] in self?.decode(text) }
385
+ }
386
+
387
+ /// Decodes the screen's messages in order. Network requests start and storage answers here; whatever
388
+ /// draws or needs AppKit goes to the main thread.
389
+ private func decode(_ text: NSString) {
390
+ guard let message = CraftTrace.span(" decode \(text.length) chars", atLeast: 1, {
391
+ text.data(using: String.Encoding.utf8.rawValue).flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] }
392
+ }) else { return }
362
393
  let payload = message["payload"] as? [String: Any] ?? [:]
363
394
  switch message["type"] as? String {
364
395
  case "RENDER", "MUTATE": DispatchQueue.main.async { self.whenAttached { self.handle(message) } }
@@ -414,15 +445,16 @@ final class CraftMacRuntime {
414
445
 
415
446
  /// An answer to the screen's request `id`, from any thread.
416
447
  func answer(_ id: String, _ value: Any = NSNull()) {
417
- thread.run { self.tasks.removeValue(forKey: id) }
448
+ decoder.async { self.tasks.removeValue(forKey: id) }
418
449
  send("API_RESPONSE", ["version": 1, "requestId": id, "data": value], correlation: id)
419
450
  }
420
451
  func fail(_ id: String, _ code: String, _ message: String) {
421
- thread.run { self.tasks.removeValue(forKey: id) }
452
+ decoder.async { self.tasks.removeValue(forKey: id) }
422
453
  send("API_ERROR", ["version": 1, "requestId": id, "code": code, "message": message], correlation: id)
423
454
  }
424
455
 
425
- /// `Network` requests, on the script thread. Their answers come back on the network's own queue.
456
+ /// `Network` requests, on the decoding queue, which keeps their tasks: a request leaves as soon as it is
457
+ /// posted, even while the screen goes on rendering. Their answers come back on the network's own queue.
426
458
  private func fetch(_ id: String, _ method: String, _ args: [Any]) {
427
459
  let options = args.first as? [String: Any] ?? [:]
428
460
  if method == "cancelStream" { network.cancelStream(args.first as? String ?? "", owner: self); answer(id); return }
@@ -442,7 +474,7 @@ final class CraftMacRuntime {
442
474
  tasks[id] = task; network.resume(task)
443
475
  }
444
476
 
445
- /// `Storage` and `SecureStorage`, on the script thread: UserDefaults and the Keychain answer on any.
477
+ /// `Storage` and `SecureStorage`, on the decoding queue: UserDefaults and the Keychain answer on any thread.
446
478
  private func store(_ id: String, _ module: String, _ method: String, _ args: [Any]) {
447
479
  switch module {
448
480
  case "SecureStorage":
@@ -87,6 +87,19 @@ export interface UpdaterConfig {
87
87
  */
88
88
  relaunch?: (appPath: string) => void | Promise<void>;
89
89
  }
90
+ /** Background policy; the host keeps preferences in its own native model. */
91
+ export interface AutoCheckOptions {
92
+ enabled?: () => boolean | Promise<boolean>;
93
+ /** Delay the first check without delaying manual checks. Defaults to zero. */
94
+ initialDelay?: number;
95
+ /** How often to re-read enabled while a check is not due. Defaults to a minute. */
96
+ policyInterval?: number;
97
+ /** Download in the background, independently of manual check behavior. */
98
+ autoDownload?: boolean;
99
+ }
100
+ export interface CheckForUpdatesOptions {
101
+ autoDownload?: boolean;
102
+ }
90
103
  export interface UpdateProgress {
91
104
  phase: 'checking' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error';
92
105
  percent: number;
@@ -99,7 +112,12 @@ export declare class AutoUpdater extends EventEmitter {
99
112
  private config;
100
113
  private updateInfo;
101
114
  private downloadPath;
115
+ private verifiedDownloadPath;
102
116
  private checkTimer;
117
+ private autoCheckGeneration;
118
+ private autoCheckRunning;
119
+ private checking;
120
+ private downloading;
103
121
  private cachedEtag;
104
122
  private cachedLastModified;
105
123
  private cachedManifest;
@@ -128,22 +146,18 @@ export declare class AutoUpdater extends EventEmitter {
128
146
  */
129
147
  getLastError(): Error | null;
130
148
  constructor(config: UpdaterConfig);
131
- /**
132
- * Start automatic update checking
133
- */
134
- startAutoCheck(): void;
135
- /**
136
- * Stop automatic update checking
137
- */
149
+ /** Start one background lifecycle. Repeated starts are idempotent. Never installs by default. */
150
+ startAutoCheck(options?: AutoCheckOptions): void;
151
+ /** Stop timers and invalidate policy reads already in flight. Downloads already started may finish. */
138
152
  stopAutoCheck(): void;
139
- /**
140
- * Check for available updates
141
- */
142
- checkForUpdates(): Promise<UpdateInfo | null>;
153
+ /** Concurrent manual and background callers share one manifest request. */
154
+ checkForUpdates(options?: CheckForUpdatesOptions): Promise<UpdateInfo | null>;
155
+ private performCheck;
143
156
  /**
144
157
  * Download the update
145
158
  */
146
159
  downloadUpdate(): Promise<string | null>;
160
+ private performDownload;
147
161
  /**
148
162
  * Install the downloaded update.
149
163
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.443",
3
+ "version": "0.0.449",
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>",