craft-native 0.0.497 → 0.0.500

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.
@@ -13,11 +13,22 @@ enum CraftPersistentStorage {
13
13
  let digest = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
14
14
  return directory.appendingPathComponent(digest + ".json")
15
15
  }
16
+ /// What an entry file holds, or nil when it is there and is not an entry.
17
+ ///
18
+ /// A file that cannot be parsed is a corrupt value, as one that parses to
19
+ /// the wrong thing is, and an app starts that value again. Only a file
20
+ /// that cannot be read at all is the disk failing, which an app retries;
21
+ /// that still throws. The two used to share the disk's answer.
22
+ private static func entry(at source: URL) throws -> [String: Any]? {
23
+ let contents = try Data(contentsOf: source)
24
+ return (try? JSONSerialization.jsonObject(with: contents)) as? [String: Any]
25
+ }
26
+
16
27
  static func get(_ key: String, prefix: String, storageDirectory: URL? = nil) throws -> Data? {
17
28
  lock.lock(); defer { lock.unlock() }
18
29
  let source = path(key, directory: storageDirectory ?? directory)
19
30
  if FileManager.default.fileExists(atPath: source.path) {
20
- guard let saved = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], saved["key"] as? String == key else {
31
+ guard let saved = try entry(at: source), saved["key"] as? String == key else {
21
32
  throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
22
33
  }
23
34
  if saved["deleted"] as? Bool == true { return nil }
@@ -26,7 +37,9 @@ enum CraftPersistentStorage {
26
37
  }
27
38
  guard let legacy = UserDefaults.standard.data(forKey: prefix + key) else { return nil }
28
39
  // Only valid legacy JSON qualifies for deferred migration on I/O failure.
29
- _ = try JSONSerialization.jsonObject(with: legacy, options: .fragmentsAllowed)
40
+ guard (try? JSONSerialization.jsonObject(with: legacy, options: .fragmentsAllowed)) != nil else {
41
+ throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Stored value is not valid JSON")
42
+ }
30
43
  // Reading a valid legacy checkpoint must survive a full disk. Migration
31
44
  // can be retried on a later read; only a successful set promises a new
32
45
  // durable checkpoint and permits the application to acknowledge it.
@@ -84,9 +97,11 @@ enum CraftPersistentStorage {
84
97
  var result = Set(UserDefaults.standard.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) })
85
98
  if FileManager.default.fileExists(atPath: directory.path) {
86
99
  for source in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) where source.pathExtension == "json" {
87
- guard let entry = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], let key = entry["key"] as? String else {
88
- throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
89
- }
100
+ // A file that is not an entry names no key. It used to fail
101
+ // the whole listing, and `clear` with it, over a value
102
+ // neither call had been asked about. `get` of its key still
103
+ // answers STORAGE_CORRUPT.
104
+ guard let entry = try entry(at: source), let key = entry["key"] as? String else { continue }
90
105
  if entry["deleted"] as? Bool == true { result.remove(key) } else { result.insert(key) }
91
106
  }
92
107
  }
@@ -95,5 +110,13 @@ enum CraftPersistentStorage {
95
110
  static func clear(prefix: String) throws {
96
111
  lock.lock(); defer { lock.unlock() }
97
112
  for key in try keys(prefix: prefix) { try set(key, data: nil, prefix: prefix) }
113
+ // Clearing a key writes over its file, so what is still not an entry
114
+ // belongs to no key this store knows, and cannot be marked removed.
115
+ // It is deleted. No earlier value can come back in its place: every
116
+ // one this store mirrors was removed just above.
117
+ guard FileManager.default.fileExists(atPath: directory.path) else { return }
118
+ for source in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) where source.pathExtension == "json" {
119
+ if try entry(at: source)?["key"] as? String == nil { try FileManager.default.removeItem(at: source) }
120
+ }
98
121
  }
99
122
  }
@@ -317,6 +317,13 @@ enum CraftWorkoutPlanBuilder {
317
317
  return double.isFinite ? double : nil
318
318
  }
319
319
 
320
+ /// A number as a whole one, toward zero, or nil when no `Int` holds it.
321
+ /// `Int(_:)` traps there, and these come from the caller: an `hour` of
322
+ /// `1e30` ended the process where it should have been refused (#510).
323
+ private static func whole(_ value: Any?) -> Int? {
324
+ number(value).flatMap { Int(exactly: $0.rounded(.towardZero)) }
325
+ }
326
+
320
327
  /// Two schedule dates as WorkoutKit compares them: the day and the time of day.
321
328
  static func sameMoment(_ a: DateComponents, _ b: DateComponents) -> Bool {
322
329
  a.year == b.year && a.month == b.month && a.day == b.day && (a.hour ?? 0) == (b.hour ?? 0) && (a.minute ?? 0) == (b.minute ?? 0)
@@ -328,8 +335,8 @@ enum CraftWorkoutPlanBuilder {
328
335
  let parts = text.split(separator: "-", omittingEmptySubsequences: false)
329
336
  guard parts.count == 3, parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
330
337
  let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil }
331
- let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 : number(body["hour"]).map { Int($0) }
332
- let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 : number(body["minute"]).map { Int($0) }
338
+ let hour = body["hour"] == nil || body["hour"] is NSNull ? 7 : whole(body["hour"])
339
+ let minute = body["minute"] == nil || body["minute"] is NSNull ? 0 : whole(body["minute"])
333
340
  guard let hour, let minute, (0...23).contains(hour), (0...59).contains(minute) else { return nil }
334
341
  var components = DateComponents()
335
342
  components.year = year
@@ -379,7 +386,7 @@ enum CraftWorkoutPlanBuilder {
379
386
  }
380
387
  func zone() -> Int? {
381
388
  guard let value = number(body["zone"]), value >= 1, value.rounded() == value else { return nil }
382
- return Int(value)
389
+ return Int(exactly: value)
383
390
  }
384
391
  switch type {
385
392
  case "heartRate":
@@ -485,7 +492,7 @@ enum CraftWorkoutPlanBuilder {
485
492
  var blocks: [IntervalBlock] = []
486
493
  for (blockIndex, value) in (workout["blocks"] as? [Any] ?? []).enumerated() {
487
494
  let block = value as? [String: Any] ?? [:]
488
- let iterations = number(block["iterations"] ?? 1).map { Int($0) } ?? 0
495
+ let iterations = whole(block["iterations"] ?? 1) ?? 0
489
496
  guard iterations >= 1 else { return refuse("block \(blockIndex + 1) needs iterations of 1 or more") }
490
497
  var steps: [IntervalStep] = []
491
498
  for (stepIndex, entry) in (block["steps"] as? [Any] ?? []).enumerated() {
@@ -503,6 +503,25 @@ final class CraftAudioBufferSource: CraftAudioSource {
503
503
  private var played = 0.0
504
504
  private var began = false
505
505
 
506
+ /// An arbitrary limit on the rate, and WebKit's: `MaxRate` in its
507
+ /// AudioBufferSourceNode.
508
+ static let maximumRate = 1024.0
509
+
510
+ /// The rate a buffer plays at: its own sample rate over the context's,
511
+ /// times `playbackRate`, times two to the power of `detune` in octaves.
512
+ ///
513
+ /// Neither param is bounded for a buffer source, so the product can be
514
+ /// infinite (a detune of 2,000,000 cents) or not a number (that, with a
515
+ /// playbackRate of 0). A looping source wrapped an infinite position into
516
+ /// one that is not a number, and `Int(position)` traps on it: the render
517
+ /// thread ended the process. WebKit clamps the rate to its limit either
518
+ /// side of zero and plays a rate that is not a number at 1; so does this.
519
+ static func rate(ratio: Double, playbackRate: Double, detune: Double) -> Double {
520
+ let rate = ratio * playbackRate * pow(2, detune / 1200)
521
+ guard !rate.isNaN else { return 1 }
522
+ return max(-maximumRate, min(maximumRate, rate))
523
+ }
524
+
506
525
  override init(id: Int) {
507
526
  super.init(id: id)
508
527
  params = [playbackRate, detune]
@@ -530,7 +549,7 @@ final class CraftAudioBufferSource: CraftAudioSource {
530
549
  position = loopFrom + (position - loopFrom).truncatingRemainder(dividingBy: span)
531
550
  }
532
551
  }
533
- let rate = buffer.sampleRate / render.sampleRate * Double(playbackRate.values[0]) * pow(2, Double(detune.values[0]) / 1200)
552
+ let rate = Self.rate(ratio: buffer.sampleRate / render.sampleRate, playbackRate: Double(playbackRate.values[0]), detune: Double(detune.values[0]))
534
553
  let first = buffer.channels[0]
535
554
  let second = buffer.channels.count > 1 ? buffer.channels[1] : first
536
555
  let frames = buffer.length
package/dist/package.d.ts CHANGED
@@ -376,6 +376,28 @@ export declare function windowsExecutableName(name: string): string;
376
376
  * off, so nothing ever reported it.
377
377
  */
378
378
  export declare function candleArguments(architecture: 'x86' | 'x64' | 'arm64', wixobjPath: string, wxsPath: string): string[];
379
+ /**
380
+ * Helper: Create Linux RPM package
381
+ */
382
+ /**
383
+ * The spec an app's rpm is built from: one prebuilt executable, installed as
384
+ * it is.
385
+ *
386
+ * `Source0` names the executable, which is what `%{SOURCE0}` in `%install`
387
+ * refers to; without the tag the macro is not defined and the install step
388
+ * fails. The executable is a finished program, so nothing is stripped from it,
389
+ * no debug package is split off and no build-id links are made: a compiled
390
+ * app that carries its own data after the program (a Bun or Deno executable)
391
+ * stops working when strip rewrites it. With nothing required, the `Requires`
392
+ * line is left out, because an empty one is not valid.
393
+ */
394
+ export declare function rpmSpec(options: {
395
+ name: string;
396
+ version: string;
397
+ summary: string;
398
+ description: string;
399
+ requires?: string;
400
+ }): string;
379
401
  /**
380
402
  * Simple packaging function for quick use
381
403
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.497",
3
+ "version": "0.0.500",
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>",