@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.
- package/README.md +51 -11
- package/Resources/GameKit/apple/LampPage.swift +202 -35
- package/Resources/GameKit/apple/LampWebGame.swift +1 -1
- package/Resources/GameKit/page/lamp-live.js +672 -0
- package/Resources/GameKit/web/lamp-game.js +7 -1
- package/Resources/share-rules.json +64 -0
- package/Resources/skills/game/SKILL.md +2 -0
- package/Resources/skills/github-projects/SKILL.md +8 -5
- package/Resources/skills/web-app/SKILL.md +49 -15
- package/Resources/skills/web-or-native/SKILL.md +6 -1
- package/cli/ai.mjs +237 -0
- package/cli/app-ai.mjs +473 -0
- package/cli/browser.mjs +18 -2
- package/cli/calls.mjs +213 -9
- package/cli/dev.mjs +61 -14
- package/cli/lamp.mjs +347 -37
- package/cli/lampapp.mjs +9 -9
- package/cli/lamps/android-host/LampPageGateway.kt +2 -2
- package/cli/lamps/lamp-for-games.js +95 -36
- package/cli/lamps/lamp.js +95 -36
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +403 -285
- package/cli/lamps/web-starter/lamp.js +95 -36
- package/cli/link.mjs +77 -13
- package/cli/local-ai.mjs +193 -0
- package/cli/openrouter.mjs +133 -0
- package/cli/outside.mjs +65 -2
- package/cli/page.js +160 -8
- package/cli/share.mjs +80 -12
- package/cli/skill/SKILL.md +35 -10
- package/cli/state.mjs +3 -0
- package/cli/update.mjs +117 -12
- package/package.json +1 -1
- package/site/lib/shared-wishes.js +848 -117
|
@@ -4564,8 +4564,8 @@
|
|
|
4564
4564
|
}
|
|
4565
4565
|
|
|
4566
4566
|
// LampTransition
|
|
4567
|
-
/// While Lamp builds a change for this app,
|
|
4568
|
-
/// goes in
|
|
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
|
|
4649
|
-
///
|
|
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:
|
|
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 {
|
|
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
|
|
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
|
-
///
|
|
4754
|
-
///
|
|
4755
|
-
///
|
|
4756
|
-
///
|
|
4757
|
-
///
|
|
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
|
|
4775
|
-
|
|
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
|
-
///
|
|
4778
|
-
var
|
|
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
|
|
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
|
-
///
|
|
4788
|
-
private static let
|
|
4789
|
-
///
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
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
|
|
4796
|
-
private let
|
|
4797
|
-
private let
|
|
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
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
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
|
|
4834
|
-
|
|
4835
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
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
|
|
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
|
-
///
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
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
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
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
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
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
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
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.
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6149
|
-
//
|
|
6150
|
-
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
6396
|
-
|
|
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
|
|
11881
|
-
///
|
|
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
|
-
|
|
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],
|
|
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
|
-
|
|
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
|