@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.17

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.
@@ -4564,8 +4564,8 @@
4564
4564
  }
4565
4565
 
4566
4566
  // LampTransition
4567
- /// While Lamp builds a change for this app, smoke curls up from the bottom of its windows, and when the change
4568
- /// goes in Lamp's stripes roll up across them once, so the change looks alive rather than abrupt. Only Lamp starts it: the
4567
+ /// While Lamp builds a change for this app, a small card at the foot of its windows says it's updating, and what's
4568
+ /// changing; when the change goes in it says "Updated" for a moment, so every change is seen. Only Lamp starts it: the
4569
4569
  /// request is signed with the app's token (the "lampTransitionSignature" key marks the builds that understand
4570
4570
  /// it), and nothing here is reachable from the app's own code. It's drawn in clear windows laid over the app's
4571
4571
  /// (children, so they move and stack with them) that let every click through, and the pictures Lamp takes for
@@ -4578,12 +4578,15 @@
4578
4578
  let info = note.userInfo
4579
4579
  let phase = info?["phase"] as? String ?? "", id = info?["id"] as? String ?? ""
4580
4580
  let at = info?["at"] as? String ?? "", signature = info?["lampTransitionSignature"] as? String ?? ""
4581
+ // What's changing, signed apart, so an app that doesn't read it still takes the request.
4582
+ let given = info?["words"] as? String
4583
+ let words = given.flatMap { Gate.wordsSignature(id: id, words: $0, token: LampInstall.token) == info?["lampTransitionWords"] as? String ? String($0.prefix(200)) : nil }
4581
4584
  MainActor.assumeIsolated {
4582
4585
  guard gate.admits(phase: phase, id: id, at: at, signature: signature, now: Date()) else { return }
4583
4586
  switch phase {
4584
- case "begin": begin()
4587
+ case "begin": begin(words: words)
4585
4588
  case "turn": turn()
4586
- case "land": land()
4589
+ case "land": land(words: words)
4587
4590
  default: end()
4588
4591
  }
4589
4592
  }
@@ -4611,6 +4614,11 @@
4611
4614
  .map { String(format: "%02x", $0) }.joined()
4612
4615
  }
4613
4616
 
4617
+ static func wordsSignature(id: String, words: String, token: String) -> String {
4618
+ HMAC<SHA256>.authenticationCode(for: Data("transition-words\n\(id)\n\(words)".utf8), using: SymmetricKey(data: Data(token.utf8)))
4619
+ .map { String(format: "%02x", $0) }.joined()
4620
+ }
4621
+
4614
4622
  mutating func admits(phase: String, id: String, at: String, signature: String, now: Date) -> Bool {
4615
4623
  guard !token.isEmpty, !id.isEmpty, ["begin", "turn", "land", "end"].contains(phase), let sent = Double(at),
4616
4624
  abs(now.timeIntervalSince1970 - sent) < 10, !seen.contains(id),
@@ -4635,31 +4643,41 @@
4635
4643
  && (LampMagic.isAppWindow($0) || LampDriver.isBarPanel($0)) && $0.frame.width > 40 && $0.frame.height > 40 }
4636
4644
  }
4637
4645
 
4638
- @MainActor static func begin() {
4646
+ @MainActor static func begin(words: String?) {
4639
4647
  renew()
4640
4648
  glasses.removeAll { $0.over == nil || $0.isLeaving }
4649
+ // A request the person sent since says what's changing now, on the cards already up too.
4650
+ if let words { for glass in glasses { glass.words = words } }
4641
4651
  for window in windows where !glasses.contains(where: { $0.over === window }) {
4642
4652
  let glass = Glass(over: window)
4653
+ glass.words = words
4643
4654
  glasses.append(glass)
4644
4655
  glass.glow(still: reduceMotion)
4645
4656
  }
4646
4657
  }
4647
4658
 
4648
- /// The app is standing still taking the change in (a game running its code again): the stripes roll up its
4649
- /// windows every few seconds until the change is on screen (`land`). Only over windows already glowing for it.
4659
+ /// The app is standing still taking the change in (a game running its code again): the card stays until the
4660
+ /// change is on screen (`land`). Only over windows already showing it.
4650
4661
  @MainActor static func turn() {
4651
4662
  renew()
4652
4663
  for glass in glasses where !glass.isLeaving { glass.turn(still: reduceMotion) }
4653
4664
  }
4654
4665
 
4655
- /// The change went in: the stripes roll up each window once (fading where motion is reduced), then the smoke and the glow go.
4656
- @MainActor static func land() {
4666
+ /// The change went in: each card says "Updated" for a moment, then goes.
4667
+ @MainActor static func land(words: String?) {
4657
4668
  timeout?.cancel()
4658
4669
  if glasses.isEmpty {
4659
4670
  // A copy opened for this change never saw the begin: it shows that it's new all the same.
4660
- for window in windows { glasses.append(Glass(over: window)) }
4671
+ for window in windows {
4672
+ let glass = Glass(over: window)
4673
+ glass.words = words
4674
+ glasses.append(glass)
4675
+ }
4676
+ }
4677
+ for glass in glasses {
4678
+ if let words { glass.words = words }
4679
+ glass.reveal(still: reduceMotion)
4661
4680
  }
4662
- for glass in glasses { glass.reveal(still: reduceMotion) }
4663
4681
  glasses.removeAll()
4664
4682
  }
4665
4683
 
@@ -4676,7 +4694,7 @@
4676
4694
  DispatchQueue.main.asyncAfter(deadline: .now() + patience, execute: work)
4677
4695
  }
4678
4696
 
4679
- /// A clear window over one of the app's, holding the effect's layers (`Look`).
4697
+ /// A clear window over one of the app's, holding the card (`Look`).
4680
4698
  @MainActor final class Glass: NSPanel {
4681
4699
  weak var over: NSWindow?
4682
4700
  private(set) var isLeaving = false
@@ -4712,6 +4730,11 @@
4712
4730
  return 12
4713
4731
  }
4714
4732
 
4733
+ var words: String? {
4734
+ get { look.words }
4735
+ set { look.words = newValue }
4736
+ }
4737
+
4715
4738
  func fit() {
4716
4739
  guard let over else { return }
4717
4740
  setFrame(over.frame, display: true)
@@ -4750,16 +4773,11 @@
4750
4773
  }
4751
4774
 
4752
4775
  // TransitionLook
4753
- /// The effect's layers, filling the window they're laid over and cut to its rounding. Core Animation runs
4754
- /// them out of the app's process, so they keep moving while the app is busy taking the change in.
4755
- ///
4756
- /// While the change is built, smoke in Lamp's stripe colors curls up from the bottom edge, thick there and thinning
4757
- /// as it rises a third of the way up, with a faint glow round the edge: noticeable on any ground, and out of the way
4758
- /// of most of the window. When the change goes in the smoke surges for a moment, then the stripes roll up the window
4759
- /// on Lamp's slant, as if round a drum: each comes in as a thin line, widens to the middle and thins again as it
4760
- /// leaves, and at the middle of the roll they cover the window whole, leaving it clear behind them. While the app
4761
- /// stands still taking the change in (a game running its code again), the stripes roll up every few seconds, so the
4762
- /// frame held there reads as the change going in, and once it's on screen the turn under way finishes.
4776
+ /// A small card at the foot of the app's window while Lamp builds its change and as it goes in: Lamp's mark
4777
+ /// turning, "Updating" and what's changing, in the person's words; then "Updated" for a moment once the change is
4778
+ /// in, so every change is seen to land, however quick. It covers nothing but itself, and every click goes through
4779
+ /// to the app. Core Animation runs it out of the app's process, so it keeps turning while the app is busy taking
4780
+ /// the change in.
4763
4781
  @MainActor final class Look: NSView {
4764
4782
  /// Lamp's stripes, in the order they come up: green, lime, yellow, orange, red and magenta.
4765
4783
  static let hues: [NSColor] = [
@@ -4771,43 +4789,69 @@
4771
4789
  NSColor(red: 0.824, green: 0.216, blue: 0.471, alpha: 1),
4772
4790
  ]
4773
4791
 
4774
- /// The stripes' slant, as on the website: rising to the right.
4775
- private static let slant: CGFloat = .pi / 6
4792
+ /// The window's own rounding: the card keeps its own, so this only says how the window is cut.
4793
+ var cornerRadius: CGFloat = 12
4776
4794
 
4777
- /// The window's own rounding, so the glow follows its corners.
4778
- var cornerRadius: CGFloat = 12 {
4795
+ /// What's changing, as the person asked for it, after "Updating": cut to the card's width.
4796
+ var words: String? {
4779
4797
  didSet {
4780
- guard cornerRadius != oldValue else { return }
4781
- layer?.cornerRadius = cornerRadius
4798
+ guard words != oldValue else { return }
4782
4799
  arranged = .zero
4783
4800
  needsLayout = true
4784
4801
  }
4785
4802
  }
4786
4803
 
4787
- /// One turn of the drum, in seconds.
4788
- private static let roll = 1.1
4789
- /// The roll's place in the turn while the app stands still: the stripes roll up once as it starts (`turn`), then
4790
- /// the glow and the smoke alone say the change is under way, so a long wait isn't a run of rainbows.
4791
- private static let turnEvery = 3.2
4792
- /// When the stripes began turning while the app stood still (`turn`), in the stripes' time.
4793
- private var turning: CFTimeInterval?
4804
+ /// The card stays at least this long, so a change that lands at once is still seen.
4805
+ private static let shownAtLeast = 1.2
4806
+ /// "Updated" stays this long before the card goes.
4807
+ private static let heldAfter = 1.4
4808
+ /// One turn of the mark, in seconds.
4809
+ private static let spinEvery = 1.4
4794
4810
 
4795
- private let glowLayer = CALayer()
4796
- private let wisps = CAEmitterLayer()
4797
- private let stripes = CALayer()
4811
+ private let card = CALayer()
4812
+ private let mark = CALayer()
4813
+ private let bands = CALayer()
4814
+ private let title = CATextLayer()
4815
+ private let detail = CATextLayer()
4816
+ private var heading = "Updating"
4798
4817
  private var arranged = CGRect.zero
4818
+ private var shownAt: CFTimeInterval?
4799
4819
 
4800
4820
  override init(frame: NSRect) {
4801
4821
  super.init(frame: frame)
4802
4822
  wantsLayer = true
4803
4823
  autoresizingMask = [.width, .height]
4804
- layer?.masksToBounds = true
4805
- layer?.cornerRadius = cornerRadius
4806
- for sublayer in [glowLayer, wisps, stripes] {
4807
- sublayer.opacity = 0
4808
- layer?.addSublayer(sublayer)
4809
- }
4810
- wisps.birthRate = 0
4824
+ card.opacity = 0
4825
+ card.backgroundColor = NSColor(white: 0.08, alpha: 0.86).cgColor
4826
+ card.borderColor = NSColor(white: 1, alpha: 0.12).cgColor
4827
+ card.borderWidth = 1
4828
+ card.shadowColor = NSColor.black.cgColor
4829
+ card.shadowOpacity = 0.3
4830
+ card.shadowRadius = 12
4831
+ card.shadowOffset = CGSize(width: 0, height: -3)
4832
+ layer?.addSublayer(card)
4833
+ // Lamp's mark: its stripes on their slant, in a disc, which turns while the change is under way.
4834
+ mark.masksToBounds = true
4835
+ for (index, hue) in Self.hues.enumerated() {
4836
+ let band = CALayer()
4837
+ band.backgroundColor = hue.cgColor
4838
+ band.name = String(index)
4839
+ bands.addSublayer(band)
4840
+ }
4841
+ mark.addSublayer(bands)
4842
+ card.addSublayer(mark)
4843
+ for text in [title, detail] {
4844
+ text.isWrapped = false
4845
+ text.truncationMode = .end
4846
+ text.alignmentMode = .left
4847
+ text.foregroundColor = NSColor.white.cgColor
4848
+ card.addSublayer(text)
4849
+ }
4850
+ title.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
4851
+ title.fontSize = 13
4852
+ detail.font = NSFont.systemFont(ofSize: 13)
4853
+ detail.fontSize = 13
4854
+ detail.opacity = 0.72
4811
4855
  }
4812
4856
 
4813
4857
  required init?(coder: NSCoder) { nil }
@@ -4830,238 +4874,122 @@
4830
4874
  CATransaction.commit()
4831
4875
  }
4832
4876
 
4833
- private func arrange() {
4834
- // Only blur, no line: each band of the glow is drawn just outside the window, where the window's own
4835
- // rounding cuts it off, so all that shows is its shadow coming in from the edge, going round the colors.
4836
- glowLayer.frame = bounds
4837
- glowLayer.sublayers?.forEach { $0.removeFromSuperlayer() }
4838
- for (blur, strength) in [(CGFloat(54), Float(0.8)), (24, 1)] {
4839
- let width: CGFloat = 40
4840
- let edge = CAShapeLayer()
4841
- edge.frame = bounds
4842
- edge.path = CGPath(roundedRect: bounds.insetBy(dx: -width / 2, dy: -width / 2), cornerWidth: cornerRadius + width / 2,
4843
- cornerHeight: cornerRadius + width / 2, transform: nil)
4844
- edge.fillColor = nil
4845
- edge.strokeColor = NSColor.white.cgColor
4846
- edge.lineWidth = width
4847
- edge.shadowColor = Self.hues[0].cgColor
4848
- edge.shadowOpacity = strength
4849
- edge.shadowRadius = blur
4850
- edge.shadowOffset = .zero
4851
- let cycle = CAKeyframeAnimation(keyPath: "shadowColor")
4852
- cycle.values = (Self.hues + [Self.hues[0]]).map(\.cgColor)
4853
- cycle.duration = 5
4854
- cycle.repeatCount = .infinity
4855
- edge.add(cycle, forKey: "hue")
4856
- glowLayer.addSublayer(edge)
4857
- }
4858
-
4859
- // Smoke rising from below the bottom edge, a stream of puffs for each color. How far it rises goes with
4860
- // the window's height (speed times life, and the puffs growing), so it reaches about a third of the way
4861
- // up at any size.
4862
- wisps.frame = bounds
4863
- wisps.emitterShape = .line
4864
- wisps.emitterPosition = CGPoint(x: bounds.midX, y: -24)
4865
- wisps.emitterSize = CGSize(width: bounds.width * 1.1, height: 0)
4866
- let rise = bounds.height * 0.1, life: Float = 6
4867
- wisps.emitterCells = Self.hues.enumerated().map { index, hue in
4868
- let cell = CAEmitterCell()
4869
- cell.contents = Self.puff
4870
- cell.color = hue.withAlphaComponent(0.42).cgColor
4871
- cell.redRange = 0.08; cell.greenRange = 0.08; cell.blueRange = 0.08
4872
- cell.birthRate = Float(bounds.width / 66)
4873
- cell.lifetime = life; cell.lifetimeRange = 1.5
4874
- cell.velocity = rise; cell.velocityRange = rise * 0.45
4875
- cell.emissionLongitude = .pi / 2; cell.emissionRange = 0.55
4876
- cell.xAcceleration = index.isMultiple(of: 2) ? 3 : -3
4877
- cell.scale = 0.8; cell.scaleRange = 0.35; cell.scaleSpeed = 0.16
4878
- cell.alphaSpeed = -0.42 / life
4879
- cell.spinRange = 0.6
4880
- return cell
4881
- }
4882
-
4883
- // The stripes: level in a layer turned to the slant and just big enough that, across its height, they reach
4884
- // from one corner of the window to the opposite one. Each starts with no height, waiting at the bottom.
4885
- let along = bounds.width * cos(Self.slant) + bounds.height * sin(Self.slant)
4886
- let across = bounds.width * sin(Self.slant) + bounds.height * cos(Self.slant)
4887
- stripes.bounds = CGRect(x: 0, y: 0, width: along, height: across)
4888
- stripes.position = CGPoint(x: bounds.midX, y: bounds.midY)
4889
- stripes.setAffineTransform(CGAffineTransform(rotationAngle: Self.slant))
4890
- stripes.sublayers?.forEach { $0.removeFromSuperlayer() }
4891
- for (index, hue) in Self.hues.enumerated() {
4892
- let stripe = CALayer()
4893
- stripe.backgroundColor = hue.cgColor
4894
- stripe.allowsEdgeAntialiasing = true
4895
- stripe.anchorPoint = .zero
4896
- Self.place(stripe, index: index, roll: 0, across: across, along: along)
4897
- stripes.addSublayer(stripe)
4898
- }
4899
- // Laid out again while the app stands still (its window resized, or put in full screen): the new stripes take up
4900
- // the roll where it is, from when it began.
4901
- if let began = turning { rollStripes(from: began, turns: 1, every: Self.turnEvery) }
4902
- }
4903
-
4904
- /// Where a stripe's edges are at a point of the roll, from 0 to 2π: the stripes lie side by side round a drum
4905
- /// whose near half is the window. An edge's height is the drum's seen from the front, so the stripes are thin
4906
- /// where the drum turns away, top and bottom, and the six together are as tall as the window at π.
4907
- private static func edges(index: Int, roll: CGFloat, across: CGFloat) -> (bottom: CGFloat, top: CGFloat) {
4908
- let share = CGFloat.pi / CGFloat(hues.count)
4909
- func height(_ angle: CGFloat) -> CGFloat { (1 - cos(min(max(angle, 0), .pi))) / 2 * across }
4910
- return (height(roll - CGFloat(index + 1) * share), height(roll - CGFloat(index) * share))
4911
- }
4912
-
4913
- /// A stripe at a point of the roll, held a point past its lower edge so no line of the window shows between two.
4914
- private static func place(_ stripe: CALayer, index: Int, roll: CGFloat, across: CGFloat, along: CGFloat) {
4915
- let edge = edges(index: index, roll: roll, across: across)
4916
- stripe.position = CGPoint(x: 0, y: edge.bottom)
4917
- stripe.bounds = CGRect(x: 0, y: 0, width: along, height: edge.top > edge.bottom ? edge.top - edge.bottom + 1 : 0)
4918
- }
4919
-
4920
- /// A puff of smoke: white, soft all the way to its edge, tinted by the cell that uses it.
4921
- private static let puff: CGImage? = {
4922
- let size = 128
4923
- guard let context = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: 0,
4924
- space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue),
4925
- let gradient = CGGradient(colorsSpace: nil, colors: [NSColor(white: 1, alpha: 0.9).cgColor, NSColor(white: 1, alpha: 0.45).cgColor,
4926
- NSColor(white: 1, alpha: 0).cgColor] as CFArray, locations: [0, 0.45, 1])
4927
- else { return nil }
4928
- let center = CGPoint(x: size / 2, y: size / 2)
4929
- context.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: CGFloat(size / 2), options: [])
4930
- return context.makeImage()
4931
- }()
4877
+ private static func width(of text: String, weight: NSFont.Weight) -> CGFloat {
4878
+ ceil(NSAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: 13, weight: weight)]).size().width)
4879
+ }
4932
4880
 
4933
- /// The glow and the smoke come up; where motion is reduced, only the glow, still.
4881
+ private func arrange() {
4882
+ let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
4883
+ let words = (self.words ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
4884
+ let height: CGFloat = 36, side: CGFloat = 18, inset: CGFloat = 12
4885
+ let titleWidth = Self.width(of: heading, weight: .semibold)
4886
+ let wordsWidth = words.isEmpty ? 0 : Self.width(of: words, weight: .regular) + 8
4887
+ let widest = max(160, min(bounds.width - 32, 460))
4888
+ let width = min(widest, inset + side + 8 + titleWidth + wordsWidth + inset + 4)
4889
+ card.frame = CGRect(x: ((bounds.width - width) / 2).rounded(), y: min(20, bounds.height * 0.06), width: width, height: height)
4890
+ card.cornerRadius = height / 2
4891
+ card.shadowPath = CGPath(roundedRect: card.bounds, cornerWidth: height / 2, cornerHeight: height / 2, transform: nil)
4892
+ mark.frame = CGRect(x: inset, y: (height - side) / 2, width: side, height: side)
4893
+ mark.cornerRadius = side / 2
4894
+ // Wider than the disc, so its stripes reach the edge however it's turned.
4895
+ let across = side * 1.6
4896
+ bands.bounds = CGRect(x: 0, y: 0, width: across, height: across)
4897
+ bands.position = CGPoint(x: side / 2, y: side / 2)
4898
+ for band in bands.sublayers ?? [] {
4899
+ let index = CGFloat(Int(band.name ?? "0") ?? 0)
4900
+ band.frame = CGRect(x: 0, y: across - (index + 1) * across / 6, width: across, height: across / 6 + 0.5)
4901
+ }
4902
+ if bands.animation(forKey: "spin") == nil { bands.transform = CATransform3DMakeRotation(.pi / 6, 0, 0, 1) }
4903
+ let line: CGFloat = 17, textY = (height - line) / 2
4904
+ let textX = inset + side + 8
4905
+ title.contentsScale = scale
4906
+ detail.contentsScale = scale
4907
+ title.string = heading
4908
+ title.frame = CGRect(x: textX, y: textY, width: min(titleWidth, width - textX - inset), height: line)
4909
+ detail.string = words
4910
+ let detailX = textX + titleWidth + 8
4911
+ detail.frame = CGRect(x: detailX, y: textY, width: max(0, width - detailX - inset - 4), height: line)
4912
+ }
4913
+
4914
+ /// A change is being built: the card comes up with "Updating" and the mark turning.
4934
4915
  func glow(still: Bool) {
4935
- layoutSubtreeIfNeeded()
4936
- Self.animate(glowLayer, "opacity", from: 0, to: 0.22, duration: 0.6)
4937
- guard !still else { return }
4938
- Self.animate(wisps, "opacity", from: 0, to: 1, duration: 1.2)
4939
- wisps.birthRate = 1
4916
+ say("Updating")
4917
+ show(still: still)
4918
+ spin(still: still)
4940
4919
  }
4941
4920
 
4942
- /// The app stands still taking the change in: the stripes roll up the window once, the smoke and the glow staying
4921
+ /// The app stands still taking the change in (a game running its code again): the card stays as it is.
4922
+ func turn(still: Bool) { glow(still: still) }
4943
4923
 
4944
- /// until `reveal`. Where motion is reduced, the glow alone stays, as while it was built.
4945
- func turn(still: Bool) {
4946
- layoutSubtreeIfNeeded()
4947
- guard !still, turning == nil else { return }
4948
- let now = stripes.convertTime(CACurrentMediaTime(), from: nil)
4949
- turning = now
4950
- rollStripes(from: now, turns: 1, every: Self.turnEvery)
4924
+ /// The change is in: "Updated", the mark stops, and the card goes after a moment. A change that lands without
4925
+ /// a begin (a copy just opened with it) shows the card all the same.
4926
+ func reveal(still: Bool) -> TimeInterval {
4927
+ show(still: still)
4928
+ say("Updated")
4929
+ bands.removeAnimation(forKey: "spin")
4930
+ bands.transform = CATransform3DMakeRotation(.pi / 6, 0, 0, 1)
4931
+ if !still {
4932
+ let pop = CABasicAnimation(keyPath: "transform.scale")
4933
+ pop.fromValue = 1.25
4934
+ pop.toValue = 1
4935
+ pop.duration = 0.3
4936
+ pop.timingFunction = CAMediaTimingFunction(name: .easeOut)
4937
+ mark.add(pop, forKey: "pop")
4938
+ }
4939
+ let now = CACurrentMediaTime()
4940
+ let wait = max(Self.heldAfter, Self.shownAtLeast - (now - (shownAt ?? now)))
4941
+ Self.animate(card, "opacity", from: 1, to: 0, duration: 0.3, delay: wait)
4942
+ return wait + 0.35
4943
+ }
4944
+
4945
+ /// Nothing goes in after all (the build failed, or the change waits for the person): the card goes.
4946
+ func fade() -> TimeInterval {
4947
+ guard shownAt != nil else { return 0 }
4948
+ Self.animate(card, "opacity", from: card.presentation()?.opacity ?? card.opacity, to: 0, duration: 0.25)
4949
+ return 0.3
4951
4950
  }
4952
4951
 
4953
- /// The change went in: the smoke surges and the stripes roll up (the stripes fading where motion is reduced), and
4954
- /// the glow and the smoke go. Answers how long that takes, after which the layers can go.
4955
- func reveal(still: Bool) -> TimeInterval {
4952
+ private func say(_ heading: String) {
4953
+ guard heading != self.heading else { return }
4954
+ self.heading = heading
4955
+ arranged = .zero
4956
+ needsLayout = true
4956
4957
  layoutSubtreeIfNeeded()
4957
- let glowing = glowLayer.presentation()?.opacity ?? glowLayer.opacity
4958
- let across = stripes.bounds.height, along = stripes.bounds.width
4959
- let layers = stripes.sublayers ?? []
4960
- if still {
4961
- wisps.birthRate = 0
4962
- Self.animate(glowLayer, "opacity", from: glowing, to: 0, duration: 0.6)
4963
- Self.animate(wisps, "opacity", from: wisps.presentation()?.opacity ?? wisps.opacity, to: 0, duration: 0.6)
4964
- CATransaction.begin()
4965
- CATransaction.setDisableActions(true)
4966
- for (index, stripe) in layers.enumerated() {
4967
- Self.place(stripe, index: index, roll: .pi, across: across, along: along)
4968
- }
4969
- CATransaction.commit()
4970
- Self.animate(stripes, "opacity", from: 0.35, to: 0, duration: 0.7)
4971
- return 0.8
4972
- }
4973
- // Turning already (`turn`): its one roll, still under way, is the last and finishes; after it, one more rolls
4974
- // now, so the change landing is marked as it is without turning. The smoke and the glow go meanwhile.
4975
- if let began = turning {
4976
- turning = nil
4977
- let now = stripes.convertTime(CACurrentMediaTime(), from: nil)
4978
- let done: Double = 0
4979
- let rolling = now - began < Self.roll
4980
- if rolling { rollStripes(from: began, turns: Float(done + 1), every: Self.turnEvery) }
4981
- else { rollStripes(from: now, turns: 1, every: Self.roll) }
4982
- wisps.birthRate = 0
4983
- Self.animate(wisps, "opacity", from: wisps.presentation()?.opacity ?? wisps.opacity, to: 0, duration: 0.6)
4984
- Self.animate(glowLayer, "opacity", from: glowing, to: 0, duration: 0.6)
4985
- let left = rolling ? began + done * Self.turnEvery + Self.roll - now : Self.roll
4986
- return max(left, 0.6) + 0.2
4987
- }
4988
- // The smoke gathers itself, more of it and rising faster, for a moment before the roll. Timed by
4989
- // animations, not by the main thread, which may be busy with the new code.
4990
- let gather = 0.3
4991
- wisps.opacity = 1
4992
- Self.animate(wisps, "velocity", from: 1, to: 3.5, duration: gather, timing: CAMediaTimingFunction(name: .easeIn))
4993
- let surge = CAKeyframeAnimation(keyPath: "birthRate")
4994
- surge.values = [1, 3, 0]
4995
- surge.keyTimes = [0, 0.95, 1]
4996
- surge.duration = gather
4997
- surge.calculationMode = .discrete
4998
- wisps.birthRate = 0
4999
- wisps.add(surge, forKey: "surge")
5000
-
5001
- // Then the stripes roll up at the drum's steady turn, which is what eases them in and out at the edges, and
5002
- // the smoke and the glow go with them.
5003
- rollStripes(from: stripes.convertTime(CACurrentMediaTime(), from: nil) + gather, turns: 1, every: Self.roll)
5004
- Self.animate(wisps, "opacity", from: 1, to: 0, duration: 0.6, delay: gather + 0.1)
5005
- Self.animate(glowLayer, "opacity", from: glowing, to: 0, duration: 0.6, delay: gather)
5006
- return gather + Self.roll + 0.2
5007
- }
5008
-
5009
- /// The stripes roll up the window from `begin` (in the stripes' time), `turns` times round, each stripe's edges
5010
- /// worked out ahead along the roll so Core Animation runs them without the app, and each left with no height
5011
- /// after. A turn comes round `every` seconds, the window clear between rolls. Rolled again from the same `begin`,
5012
- /// the roll goes on unbroken with its new number of turns.
5013
- private func rollStripes(from begin: CFTimeInterval, turns: Float, every: Double) {
5014
- let across = stripes.bounds.height, along = stripes.bounds.width
5015
- let steps = 60
5016
- // The roll takes its own time at the start of each turn, then the stripes wait at the top, with no height.
5017
- let share = Self.roll / max(every, Self.roll)
5018
- let times = (0...steps).map { NSNumber(value: share * Double($0) / Double(steps)) } + (share < 1 ? [1] : [])
5019
- CATransaction.begin()
5020
- CATransaction.setDisableActions(true)
5021
- stripes.opacity = 1
5022
- for (index, stripe) in (stripes.sublayers ?? []).enumerated() {
5023
- let frames = (0...steps).map { Self.edges(index: index, roll: 2 * .pi * CGFloat($0) / CGFloat(steps), across: across) }
5024
- let held = share < 1 ? 1 : 0
5025
- let bottoms = frames.map(\.bottom), heights = frames.map { $0.top > $0.bottom ? $0.top - $0.bottom + 1 : 0 }
5026
- Self.follow(stripe, "position.y", values: bottoms + bottoms.suffix(held), times: times, every: every, from: begin, turns: turns)
5027
- Self.follow(stripe, "bounds.size.height", values: heights + heights.suffix(held), times: times, every: every, from: begin, turns: turns)
5028
- Self.place(stripe, index: index, roll: 2 * .pi, across: across, along: along)
5029
- }
5030
- CATransaction.commit()
5031
4958
  }
5032
4959
 
5033
- private static func follow(_ layer: CALayer, _ key: String, values: [CGFloat], times: [NSNumber], every: Double,
5034
- from begin: CFTimeInterval, turns: Float) {
5035
- let animation = CAKeyframeAnimation(keyPath: key)
5036
- animation.values = values
5037
- animation.keyTimes = times
5038
- animation.duration = every
5039
- animation.repeatCount = turns
5040
- animation.beginTime = begin
5041
- animation.fillMode = .backwards
5042
- layer.add(animation, forKey: key)
4960
+ private func show(still: Bool) {
4961
+ guard shownAt == nil else { return }
4962
+ shownAt = CACurrentMediaTime()
4963
+ layoutSubtreeIfNeeded()
4964
+ Self.animate(card, "opacity", from: 0, to: 1, duration: still ? 0.15 : 0.25)
4965
+ guard !still else { return }
4966
+ let rise = CABasicAnimation(keyPath: "transform.translation.y")
4967
+ rise.fromValue = -10
4968
+ rise.toValue = 0
4969
+ rise.duration = 0.3
4970
+ rise.timingFunction = CAMediaTimingFunction(name: .easeOut)
4971
+ card.add(rise, forKey: "rise")
5043
4972
  }
5044
4973
 
5045
- /// The glow and the smoke go without a change going in. Answers how long that takes.
5046
- func fade() -> TimeInterval {
5047
- wisps.birthRate = 0
5048
- Self.animate(glowLayer, "opacity", from: glowLayer.presentation()?.opacity ?? glowLayer.opacity, to: 0, duration: 0.5)
5049
- Self.animate(wisps, "opacity", from: wisps.presentation()?.opacity ?? wisps.opacity, to: 0, duration: 1.0)
5050
- return 1.1
4974
+ private func spin(still: Bool) {
4975
+ guard !still, bands.animation(forKey: "spin") == nil else { return }
4976
+ let spin = CABasicAnimation(keyPath: "transform.rotation.z")
4977
+ spin.fromValue = CGFloat.pi / 6
4978
+ spin.toValue = CGFloat.pi / 6 - 2 * .pi
4979
+ spin.duration = Self.spinEvery
4980
+ spin.repeatCount = .infinity
4981
+ bands.add(spin, forKey: "spin")
5051
4982
  }
5052
4983
 
5053
- private static func animate(_ layer: CALayer, _ key: String, from: Float, to: Float, duration: Double, delay: Double = 0,
5054
- timing: CAMediaTimingFunction = CAMediaTimingFunction(name: .easeOut)) {
4984
+ private static func animate(_ layer: CALayer, _ key: String, from: Float, to: Float, duration: Double, delay: Double = 0) {
5055
4985
  let animation = CABasicAnimation(keyPath: key)
5056
4986
  animation.fromValue = from
5057
4987
  animation.toValue = to
5058
4988
  animation.duration = duration
5059
- animation.timingFunction = timing
5060
- if delay > 0 {
5061
- animation.beginTime = layer.convertTime(CACurrentMediaTime(), from: nil) + delay
5062
- animation.fillMode = .backwards
5063
- }
5064
- layer.setValue(to, forKey: key)
4989
+ animation.beginTime = CACurrentMediaTime() + delay
4990
+ animation.fillMode = .backwards
4991
+ animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
4992
+ layer.setValue(to, forKeyPath: key)
5065
4993
  layer.add(animation, forKey: key)
5066
4994
  }
5067
4995
  }
@@ -5659,6 +5587,9 @@
5659
5587
  var frame: CGRect // in the picture's pixels
5660
5588
  var selected = false
5661
5589
  var disabled = false
5590
+ /// A page's own thing: its place in the list the page's read made (`LampPageReader`), by which a click
5591
+ /// finds that very thing. Nil for what the accessibility tree lists.
5592
+ var ref: Int? = nil
5662
5593
  }
5663
5594
 
5664
5595
  /// The most things listed: a full window is a few dozen, and past this the list costs more than it tells.
@@ -5850,7 +5781,7 @@
5850
5781
  // which keeps running while the dialog is open only because the action isn't itself on that queue.
5851
5782
  after(0) {
5852
5783
  guard Handover.current?.carriesOn == true else { return }
5853
- LampShowcase.note("did", run(action))
5784
+ LampShowcase.note("did", run(action) { said, _ in LampShowcase.note("did", said) })
5854
5785
  }
5855
5786
  try? await Task.sleep(for: .milliseconds(150))
5856
5787
  }
@@ -5891,8 +5822,12 @@
5891
5822
  static var items: (NSWindow) -> [LampScreen.Item] = { _ in [] }
5892
5823
  /// Carries out an action aimed at a page (`point`, in the window's frame view, on it; or none, with the page
5893
5824
  /// holding the keyboard) in the page's own script, which takes it whether or not the app is in front, where
5894
- /// WebKit lets an event made by the app go. What it did, or nil when the action isn't the page's.
5895
- static var act: ((LampDriver.Action, NSPoint?, NSWindow) -> String?)?
5825
+ /// WebKit lets an event made by the app go. What it did, or nil when the action isn't the page's. A click is
5826
+ /// meant for the page's thing given, if any, and its outcome comes later, from the page: in words, and whether
5827
+ /// it clicked (a thing that was dimmed, gone or under another isn't).
5828
+ static var act: ((LampDriver.Action, NSPoint?, LampScreen.Item?, NSWindow, @escaping @MainActor (String, Bool) -> Void) -> String?)?
5829
+ /// What `act` says of a click until the page answers it: the batch waits for that answer before going on.
5830
+ static let sent = "sent to the page, which hasn't said what it landed on"
5896
5831
  /// Whether the game on a page is playing (`LampPagePlaying`): nil for an app with no game, false while it loads.
5897
5832
  @MainActor static var playing: Bool?
5898
5833
  /// Gives a game's controller to Lamp for batch `id` (seconds it may run) or back (nil): only that batch's own
@@ -5903,6 +5838,116 @@
5903
5838
  static var hold: ((LampDriver.Action, NSWindow, LampDriver.Handover, @escaping @MainActor (String, Bool) -> Void) -> Bool)?
5904
5839
  }
5905
5840
 
5841
+ // begin LampSaves
5842
+ /// What a save that `use_app` set off came to: a page's download (`LampPageDownloads` in LampPage.swift) says here
5843
+ /// where it was told to go and how it ended, and the driver's answer says it, so the builder knows the file is there,
5844
+ /// how big it is and what a zip holds without reading the person's folders itself. Only saves whose panel a batch answered
5845
+ /// are kept: the person's own are none of the builder's business. A save can end after that batch has answered, so
5846
+ /// each is said again once it ends, in a later answer, marked as from earlier.
5847
+ @MainActor
5848
+ enum LampSaves {
5849
+ enum State: Equatable { case saving, saved, canceled, failed(String), notMoved(String) }
5850
+
5851
+ final class Save {
5852
+ let name: String
5853
+ /// The batch that answered its panel.
5854
+ let batch: String
5855
+ var destination: URL?
5856
+ var state = State.saving
5857
+ /// What the builder was last told of it.
5858
+ var told: State?
5859
+ init(name: String, batch: String) { self.name = name; self.batch = batch }
5860
+ }
5861
+
5862
+ /// How long a batch's answer waits for a file it's saving (`Store.use` waits as long beyond the actions).
5863
+ static let wait = 8.0
5864
+ /// The batch running now, if any (`LampDriver.perform` sets it).
5865
+ static var batch: String?
5866
+ private(set) static var all: [Save] = []
5867
+
5868
+ /// A save the batch running now answered the panel for (its Save or Cancel): kept only during a batch, so the person's
5869
+ /// own saves are left out, and a download whose panel came up only after the batch that clicked it had answered is
5870
+ /// still the batch's that answers its panel.
5871
+ static func began(_ name: String) -> Save? {
5872
+ guard let batch else { return nil }
5873
+ let save = Save(name: name, batch: batch)
5874
+ all.append(save)
5875
+ return save
5876
+ }
5877
+
5878
+ static func chose(_ save: Save?, _ url: URL) { save?.destination = url }
5879
+
5880
+ static func ended(_ save: Save?, _ state: State) { save?.state = state }
5881
+
5882
+ /// Whether a save that has somewhere to go is still being written.
5883
+ static var writing: Bool { all.contains { $0.state == .saving && $0.destination != nil } }
5884
+
5885
+ /// What the builder hasn't been told yet, one line per save, for batch `id`; each said is marked told, and one that
5886
+ /// has ended is forgotten. A save still waiting in its panel has nothing to say yet.
5887
+ static func news(for id: String) -> [String] {
5888
+ var lines: [String] = []
5889
+ for save in all where save.told != save.state && !(save.state == .saving && save.destination == nil) {
5890
+ save.told = save.state
5891
+ let earlier = save.batch == id ? "" : " (from an earlier use of the app)"
5892
+ let place = save.destination.map { ($0.path as NSString).abbreviatingWithTildeInPath } ?? "\"\(save.name)\""
5893
+ switch save.state {
5894
+ case .saving: lines.append("Still saving \(place)\(earlier): it isn't finished yet, so it isn't saved yet.")
5895
+ case .saved: lines.append("Saved\(earlier): " + (save.destination.map(describe) ?? place))
5896
+ case .canceled: lines.append("Not saved\(earlier): the save panel for \"\(save.name)\" was canceled.")
5897
+ case .failed(let why): lines.append("Not saved\(earlier): the download of \(place) failed (\(why)).")
5898
+ case .notMoved(let why): lines.append("Not saved\(earlier): the download of \(place) finished but couldn't be put there (\(why)).")
5899
+ }
5900
+ }
5901
+ all.removeAll { $0.told == $0.state && $0.state != .saving }
5902
+ return lines
5903
+ }
5904
+
5905
+ /// The file at `url`: where it is, its size and, for a zip, the names of what it holds (never what's in them).
5906
+ nonisolated static func describe(_ url: URL) -> String {
5907
+ let path = (url.path as NSString).abbreviatingWithTildeInPath
5908
+ guard let handle = try? FileHandle(forReadingFrom: url) else { return "\(path) (Lamp couldn't inspect it)" }
5909
+ defer { try? handle.close() }
5910
+ guard let size = try? handle.seekToEnd() else { return "\(path) (Lamp couldn't inspect it)" }
5911
+ let bytes = size < 1000 ? "\(size) bytes" : ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) + " (\(size) bytes)"
5912
+ return "\(path), \(bytes), " + zip(handle, size: size)
5913
+ }
5914
+
5915
+ /// A zip's list of files is at its end: the end record says where it starts, and each entry there names a file.
5916
+ private nonisolated static func zip(_ handle: FileHandle, size: UInt64) -> String {
5917
+ func read(_ offset: UInt64, _ count: Int) -> [UInt8]? {
5918
+ guard (try? handle.seek(toOffset: offset)) != nil, let data = try? handle.read(upToCount: count), data.count == count else { return nil }
5919
+ return [UInt8](data)
5920
+ }
5921
+ func u16(_ b: [UInt8], _ i: Int) -> Int { Int(b[i]) | Int(b[i + 1]) << 8 }
5922
+ func u32(_ b: [UInt8], _ i: Int) -> UInt64 { UInt64(u16(b, i)) | UInt64(u16(b, i + 2)) << 16 }
5923
+ guard size >= 4, let head = read(0, 4) else { return "not a zip archive" }
5924
+ guard head[0] == 0x50, head[1] == 0x4b, (head[2] == 3 && head[3] == 4) || (head[2] == 5 && head[3] == 6) else {
5925
+ return "not a zip archive"
5926
+ }
5927
+ let unreadable = "it starts like a zip archive but its list of files couldn't be read, so it may be cut short or damaged"
5928
+ let tailCount = Int(min(size, 22 + 65_535))
5929
+ guard let tail = read(size - UInt64(tailCount), tailCount), tailCount >= 22 else { return unreadable }
5930
+ guard let end = stride(from: tailCount - 22, through: 0, by: -1).first(where: {
5931
+ tail[$0] == 0x50 && tail[$0 + 1] == 0x4b && tail[$0 + 2] == 5 && tail[$0 + 3] == 6
5932
+ }) else { return unreadable }
5933
+ let total = u16(tail, end + 10), listSize = u32(tail, end + 12), listStart = u32(tail, end + 16)
5934
+ if total == 0xffff || listStart == 0xffff_ffff { return "a large zip archive whose files Lamp didn't list" }
5935
+ guard listStart + listSize <= size, listSize <= 1 << 20, let list = read(listStart, Int(listSize)) else { return unreadable }
5936
+ var names: [String] = [], at = 0
5937
+ for _ in 0..<total {
5938
+ guard at + 46 <= list.count, list[at] == 0x50, list[at + 1] == 0x4b, list[at + 2] == 1, list[at + 3] == 2 else { return unreadable }
5939
+ let nameLength = u16(list, at + 28), next = at + 46 + nameLength + u16(list, at + 30) + u16(list, at + 32)
5940
+ guard next <= list.count else { return unreadable }
5941
+ if names.count < 12 { names.append(String(String(decoding: list[(at + 46)..<(at + 46 + nameLength)], as: UTF8.self).prefix(100))) }
5942
+ at = next
5943
+ }
5944
+ guard total > 0 else { return "an empty zip archive" }
5945
+ let more = total > names.count ? ", and \(total - names.count) more" : ""
5946
+ return "a zip archive of \(total) \(total == 1 ? "entry" : "entries"): " + names.joined(separator: ", ") + more
5947
+ }
5948
+ }
5949
+ // end LampSaves
5950
+
5906
5951
  /// Lets Lamp's builder use the app the way a person would: it clicks, types, presses keys and picks from
5907
5952
  /// menus in the front window, from inside the app, so no Accessibility permission is needed. An action names
5908
5953
  /// what it's for by `target`, an id or name from the screen as text (LampScreen, written beside each picture),
@@ -6096,7 +6141,15 @@
6096
6141
  let front = LampRuntime.windowsOnScreen.first
6097
6142
  let before = front.flatMap(LampRuntime.picture), dialog = front.map(isDialog) ?? false
6098
6143
  var report: [String] = []
6144
+ // A download the actions set off is this batch's until it answers (`LampSaves`).
6145
+ LampSaves.batch = id
6099
6146
  func answer(stopped: Bool) {
6147
+ // A newer batch, begun meanwhile, says what's saved in its own answer. So does the next one after a stop:
6148
+ // Lamp may stop reading for this answer once it has stopped, and what's saved would be said to no one.
6149
+ if LampSaves.batch == id || LampSaves.batch == nil {
6150
+ LampSaves.batch = nil
6151
+ if !stopped { report += LampSaves.news(for: id) }
6152
+ }
6100
6153
  var answer = ["id": id, "report": report.joined(separator: "\n")]
6101
6154
  if stopped { answer["stopped"] = "person" }
6102
6155
  // Once the pictures are written, which Lamp reads with the answer.
@@ -6140,18 +6193,34 @@
6140
6193
  }
6141
6194
  /// `shown` is where Lamp's cursor was sent for this action during the pause before it, if anywhere;
6142
6195
  /// the spot is found again now that it's due, since the app may have moved it meanwhile.
6196
+ /// A click whose answer the page hasn't given yet (its report line), and the step waiting for it: nothing goes on,
6197
+ /// and the report isn't written, until the page has said what the click landed on, or `unanswered` has passed.
6198
+ var awaiting: (line: Int, resume: (() -> Void)?)?
6143
6199
  func step(_ index: Int, shown: Aim?) {
6144
6200
  guard batch.carriesOn else { return }
6201
+ if awaiting != nil {
6202
+ awaiting?.resume = { step(index, shown: shown) }
6203
+ return
6204
+ }
6145
6205
  keepBarPanel()
6146
6206
  note()
6147
6207
  guard index < actions.count else {
6148
- batch.end()
6149
- // Let the app react, then show the result.
6150
- after(0.6) {
6151
- keepBarPanel()
6152
- report.append(outcome(since: front, before, wasDialog: dialog))
6153
- answer(stopped: false)
6208
+ // Let the app react, then show the result: once a file the actions are saving is written, or up to
6209
+ // `LampSaves.wait` seconds, after which it's said to be still saving. The batch is still running till
6210
+ // then, so Lamp's Stop or the person's own click ends the wait and answers at once (`Handover`).
6211
+ func finish(waited: Double) {
6212
+ // Stopped (it has answered) or replaced by a newer batch.
6213
+ guard batch.carriesOn else { return }
6214
+ guard LampSaves.writing, waited < LampSaves.wait else {
6215
+ batch.end()
6216
+ keepBarPanel()
6217
+ report.append(outcome(since: front, before, wasDialog: dialog))
6218
+ answer(stopped: false)
6219
+ return
6220
+ }
6221
+ after(0.25) { finish(waited: waited + 0.25) }
6154
6222
  }
6223
+ after(0.6) { finish(waited: 0) }
6155
6224
  return
6156
6225
  }
6157
6226
  let action = actions[index]
@@ -6228,7 +6297,45 @@
6228
6297
  goOn(after: Self.settle)
6229
6298
  }
6230
6299
  } else {
6231
- report[line] = run(action)
6300
+ // A click on a page is answered by the page (`LampPageItems.act`), in its own time: the batch
6301
+ // waits for that answer only once `run` has said the click went to a page. Any other click (one
6302
+ // that opens a modal alert or panel, and doesn't return until it closes) never waits, so the
6303
+ // steps that answer the modal still run.
6304
+ var waits = false
6305
+ var early: (said: String, clicked: Bool)?
6306
+ func take(_ said: String, _ clicked: Bool) {
6307
+ // The page's answer in place of "sent"; a click that didn't happen isn't one that should
6308
+ // have changed the screen.
6309
+ report[line] = said
6310
+ if !clicked, last?.line == line { last = nil }
6311
+ }
6312
+ func goOn() {
6313
+ let resume = awaiting?.resume
6314
+ awaiting = nil
6315
+ resume?()
6316
+ }
6317
+ let did = run(action) { said, clicked in
6318
+ guard waits else {
6319
+ if early == nil { early = (said, clicked) }
6320
+ return
6321
+ }
6322
+ guard awaiting?.line == line else { return }
6323
+ take(said, clicked)
6324
+ goOn()
6325
+ }
6326
+ report[line] = did
6327
+ if let early {
6328
+ take(early.said, early.clicked)
6329
+ } else if did.hasSuffix(LampPageItems.sent) {
6330
+ waits = true
6331
+ awaiting = (line, nil)
6332
+ after(Self.unanswered) {
6333
+ guard awaiting?.line == line else { return }
6334
+ report[line] = report[line].replacingOccurrences(of: LampPageItems.sent, with:
6335
+ "the page didn't answer within \(LampScreen.number(Self.unanswered)) s, so whether it was clicked isn't known")
6336
+ goOn()
6337
+ }
6338
+ }
6232
6339
  }
6233
6340
  // A page reads its things again, for the next action's target: ready well within the pause.
6234
6341
  LampPageItems.refresh? {}
@@ -6248,7 +6355,7 @@
6248
6355
  /// How long after an action the next one's spot is looked for: a press is taken up on the run loop's
6249
6356
  /// next turn, so what it opened or changed is on screen by then.
6250
6357
  static let settle = 0.1
6251
- /// How long a game's page has to answer a hold of its controller before the batch goes on without it.
6358
+ /// How long a game's page has to answer a hold of its controller, or a page a click, before the batch goes on without it.
6252
6359
  static let unanswered = 3.0
6253
6360
 
6254
6361
  /// Where an action happens on screen, what it's doing in a few words, which window that's in, and
@@ -6344,7 +6451,9 @@
6344
6451
  "clicked or typed in from here: press return to save there, or escape to cancel."
6345
6452
  }
6346
6453
 
6347
- private static func run(_ action: Action) -> String {
6454
+ /// What `action` did. A click on a page says so later, from the page (`late`, with whether it clicked), once
6455
+ /// the page has said what the click landed on; what's returned then is only that it was sent.
6456
+ private static func run(_ action: Action, late: @escaping @MainActor (String, Bool) -> Void = { _, _ in }) -> String {
6348
6457
  if action.action == "menu" { return menuBar(action.item) }
6349
6458
  if action.action == "bar_icon" { return barIcon(action.item) }
6350
6459
  // A game's controller is held in the game's page (`step`): an app with none has nothing to hold.
@@ -6382,7 +6491,8 @@
6382
6491
  // The panel's own Save button answers to the real keyboard only; end it as that would.
6383
6492
  if let parent = panel.sheetParent { parent.endSheet(panel, returnCode: .OK) } else { NSApp.stopModal(withCode: .OK) }
6384
6493
  panel.orderOut(nil)
6385
- return "pressed return: saved as \(panel.url?.path ?? panel.nameFieldStringValue)"
6494
+ return "pressed return: the save panel accepted \(panel.url?.path ?? panel.nameFieldStringValue) as where to save. " +
6495
+ "That alone doesn't mean the file was written there"
6386
6496
  case ("key", "escape"):
6387
6497
  panel.cancel(nil)
6388
6498
  return "pressed escape: canceled the panel"
@@ -6391,9 +6501,14 @@
6391
6501
  }
6392
6502
  }
6393
6503
  // On a page (LampPage.swift's), the page carries the action out itself.
6394
- if let act = LampPageItems.act, !(window is NSSavePanel), ["click", "double_click", "type", "key"].contains(action.action),
6395
- let did = act(action, action.x != nil && action.y != nil ? point : nil, window) {
6396
- return (what.map { "\(action.action) \($0): " } ?? "\(action.action): ") + did
6504
+ if let act = LampPageItems.act, !(window is NSSavePanel), ["click", "double_click", "type", "key"].contains(action.action) {
6505
+ let label = what.map { "\(action.action) \($0): " }
6506
+ ?? (action.x != nil && action.y != nil && action.action.hasSuffix("click") ? "\(action.action) at \(Int(action.x ?? 0)), \(Int(action.y ?? 0)): " : "\(action.action): ")
6507
+ // A page's own thing (what the accessibility tree has no element for) is the one the click is for.
6508
+ let meant = named.flatMap { $0.element == nil ? $0.item : nil }
6509
+ if let did = act(action, action.x != nil && action.y != nil ? point : nil, meant, window, { late(label + $0, $1) }) {
6510
+ return label + did
6511
+ }
6397
6512
  }
6398
6513
  // A game's own: what isn't on its page (the window's edge, a dialog in front) is left alone. Lamp looks for
6399
6514
  // these words in the app (`Store.gamePageMarker`) before it sends a game's page any click.
@@ -11877,15 +11992,17 @@
11877
11992
  /// The AI proposes calls; Lamp checks their shapes; the app's `check` decides whether each change is allowed now and
11878
11993
  /// its `run` makes it. Reads run first, then changes one at a time, each checked right before it runs; a change that
11879
11994
  /// fails skips the rest of that step. The same change asked twice in a request runs once. A request may take 4 AI
11880
- /// steps between questions, 6 changes in all, 3 questions back, and the route's time (25 seconds over OpenRouter, a
11881
- /// minute through Claude Code, Codex or a model on the Mac); asking something new, cancelling or closing ends it.
11995
+ /// steps between questions, 6 changes in all and 3 questions back, with no time limit unless the app sets one
11996
+ /// (`seconds:`): a real answer is never cut off. Each step stops only when a call has stopped answering
11997
+ /// (`stepSeconds`). Asking something new, cancelling or closing ends it.
11882
11998
  @MainActor final class LampActSession {
11883
11999
  static let maxSteps = 4
11884
12000
  static let maxWrites = 6
11885
12001
  static let maxQuestions = 3
11886
12002
  /// What a session keeps of its changes; past this it stops taking changes and says so, rather than forget them.
11887
12003
  static let maxReceipts = 60
11888
- static let maxSeconds: Double = 60
12004
+ /// How long one step may take before its call is taken to have stopped answering.
12005
+ static let stepSeconds: Double = 600
11889
12006
 
11890
12007
  let about: String
11891
12008
  let tools: [LampActTool]
@@ -11909,11 +12026,16 @@
11909
12026
  private var closed = false
11910
12027
  private weak var running: LampActTurn?
11911
12028
  private let send: ([String: Any], TimeInterval) async throws -> [String: Any]
12029
+ /// The longest a request may take, when the app wants a quick answer; nil for no limit.
12030
+ let seconds: Double?
11912
12031
 
12032
+ /// `seconds` limits each request's time, for a feature that should answer quickly; leave it out for no limit.
11913
12033
  /// `send` stands in for Lamp in a test; leave it out.
11914
- init(about: String = "", tools: [LampActTool], send: (([String: Any], TimeInterval) async throws -> [String: Any])? = nil) {
12034
+ init(about: String = "", tools: [LampActTool], seconds: Double? = nil,
12035
+ send: (([String: Any], TimeInterval) async throws -> [String: Any])? = nil) {
11915
12036
  self.about = about
11916
12037
  self.tools = tools
12038
+ self.seconds = seconds.map { max(1, $0) }
11917
12039
  self.send = send ?? { body, timeout in try await LampAI.post("/ai/act", body, timeout: timeout) }
11918
12040
  }
11919
12041
 
@@ -11977,7 +12099,7 @@
11977
12099
  return out
11978
12100
  }
11979
12101
  let started = Date()
11980
- var deadline = started.addingTimeInterval(Self.maxSeconds)
12102
+ let deadline = seconds.map { started.addingTimeInterval($0) } ?? .distantFuture
11981
12103
  func over() -> LampActResult.Stop? {
11982
12104
  if closed || cancelled.contains(turn) || turn != generation || Task.isCancelled { return .cancelled }
11983
12105
  if Date() >= deadline { return .time }
@@ -11991,7 +12113,7 @@
11991
12113
  var route: String?
11992
12114
  while true {
11993
12115
  if let stop = over() { out.stopped = stop; break }
11994
- let left = deadline.timeIntervalSinceNow
12116
+ let left = min(deadline.timeIntervalSinceNow, Self.stepSeconds)
11995
12117
  if left <= 0.5 { out.stopped = .time; break }
11996
12118
  var body: [String: Any] = ["request": request, "about": about, "now": LampAI.actPresent,
11997
12119
  "tools": tools.map(\.declared), "steps": steps, "seconds": Int(left)]
@@ -12007,10 +12129,6 @@
12007
12129
  out.error = message
12008
12130
  break
12009
12131
  }
12010
- // The route's own time for a turn, counted from its start, shortens it; it never lengthens it.
12011
- if let seconds = (answer["seconds"] as? NSNumber)?.doubleValue, seconds > 0 {
12012
- deadline = min(deadline, started.addingTimeInterval(seconds))
12013
- }
12014
12132
  // Cancelled, out of time or replaced: an answer that comes late runs nothing.
12015
12133
  if let stop = over() { out.stopped = stop; break }
12016
12134
  route = answer["route"] as? String ?? route