@lampdotdev/lamp 0.35.0 → 0.36.2-nightly.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,10 +16,10 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
16
16
  | Command | What it does |
17
17
  | --- | --- |
18
18
  | `lamp new <folder> [--name "Its Name"] [--web \| --mac]` | Makes a new web game's folder from Lamp's starting files, or with `--web` a new web app's, or with `--mac` a new Mac app in Swift. |
19
- | `lamp init [--update]` | Puts Lamp's game runtime beside a page that's already there, changing none of the page's own files. |
19
+ | `lamp init [--update]` | Puts Lamp's game runtime beside a page that's already there, changing none of the page's own files. `--update` writes again the Lamp files the folder has (`lamp.js` too), adding the game's only to a page that's a game already. |
20
20
  | `lamp unpack <file.lampapp> [folder]` | Unpacks a Lamp app file into a new folder beside it, named for it. A file carries none of Lamp's own files: a page gets them beside it as it's unpacked (the runtime for a game, lamp.js, the libraries its import map names), and a Mac app gets Package.swift, build.sh and LampSupport.swift from Lamp's starting files as it's first built (`writeMacStarter`), where it has none. One with a widget, a share extension or a 3D scene is left to Lamp's app, which writes the files they're built with. |
21
- | `lamp export [--to file] [--anyway]` | Makes the app's Lamp app file as Lamp's Share… does (`Sharing.export`, followed by `cli/lampapp.mjs`): its own files as its kind travels, Assets/, APP.md, what it declares, its credits with the app added to the chain, the licenses they name, its icon's design and lamp-app.json, never Lamp's own files. Refused when a license its credits name is gone or something in it is shaped like a key; `--anyway` sends work its credits give no license for. |
22
- | `lamp dev [--open] [--lan] [--port N]` | Runs the page and says how each change went. Each save goes into every open copy of it. `--open` opens a window for it; `--lan` lets other devices on the network open it. |
21
+ | `lamp export [--to file] [--anyway]` | Makes the app's Lamp app file as Lamp's Share… does (`Sharing.export`, followed by `cli/lampapp.mjs`): its own files as its kind travels, Assets/, APP.md, what it declares, its credits with the app added to the chain, the licenses they name, its icon's design and lamp-app.json, never Lamp's own files. Refused when a license its credits name is gone or something in it is shaped like a key; `--anyway` sends work its credits give no license for. The shapes of keys, the names a license goes by and the sizes another Lamp opens are in `Resources/share-rules.json`, which Lamp's app reads too (`ShareRules`). |
22
+ | `lamp dev [--open] [--lan] [--port N]` | Runs the page and says how each change went. Each save goes into every open copy of it, with the card Lamp's app puts on an app taking a change (`page.js`, left out of lamp's pictures; `lamp changing "<words>"` says on it what's changing). `--open` opens a window for it; `--lan` lets other devices on the network open it. |
23
23
  | `lamp open` | Shows the page in a window, running it in the background if it isn't running, and returns at once. |
24
24
  | `lamp open --in-lamp` | Gives a Mac app to Lamp's app (`cli/outside.mjs`), over the socket the person's own coding agent uses (`OutsideServer`, `OutsideWork`): `open_project` takes the folder on, with Lamp's review the first time, and from then on `look`, `reload` and `dev` call `apply_change`, so a change goes into the open app as Lamp's app puts it there (`LiveReload`) and its outcome is said: live, held, reopened, not_built, crashed. `use` calls `use_app`, in plain sight in the app Lamp's app has open, and `stop` calls `close_app`. A folder Lamp's app no longer has (its refusal says `taken_on: false`) is lamp's own again, said once. `lamp open --here` has lamp run its own copy again. `LAMP_APP` names another Lamp by its bundle id (a Lamp Dev instance). A Mac app `lamp new --mac` makes has the `lamp-app.json` Lamp's app takes a folder on by. |
25
25
  | `lamp look [--reopen] [--wait s] [--json]` | How the changes since the last look went, a picture of the page, its screen as text, new errors and the log. `--reopen` opens the page again from its beginning first. |
@@ -27,12 +27,17 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
27
27
  | `lamp reload [--fresh]` | Opens the page again from its files, a game from where the player is, or with `--fresh` from its beginning (`window.lampFreshStart`). |
28
28
  | `lamp stop` | Stops running it. |
29
29
  | `lamp share --words "…"` | Puts the page on lamp.dev: anyone with the link plays it there and can send an idea. Again, it sends the page as it is now to the same link. |
30
- | `lamp ideas [in <n> \| dismiss <n>]` | The ideas people playing the link have sent, and saying what became of one. |
30
+ | `lamp share` (a page of Lamp's) | A web app or game in a Lamp's own folder is lamp's to share like any other page, Lamp open or not, to the link that Lamp keeps for it: lamp takes the link from the Lamp's `Wish Links.json` (by the app's instance id in `.lamp/meta.json`, `lampsKeptLink`) the first time, and tells it what went after each share or `unshare` (`keepInLamp`), so its Share Link card shows the link as it is. `ideas` and `unshare` take the link the same way. |
31
+ | `lamp share` (any other app of Lamp's) | For a folder in Lamp's own (or a link to one) that lamp doesn't share as a page, shared by Lamp's app over its socket (`share_link`, `OutsideWork`): its Share Link card filled as it fills itself (`WishLinkCards.prepare`: the link's last words, the questions and what's new Lamp writes, the app's file, the GitHub release) and shared without its window, with `--line`, `--by`, `--asks`, `--notes`, `--no-file`, `--no-github`, `--put-on-github` in place of the card's own, and `--preview` to see it first. The link's secret stays in Lamp. Which Lamp is the one whose folder it is (`lampOwning`), or the one it was given to (`lamp open --in-lamp`, `givenTo`); any other folder is lamp's to share, whatever Lamp is running, and so is a given folder when its Lamp is too old to share for lamp. |
32
+ | `lamp ideas [in <n> \| dismiss <n> \| reply <n> \| done <n> \| email <address\|off>] [note]` | The ideas people playing the link have sent and what's been said since, saying what became of one, and answering whoever sent it. |
31
33
  | `lamp unshare` | Takes the link down. |
34
+ | `lamp account [username] [--email address]` | The maker's account on lamp.dev, which their links go under (`lamp.dev/<username>/<app>`): with a username and an email it asks the site for one (`askAccount`), whose key lamp keeps with its state (`account.json`, by site) and sends with every share (`X-Lamp-Account`); the link in the email the site sends confirms it. With only an email that has an account, it signs in to it, with a key of its own. On its own, it says which it's signed in as. A share before the email's link is opened goes out as links always have (`/w/<id>-<name>`), and moves under the username at its next share. |
35
+ | `lamp play <link> [--to folder] [--newest]` | Plays an app shared on lamp.dev on this computer: the link's file (`latestOf`, its `.lampapp`), unpacked as `lamp unpack` does into a folder of its own in `~/Lamp Games` (or `--to`), with Lamp's own files beside its page, then opened as `lamp open` opens any folder. Its AI is answered by `lamp dev` (`ai.mjs`) in any browser, Safari included, where a shared page can't reach the computer. lamp remembers which folder each link went into (a file for each link under `played/` in its state), so playing it again goes on where it was; `--newest` takes the link's latest version into a new folder. |
36
+ | `lamp ai [--forget]` | Answers the AI of pages shared on lamp.dev, played in a browser on this computer, with the person's own Claude Code, Codex or OpenRouter key (`cli/local-ai.mjs`, the same answers as `ai.mjs` gives `lamp dev`), until it's stopped. It listens on this computer alone at a port lamp.js knows (`LOCAL_AI_PORT`, `NEARBY` in lamp.js), and answers only a link's own page (`<id in hex>.play.lamp.dev`, `playOrigin`): the first time a page uses the AI, the person is asked in the terminal, and the page keeps the token it's given. Whether there's an AI is answered without asking. The browser asks the person too, once, before a page may reach this computer, so the link's frame on lamp.dev is given that permission. Safari never lets the page reach it (Safari 27, checked against a stand-in on the port), so lamp.js tells someone playing in Safari to use Chrome, Lamp or `lamp play` instead. `--forget` takes back every page allowed. Lamp's app runs the same server while it's open (`sharedPages` in `calls.mjs`, `SharedPagesAI`): it asks the person in a dialog and answers with the AI they set up in Lamp, and the pages allowed are the same list; whichever of the two starts first answers. |
32
37
  | `lamp devices [add \| forget <name>]` | The person's devices lamp puts a page on: an Xbox in Developer Mode (those set up and those found on the network), and Android (lamp's emulators, and the phones, tablets and TVs plugged in with USB debugging on). `add` sets one up, a step at a time; `add android` downloads Android's tools. |
33
38
  | `lamp open --on xbox` | Packages the page, installs it on the device and opens it there. `lamp look --on xbox` is the device's screen and the app's log there, `lamp stop --on xbox` ends it, and `lamp remove --on xbox` takes it off. |
34
39
  | `lamp open --on android [--hidden]` | Builds Lamp's Android app around the page and opens it in lamp's Android emulator (`--on android-tv`: its TV emulator; a device of the person's by its name), with `look`, `stop` and `remove` as on any device, and `lamp use --on android` to tap, type and press there. `--hidden` starts the emulator with no window. |
35
- | `lamp setup [--remove]` | Teaches Claude Code and Codex on this computer to work this way: a skill for each. |
40
+ | `lamp setup [--remove]` | Teaches Claude Code and Codex on this computer to work this way: a skill for each. Says so when the computer has no browser lamp can drive, which web apps and games are looked at and played in; it's advice, never a failure. |
36
41
  | `lamp guide` | What that skill says, for any other agent. |
37
42
  | `lamp blocks [Name]` | Lamp's building blocks for a Mac app, each with what it's for, or one in full: read from the app's own `Sources/LampSupport.swift`, whose comments say how each is used. |
38
43
  | `lamp skills [word]`, `lamp skill <name>` | Lamp's skills (`Resources/skills`), the one place that says which building blocks to use and how: all of them or those about a word, and one in full, put together from its sections as Lamp's app does (`cli/skills.mjs` follows `Skills.assembled`). |
@@ -46,6 +51,11 @@ runtime, so no Node is needed:
46
51
  /Applications/Lamp.app/Contents/Resources/cli/lamp setup
47
52
  ```
48
53
 
54
+ To type `lamp` anywhere, Lamp's menu has Install Command-Line Tool… (`CommandLineTool`): it links `/usr/local/bin/lamp`
55
+ to that copy, asking for the Mac's administrator password when Lamp can't write there itself, so the command updates
56
+ with Lamp. A build from source or an instance names its own (`Edition.commandName`: `lamp-dev`, `lamp-dev-<instance>`).
57
+ The launcher finds the app it's in through the link (`${0:A:h}` in `cli/lamp.sh`).
58
+
49
59
  On a computer without Lamp, lamp comes from npm, `npm install -g @lampdotdev/lamp` (it needs Node 20), and
50
60
  `lamp setup` sets it up the same way. From a checkout of this repository, `node cli/lamp.mjs setup`.
51
61
 
@@ -54,8 +64,17 @@ The npm package is laid out as the repository is (`scripts/npm-package.sh <versi
54
64
  site's limits for a link, and the Lamp resources lamp reads, with their licenses. A copy npm installed asks npm's
55
65
  registry for a newer one at most once a day, in a process of its own that never holds up the command, and the next
56
66
  command says so in one line, once a day, with the command that updates it (`cli/update.mjs`). It never updates itself.
57
- The copy in Lamp's app updates with Lamp and a checkout with its repository, so neither asks; nor does one in CI, or
58
- with `LAMP_NO_UPDATE_CHECK` set.
67
+ A checkout updates with its repository, so it doesn't ask; nor does a copy in CI, or with `LAMP_NO_UPDATE_CHECK` set.
68
+
69
+ lamp is also published to npm from every push to main, as `nightly` (the release workflow's `cli-nightly` job, at the
70
+ version `scripts/cli-version.sh` gives: `0.36.2-nightly.9` for the ninth commit after 0.36.1), in minutes rather than
71
+ the most of an hour Lamp Nightly takes. The `lamp` that Lamp's app puts on the PATH (`cli/lamp.sh`) follows it: at most
72
+ once an hour a process of its own fetches the newest of its channel (Lamp Nightly's `nightly`, Lamp's `latest`; none for
73
+ Lamp Dev) into `~/.lamp/cli` when it's newer than the app's own copy, checked against npm's integrity hash, and the
74
+ launcher runs that one from then on, until the app has caught up (`~/.lamp/cli/use` names it with the app version it was
75
+ fetched for). Lamp's app stamps its copy with the same numbering (build.sh), so the same commit compares equal. Lamp's
76
+ own calls of lamp (`LampCLI`) always run the app's copy, so the app and what it asks of lamp never differ.
77
+ `lamp --version` says which is running.
59
78
 
60
79
  writes one skill, [`skill/SKILL.md`](skill/SKILL.md), where each agent found on the computer reads its skills
61
80
  (`~/.claude/skills/lamp`, `~/.codex/skills/lamp`), with the command as it's run on this computer filled in. From its
@@ -72,13 +91,17 @@ names its launcher, so the agent always runs the lamp of the Lamp that's install
72
91
 
73
92
  ## How a change goes in
74
93
 
75
- Files are watched; a save is handed to every open page a moment later.
94
+ Files are watched; a save is handed to every open page a moment later. `APP.md` at the page's top is the app's
95
+ notes, never part of the page, so saving it hands nothing over; any other Markdown may be content the page loads, and is a change.
76
96
 
77
97
  - **A game made with Lamp's runtime** (`lamp-game.js` beside the page) takes it the way it does inside Lamp. The
78
98
  runtime is handed the change exactly as Lamp's app around the page hands it over (`window.__lampLive`), so nothing
79
99
  in the game differs. A module that accepts changes is swapped in while the game runs (`live`); other code runs
80
100
  again with the player where they were (`reran`); what can't go in either way opens the page again from the
81
101
  player's place (`reopened`); and a game at a moment it can't be interrupted waits for the person's Reload (`held`).
102
+ - **A game whose page doesn't run on the runtime** (a folder with `lamp-game.js` whose page doesn't load it) takes
103
+ it as it does inside Lamp too, through `lamp-live.js`, given after `page.js`: a stylesheet, or a change inside one
104
+ method of a class a module exports, goes in while the game runs (`live`); anything else waits for Reload (`held`).
82
105
  - **Any other page** opens again.
83
106
 
84
107
  ## Lamp's own files beside a game's page
@@ -92,7 +115,7 @@ What a game's folder lacks all the same, the server gives it:
92
115
  | `lamp-device.js`, `lamp-build.js` | Made by the server: the device is a browser on any device, as a page published to the web is given, and the build is named for the files as they are. |
93
116
  | `lamp-controllers.js` | `cli/lamps/lamp-controllers.js`, the list of controllers as Lamp writes it (`Controllers.pageJS`). `CLIFilesTests` fails when the two differ, and writes the copy again when run with `LAMP_WRITE_CLI_FILES=1`. The game runtime can't load without it. |
94
117
  | `three/`, `phaser/`, `rapier/` | Lamp's copies (`Resources/WebLibraries`), for a library the page asks for and the folder has none of. |
95
- | `lamp.js` | Lamp's building blocks for a page (saved data, the app's files, AI through Lamp's app), as Lamp's own code writes them: `scripts/cli-files.sh` writes the file out (`cli/lamps/lamp.js`, and `lamp-for-games.js` as a game is given it, without a TV's remote; not committed, since they change often), and without it a stand-in that holds nothing. What a block asks of Lamp's app as the page runs (its AI) isn't answered here. |
118
+ | `lamp.js` | Lamp's building blocks for a page (saved data, the app's files, AI through Lamp's app), as Lamp's own code writes them: `scripts/cli-files.sh` writes the file out (`cli/lamps/lamp.js`, and `lamp-for-games.js` as a game is given it, without a TV's remote; not committed, since they change often), and without it a stand-in that holds nothing. Its AI (`LampAI`: status, ask, act, decide) is answered by `lamp dev` itself (`ai.mjs`, through `lampGatewayHost` in `page.js`, `/__lamp/ai`, only for the page on this Mac) with the person's own Claude Code or Codex, or OpenRouter with their key in `OPENROUTER_API_KEY` (`LAMP_AI` picks one: claude, codex or openrouter; `LAMP_AI_MODEL` its model), the engines sealed as Lamp's app seals it (the same run, `complete` in ai.mjs); making pictures and hearing need Lamp's app. On a page shared on lamp.dev, Lamp's app or `lamp ai` answers it instead. |
96
119
 
97
120
  A file the page asks for that neither the folder nor Lamp has is logged as an error, since a module that imports one
98
121
  never runs and the page can't say so itself.
@@ -125,7 +148,10 @@ What it looks at:
125
148
  1. **The window `lamp open` or `lamp dev --open` opened**, when there is one: what the person sees, where they are in it.
126
149
  2. **A copy of the page with no window**, which lamp starts for itself otherwise and closes ten minutes after it was
127
150
  last looked at. It starts where the page starts.
128
- 3. With no browser lamp can drive on the computer, **a page open in any browser** says what's on it, without a picture.
151
+ 3. With no browser lamp can drive on the computer, **a page open in any browser** says what's on it, without a picture,
152
+ and `lamp use` can't act in it. A look with no page open yet waits for one, as long as it's given to wait. `open`,
153
+ `look`, `use` and `setup` each say that Chrome, Edge, Brave or Chromium is what's missing (`noBrowser` in
154
+ `cli/browser.mjs`).
129
155
 
130
156
  The window and the copy are a Chromium-family browser already on the computer (Chrome, Edge, Brave, Chromium;
131
157
  `LAMP_BROWSER` names another), started with files of its own, never the person's own browser profile, and spoken to
@@ -317,7 +343,13 @@ does. A problem says its kind where Lamp's app acts on which it was: a device th
317
343
  search for one target; `probe`, whether a device is there now, as a setup step's check asks; `run`, `open`, `putBack` and `check`, an
318
344
  integration's sequences and a setup's checks on one device, with the values Lamp's app fills in (what the person
319
345
  typed, from its own store, on the call's input) and, while a call waits on the device, an event that says so; `network`, the sweep
320
- of this computer's subnet and every Bonjour service (cli/mdns.mjs); and `neighbours`, the gateway and subnet now.
346
+ of this computer's subnet and every Bonjour service (cli/mdns.mjs); and `neighbours`, the gateway and subnet now. An app's own AI is told and read here too (cli/app-ai.mjs, which `lamp dev`'s answers use as well): `actStep`,
347
+ `actAsk` and `actRead`, a step of `LampAI.act` checked, what its route is asked and its answer read (`AppAct`); and
348
+ `decideAsk` and `decideRead`, a decision answered by a text model (`AppAI.decideByText`). Which route answers and
349
+ asking it stay in Lamp's app, with its keys and its models, except Claude Code and Codex, which `engineAnswer` runs
350
+ (`AppAI.viaEngine`), sealed, with the program and its settings Lamp's app chose, and OpenRouter, which
351
+ `openRouterText`, `openRouterChat`, `openRouterDecide` and `openRouterImage` ask with the person's key (cli/openrouter.mjs; `AppAI.viaOpenRouter`,
352
+ `AppAct.openRouterStep`).
321
353
  Two things stay in Lamp's app, since only it can see them: whether macOS lets Lamp look at all, which is
322
354
  Network.framework's to say (`DeviceProbe.localNetworkDenied`), and the neighbor table, which macOS shows only to a
323
355
  process it lets on the local network, and the runtime Lamp carries isn't one (`Neighbours`). Moving a
@@ -356,3 +388,11 @@ into a Mac app without opening it again.
356
388
  ```bash
357
389
  node --test cli/tests/*.test.mjs
358
390
  ```
391
+
392
+ The nightly Mac checks also pack and install the npm tarball in a temporary folder, then run its command, read its
393
+ guidance and create each kind of starter. To check the package without publishing it, on a Mac:
394
+
395
+ ```bash
396
+ scripts/npm-package.sh 0.0.0
397
+ node scripts/check-npm-package.mjs build/npm/lampdotdev-lamp-0.0.0.tgz
398
+ ```
@@ -129,11 +129,13 @@ final class LampPageGateway: NSObject, WKScriptMessageHandlerWithReply {
129
129
  }
130
130
  return LampControllers.answer(stop: stop, reply)
131
131
  }
132
- Self.ask(url, body: call["body"] as? String) { status, body in replyHandler(["status": status, "body": body], nil) }
132
+ // The page's own wait for an answer (a status check's few seconds, an action step's ten minutes at most).
133
+ let timeout = min(max((call["timeout"] as? NSNumber)?.doubleValue ?? 240_000, 1_000), 600_000) / 1000
134
+ Self.ask(url, body: call["body"] as? String, timeout: timeout) { status, body in replyHandler(["status": status, "body": body], nil) }
133
135
  }
134
136
 
135
- private static func ask(_ url: URL, body: String?, done: @escaping @MainActor @Sendable (Int, String) -> Void) {
136
- var request = URLRequest(url: url, timeoutInterval: 240)
137
+ private static func ask(_ url: URL, body: String?, timeout: TimeInterval, done: @escaping @MainActor @Sendable (Int, String) -> Void) {
138
+ var request = URLRequest(url: url, timeoutInterval: timeout)
137
139
  request.httpMethod = "POST"
138
140
  request.setValue("Bearer " + LampInstall.token, forHTTPHeaderField: "Authorization")
139
141
  request.setValue("application/json", forHTTPHeaderField: "Content-Type")
@@ -686,9 +688,18 @@ final class LampPageFiles: NSObject, WKURLSchemeHandler {
686
688
  private let lock = NSLock()
687
689
  private var changed: [String: URL] = [:]
688
690
  private var gone: Set<String> = []
691
+ private var askedFor: Set<String> = []
689
692
 
690
693
  init(scheme: String) { self.scheme = scheme }
691
694
 
695
+ /// Every file of the page the page asked for since the app opened, there or not: a change says them, so a game's page
696
+ /// that doesn't run on the runtime tells a file it uses from one it doesn't (`LampPageLive.gameScript`), which the
697
+ /// page can't see itself.
698
+ var asked: [String] {
699
+ lock.lock(); defer { lock.unlock() }
700
+ return askedFor.sorted()
701
+ }
702
+
692
703
  func take(changed: [String: URL], gone: Set<String>) {
693
704
  lock.lock(); defer { lock.unlock() }
694
705
  self.changed = changed
@@ -722,6 +733,7 @@ final class LampPageFiles: NSObject, WKURLSchemeHandler {
722
733
  let path = url.path.isEmpty || url.path.hasSuffix("/") ? url.path + "index.html" : url.path
723
734
  let relative = String(path.drop(while: { $0 == "/" }))
724
735
  lock.lock()
736
+ askedFor.insert(relative)
725
737
  let newer = changed[relative], removed = gone.contains(relative)
726
738
  lock.unlock()
727
739
  if let newer { return try? Data(contentsOf: newer) }
@@ -798,13 +810,17 @@ enum LampPageGuard {
798
810
  UserDefaults.standard.set(true, forKey: done)
799
811
  }
800
812
 
813
+ /// The pages held to a policy, which open again when its rules change (`retirePages`).
801
814
  private static let watchers = NSMapTable<WKWebView, LampPageWatcher>.weakToStrongObjects()
815
+ /// Every page's watcher, kept as long as its page.
816
+ private static let kept = NSMapTable<WKWebView, LampPageWatcher>.weakToStrongObjects()
802
817
 
803
- /// Where the page may go, decided for every navigation of its window and frames.
818
+ /// Where the page may go, decided for every navigation of its window and frames, and the files it opens and saves:
819
+ /// every page has this, since a page with no policy still picks files and saves them (`LampPageWatcher`).
804
820
  @MainActor static func watch(_ view: WKWebView, scheme: String) {
805
- guard policy != nil else { return }
806
821
  let watcher = LampPageWatcher(scheme: scheme)
807
- watchers.setObject(watcher, forKey: view)
822
+ kept.setObject(watcher, forKey: view)
823
+ if policy != nil { watchers.setObject(watcher, forKey: view) }
808
824
  view.navigationDelegate = watcher
809
825
  view.uiDelegate = watcher
810
826
  }
@@ -850,33 +866,98 @@ enum LampPageGuard {
850
866
  }
851
867
  }
852
868
 
853
- /// The page's window and frames: only the page's own address, and a link the person clicks opened in the browser once
854
- /// they've seen where it goes.
869
+ /// The page's window and frames: a shared page goes only to its own address, and a link the person clicks opens in the
870
+ /// browser once they've seen where it goes. Any page opens the person's files with a file field and saves what it
871
+ /// downloads (a link with `download`, a Blob it made, a file the web view can't show) where the person says, as a
872
+ /// browser does, so a plain site needs no Swift for either. A download is a request, held to what the page may fetch,
873
+ /// never a navigation: it doesn't take a shared page anywhere.
855
874
  @MainActor final class LampPageWatcher: NSObject, WKNavigationDelegate, WKUIDelegate {
856
875
  private let scheme: String
857
876
  private var asking = false
877
+ /// A shared page's request to one of its sites, let go to see whether it answers with a file, and whether the person
878
+ /// clicked it in the page's window, so a page that comes back instead is offered to the browser as before.
879
+ private var trying: (url: URL, clicked: Bool)?
858
880
  init(scheme: String) { self.scheme = scheme }
859
881
 
860
882
  func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
861
883
  decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
862
- guard let url = action.request.url, let policy = LampPageGuard.current,
863
- !policy.allowsFrame(url, own: LampPageGuard.own(scheme)) else { return decisionHandler(.allow) }
884
+ guard let url = action.request.url else { return decisionHandler(.allow) }
885
+ let policy = LampPageGuard.current, own = LampPageGuard.own(scheme)
886
+ #if os(macOS)
887
+ if action.shouldPerformDownload, policy?.allows(url, own: own) ?? true { return decisionHandler(.download) }
888
+ #endif
889
+ guard let policy, !policy.allowsFrame(url, own: own) else { return decisionHandler(.allow) }
890
+ let clicked = action.targetFrame?.isMainFrame != false && Self.clicked(action)
891
+ #if os(macOS)
892
+ // A link to a file on one of the page's sites (`download` is ignored across origins): the request is one the page
893
+ // may make anyway, and its answer decides, saved if it's a file, never shown (`decidePolicyFor response`). A
894
+ // redirect elsewhere comes back here and is refused.
895
+ if ["http", "https"].contains(url.scheme?.lowercased() ?? ""), policy.allows(url, own: own) {
896
+ trying = (url, clicked)
897
+ return decisionHandler(.allow)
898
+ }
899
+ #endif
864
900
  decisionHandler(.cancel)
901
+ refused(url, clicked: clicked, over: webView)
902
+ }
903
+
904
+ private func refused(_ url: URL, clicked: Bool, over view: WKWebView) {
865
905
  FileHandle.standardError.write(Data("Lamp kept this shared app from opening \(url.absoluteString.prefix(300))\n".utf8))
866
- if action.targetFrame?.isMainFrame != false { offer(url, for: action, over: webView) }
906
+ if clicked { offer(url, over: view) }
907
+ }
908
+
909
+ #if os(macOS)
910
+ /// What the web view can't show (a zip, a file sent as an attachment) is saved rather than shown as nothing. A shared
911
+ /// page's site answering with a page is never shown: that's refused as it would have been before the request.
912
+ func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
913
+ decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) {
914
+ let attachment = ((response.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") ?? "")
915
+ .lowercased().hasPrefix("attachment")
916
+ let file = attachment || !response.canShowMIMEType
917
+ if let url = response.response.url, let policy = LampPageGuard.current,
918
+ !policy.allowsFrame(url, own: LampPageGuard.own(scheme)) {
919
+ let clicked = trying?.url == url && trying?.clicked == true
920
+ trying = nil
921
+ if file { return decisionHandler(.download) }
922
+ decisionHandler(.cancel)
923
+ return refused(url, clicked: clicked, over: webView)
924
+ }
925
+ decisionHandler(file ? .download : .allow)
867
926
  }
868
927
 
869
- /// A link that opens a window of its own (target="_blank"): never a window here.
928
+ func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
929
+ download.delegate = LampPageDownloads.shared
930
+ }
931
+
932
+ func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
933
+ download.delegate = LampPageDownloads.shared
934
+ }
935
+
936
+ /// A file field: the person's choice of files, or folders where the field takes one, as a browser offers them.
937
+ func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo,
938
+ completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void) {
939
+ let panel = NSOpenPanel()
940
+ panel.canChooseFiles = true
941
+ panel.canChooseDirectories = parameters.allowsDirectories
942
+ panel.allowsMultipleSelection = parameters.allowsMultipleSelection
943
+ panel.resolvesAliases = true
944
+ let chosen: (NSApplication.ModalResponse) -> Void = { answer in completionHandler(answer == .OK ? panel.urls : nil) }
945
+ if let window = webView.window { panel.beginSheetModal(for: window, completionHandler: chosen) } else { chosen(panel.runModal()) }
946
+ }
947
+ #endif
948
+
949
+ /// A link that opens a window of its own (target="_blank"): never a window here. Only a shared page offers it to the
950
+ /// browser; the person's own page does with it what it always has, nothing.
870
951
  func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
871
952
  for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
872
- if let url = action.request.url { offer(url, for: action, over: webView) }
953
+ if LampPageGuard.policy != nil, let url = action.request.url, Self.clicked(action) { offer(url, over: webView) }
873
954
  return nil
874
955
  }
875
956
 
876
957
  /// A website the person clicked a link to, opened in the browser once they've said yes to where it goes. Anything
877
958
  /// the page does by itself (setting its address, opening a window, a redirect) goes nowhere.
878
- private func offer(_ url: URL, for action: WKNavigationAction, over view: WKWebView) {
879
- guard action.navigationType == .linkActivated, Self.byPerson(action), !asking,
959
+ private func offer(_ url: URL, over view: WKWebView) {
960
+ guard !asking,
880
961
  let scheme = url.scheme?.lowercased(), ["http", "https", "mailto"].contains(scheme) else { return }
881
962
  asking = true
882
963
  LampPageGuard.ask("Open \(url.host ?? "this website")?", url.absoluteString, button: "Open", over: view) { [weak self] open in
@@ -890,6 +971,9 @@ enum LampPageGuard {
890
971
  }
891
972
  }
892
973
 
974
+ /// A link the person clicked.
975
+ private static func clicked(_ action: WKNavigationAction) -> Bool { action.navigationType == .linkActivated && byPerson(action) }
976
+
893
977
  /// Whether the person's own click or tap started it. WebKit says so only by a name it doesn't publish: where it isn't
894
978
  /// there, a link's being clicked is all there is to go on.
895
979
  private static func byPerson(_ action: WKNavigationAction) -> Bool {
@@ -898,6 +982,63 @@ enum LampPageGuard {
898
982
  }
899
983
  }
900
984
 
985
+ #if os(macOS)
986
+ /// Where a page's download goes: where the person says, in the window it came from, starting in Downloads with the name
987
+ /// the page gave it. Nothing is saved unless they say where.
988
+ @MainActor final class LampPageDownloads: NSObject, WKDownloadDelegate {
989
+ static let shared = LampPageDownloads()
990
+ /// Each download is written beside where it goes, under a hidden name, and takes that place only once it's whole: a
991
+ /// file it replaces is kept if it fails.
992
+ private var places: [ObjectIdentifier: (writing: URL, final: URL)] = [:]
993
+ /// How each download set off by Lamp's builder goes, which `use_app` tells it (`LampSaves` in LampSupport.swift).
994
+ private var saves: [ObjectIdentifier: LampSaves.Save] = [:]
995
+
996
+ func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String,
997
+ completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) {
998
+ let panel = NSSavePanel()
999
+ panel.nameFieldStringValue = suggestedFilename
1000
+ panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
1001
+ panel.canCreateDirectories = true
1002
+ let chosen: (NSApplication.ModalResponse) -> Void = { answer in
1003
+ // Kept from when its panel is answered, whenever the download began (`LampSaves.began`).
1004
+ let save = LampSaves.began(suggestedFilename)
1005
+ guard answer == .OK, let url = panel.url else {
1006
+ LampSaves.ended(save, .canceled)
1007
+ return completionHandler(nil)
1008
+ }
1009
+ LampSaves.chose(save, url)
1010
+ if let save { self.saves[ObjectIdentifier(download)] = save }
1011
+ let writing = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).\(UUID().uuidString).download")
1012
+ self.places[ObjectIdentifier(download)] = (writing, url)
1013
+ completionHandler(writing)
1014
+ }
1015
+ if let window = download.webView?.window { panel.beginSheetModal(for: window, completionHandler: chosen) } else { chosen(panel.runModal()) }
1016
+ }
1017
+
1018
+ func downloadDidFinish(_ download: WKDownload) {
1019
+ let save = saves.removeValue(forKey: ObjectIdentifier(download))
1020
+ guard let place = places.removeValue(forKey: ObjectIdentifier(download)) else { return }
1021
+ let files = FileManager.default
1022
+ do {
1023
+ // The save panel asked before replacing a file of that name.
1024
+ if files.fileExists(atPath: place.final.path) { _ = try files.replaceItemAt(place.final, withItemAt: place.writing) }
1025
+ else { try files.moveItem(at: place.writing, to: place.final) }
1026
+ LampSaves.ended(save, .saved)
1027
+ } catch {
1028
+ try? files.removeItem(at: place.writing)
1029
+ LampSaves.ended(save, .notMoved(error.localizedDescription))
1030
+ FileHandle.standardError.write(Data("A download couldn't be saved: \(error.localizedDescription)\n".utf8))
1031
+ }
1032
+ }
1033
+
1034
+ func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
1035
+ if let place = places.removeValue(forKey: ObjectIdentifier(download)) { try? FileManager.default.removeItem(at: place.writing) }
1036
+ LampSaves.ended(saves.removeValue(forKey: ObjectIdentifier(download)), .failed(error.localizedDescription))
1037
+ FileHandle.standardError.write(Data("A download failed: \(error.localizedDescription)\n".utf8))
1038
+ }
1039
+ }
1040
+ #endif
1041
+
901
1042
  /// What the page was refused, from Lamp's own world in it: into the app's log, and, for a page whose review said it may
902
1043
  /// connect elsewhere, shown above it with a way to allow the site.
903
1044
  @MainActor final class LampPageRefused: NSObject, WKScriptMessageHandler {
@@ -1150,8 +1291,10 @@ typealias LampPageHolderBase = UIView
1150
1291
  /// The web view the page is shown in: its sound and pictures without a tap first, nothing to scroll or bounce.
1151
1292
  @MainActor enum LampPageView {
1152
1293
  /// A page that logs its own errors (a game's runtime does) leaves `catchErrors` off, or each would be logged twice.
1153
- /// `live` takes Lamp's changes while the app is open (`LampPageLive`).
1154
- static func make(_ files: LampPageFiles, catchErrors: Bool = true, live: Bool = false) -> WKWebView {
1294
+ /// `live` takes Lamp's changes while the app is open (`LampPageLive`). `gameChanges`, a game's page: one that doesn't
1295
+ /// run on Lamp's game runtime still takes a change while it's played where it can (`LampPageLive.gameScript`), where
1296
+ /// any other page opens again for it.
1297
+ static func make(_ files: LampPageFiles, catchErrors: Bool = true, live: Bool = false, gameChanges: Bool = false) -> WKWebView {
1155
1298
  let configuration = WKWebViewConfiguration()
1156
1299
  configuration.setURLSchemeHandler(files, forURLScheme: files.scheme)
1157
1300
  // LampAI and the page's calls of this device, and its console into the app's log.
@@ -1160,6 +1303,10 @@ typealias LampPageHolderBase = UIView
1160
1303
  LampPageGuard.attach(to: configuration, scheme: files.scheme)
1161
1304
  let changes = live ? LampPageLive(files: files) : nil
1162
1305
  if let changes { configuration.userContentController.add(changes, contentWorld: .page, name: "lampLive") }
1306
+ if changes != nil, gameChanges {
1307
+ configuration.userContentController.addUserScript(WKUserScript(source: LampPageLive.gameScript, injectionTime: .atDocumentStart,
1308
+ forMainFrameOnly: true, in: .page))
1309
+ }
1163
1310
  configuration.mediaTypesRequiringUserActionForPlayback = []
1164
1311
  #if os(iOS)
1165
1312
  configuration.allowsInlineMediaPlayback = true
@@ -1217,6 +1364,8 @@ typealias LampPageHolderBase = UIView
1217
1364
  /// What each window's pages held when last read, by the window's number.
1218
1365
  private static var read: [Int: [LampScreen.Item]] = [:]
1219
1366
  private static let script = "{{PAGE_SCREEN_SCRIPT}}"
1367
+ /// A click that says what it landed on, and won't click a thing named that's dimmed or under another (`PageScreen.click`).
1368
+ private static let click = "{{PAGE_CLICK_SCRIPT}}"
1220
1369
 
1221
1370
  /// The page shown in a window, or, with none there, the first shown anywhere: what the app menu's Reload reopens.
1222
1371
  static func page(in window: NSWindow?) -> WKWebView? {
@@ -1228,7 +1377,7 @@ typealias LampPageHolderBase = UIView
1228
1377
  pages.add(view)
1229
1378
  LampPageItems.refresh = refresh
1230
1379
  LampPageItems.items = { read[$0.windowNumber] ?? [] }
1231
- LampPageItems.act = act
1380
+ LampPageItems.act = { act($0, at: $1, meant: $2, in: $3, said: $4) }
1232
1381
  LampPageItems.own = { own($0, batch: $1) }
1233
1382
  LampPageItems.hold = { hold($0, in: $1, for: $2, said: $3) }
1234
1383
  }
@@ -1302,17 +1451,23 @@ typealias LampPageHolderBase = UIView
1302
1451
  ]
1303
1452
 
1304
1453
  /// A click, a key or typing on a page, carried out in the page: the point (in the window's frame view) turned into
1305
- /// the page's own, or, with none, whatever the page has focused while it holds the window's keyboard.
1306
- private static func act(_ action: LampDriver.Action, at point: NSPoint?, in window: NSWindow) -> String? {
1454
+ /// the page's own, or, with none, whatever the page has focused while it holds the window's keyboard. A click's
1455
+ /// outcome is the page's answer, given to `said` once it comes (with whether it clicked): what it landed on, and for
1456
+ /// the thing `meant` (one of the page's own, as listed), whether it was that thing at all.
1457
+ private static func act(_ action: LampDriver.Action, at point: NSPoint?, meant: LampScreen.Item?, in window: NSWindow,
1458
+ said: @escaping @MainActor (String, Bool) -> Void) -> String? {
1307
1459
  guard let frame = window.contentView?.superview ?? window.contentView else { return nil }
1308
1460
  let shown = pages.allObjects.filter { $0.window === window }
1309
1461
  let page: WKWebView, spot: String
1462
+ var css = CGPoint.zero, zoom: CGFloat = 1
1310
1463
  if let point {
1311
1464
  guard let hit = shown.first(where: { $0.frame.contains($0.superview?.convert(point, from: frame) ?? .zero) }) else { return nil }
1312
- let local = hit.convert(point, from: frame), zoom = max(hit.pageZoom, 0.01)
1465
+ let local = hit.convert(point, from: frame)
1466
+ zoom = max(hit.pageZoom, 0.01)
1313
1467
  let y = hit.isFlipped ? local.y : hit.bounds.height - local.y
1314
1468
  page = hit
1315
- spot = "document.elementFromPoint(\(local.x / zoom), \(y / zoom))"
1469
+ css = CGPoint(x: local.x / zoom, y: y / zoom)
1470
+ spot = "document.elementFromPoint(\(css.x), \(css.y))"
1316
1471
  } else {
1317
1472
  guard let focused = shown.first(where: { page in
1318
1473
  (window.firstResponder as? NSView).map { $0 === page || $0.isDescendant(of: page) } ?? false
@@ -1325,15 +1480,22 @@ typealias LampPageHolderBase = UIView
1325
1480
  case "click", "double_click":
1326
1481
  guard point != nil else { return nil }
1327
1482
  let clicks = action.action == "click" ? 1 : 2
1328
- run("""
1329
- var focusable = el.closest('[data-focus], button, a[href], input, select, textarea, [tabindex]') || el;
1330
- if (focusable.focus) focusable.focus();
1331
- for (var i = 0; i < \(clicks); i++) if (focusable.click) focusable.click();
1332
- if (\(clicks) === 2) focusable.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
1333
- """)
1334
- // Sent, not proof: a game takes a click in its own time, and may not take it at all.
1335
- if action.page == true { return (clicks == 1 ? "clicked" : "double-clicked") + " in the game's page; the picture shows whether the game took it" }
1336
- return clicks == 1 ? "clicked in the page" : "double-clicked in the page"
1483
+ // The thing as the page's read listed it, with its box in the page's own pixels: the page clicks that very
1484
+ // thing, once it's checked it's still there, at its middle (the point).
1485
+ let scale = LampRuntime.scale(of: window)
1486
+ let want = meant.map { "{ ref: \($0.ref ?? -1), w: \($0.frame.width / scale / zoom), h: \($0.frame.height / scale / zoom) }" } ?? "null"
1487
+ page.evaluateJavaScript(click + "(\(css.x), \(css.y), \(want), \(clicks))") { answer, error in
1488
+ MainActor.assumeIsolated {
1489
+ guard error == nil, let json = answer as? String,
1490
+ let told = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any], let words = told["said"] as? String else {
1491
+ return said("the page couldn't take the click (\(error?.localizedDescription ?? "it gave no answer")), so it may not have happened", false)
1492
+ }
1493
+ let did = told["did"] as? Bool ?? false
1494
+ // Sent, not proof: a game takes a click in its own time, and may not take it at all.
1495
+ said(words + (did && action.page == true ? "; the picture shows whether the game took it" : ""), did)
1496
+ }
1497
+ }
1498
+ return LampPageItems.sent
1337
1499
  case "type":
1338
1500
  let text = action.text ?? ""
1339
1501
  let quoted = String(decoding: (try? JSONSerialization.data(withJSONObject: [text])) ?? Data("[\"\"]".utf8), as: UTF8.self)
@@ -1423,7 +1585,7 @@ typealias LampPageHolderBase = UIView
1423
1585
  let role = ["button": "button", "field": "text field", "choice": "choice", "image": "picture"][kind] ?? "text"
1424
1586
  return LampScreen.Item(role: role, label: entry["name"] as? String ?? "", value: entry["value"] as? String ?? "",
1425
1587
  frame: CGRect(x: (box.minX + n("x")) * scale, y: (top + n("y")) * scale, width: n("w") * scale, height: n("h") * scale),
1426
- selected: entry["focused"] as? Bool ?? false)
1588
+ selected: entry["focused"] as? Bool ?? false, ref: (entry["ref"] as? NSNumber)?.intValue)
1427
1589
  }
1428
1590
  }
1429
1591
  }
@@ -1435,7 +1597,8 @@ typealias LampPageHolderBase = UIView
1435
1597
  /// there's a change, or after a while with nothing), fetches the files that differ from its bundle into its caches, has
1436
1598
  /// `LampPageFiles` serve them in place of the bundle's, and tells the page (`window.__lampLive` in lamp-game.js), which
1437
1599
  /// puts new pictures, models, sounds and the code its modules take in as it runs, and opens itself again for code they
1438
- /// don't, keeping the player's place.
1600
+ /// don't, keeping the player's place. A game's page that doesn't run on the runtime is told by `gameScript`, which puts
1601
+ /// in what it safely can and holds the rest for Reload; any other page opens again.
1439
1602
  /// What the page did is told back to Lamp, which says so in the chat. The files are kept for the next launch of this
1440
1603
  /// same bundle, so the app opens as Lamp last changed it; a new bundle (Lamp installed the app again) starts from its own.
1441
1604
  @MainActor final class LampPageLive: NSObject, WKScriptMessageHandler {
@@ -1444,6 +1607,9 @@ typealias LampPageHolderBase = UIView
1444
1607
  private(set) weak static var current: LampPageLive?
1445
1608
  #endif
1446
1609
  private let files: LampPageFiles
1610
+ /// A game's page that doesn't run on Lamp's game runtime takes changes with this (lamp-live.js, written in by Lamp),
1611
+ /// given to it before any script of its own; the runtime, as it loads, takes over from it.
1612
+ static let gameScript = "{{PAGE_LIVE_SCRIPT}}"
1447
1613
  private weak var page: WKWebView?
1448
1614
  private var epoch = "", gen = 0
1449
1615
  private var have: [String: String] = [:]
@@ -1557,10 +1723,11 @@ typealias LampPageHolderBase = UIView
1557
1723
  let pageChanged = answer["changed"] as? [String] ?? []
1558
1724
  // Only the phone page: the game's own page has nothing to do.
1559
1725
  if pageChanged.isEmpty, !now { tell(next, "phone"); return }
1560
- let change: [String: Any] = ["gen": next, "changed": pageChanged, "removed": answer["removed"] as? [String] ?? [], "now": now, "pill": Self.place != "mac"]
1726
+ let change: [String: Any] = ["gen": next, "changed": pageChanged, "removed": answer["removed"] as? [String] ?? [], "now": now, "pill": Self.place != "mac",
1727
+ "read": files.asked]
1561
1728
  guard let page, let json = try? JSONSerialization.data(withJSONObject: change) else { return }
1562
1729
  let taken = try? await page.evaluateJavaScript("window.__lampLive ? window.__lampLive(\(String(decoding: json, as: UTF8.self))) : false", in: nil, contentWorld: .page)
1563
- // A page without Lamp's game runtime is opened again as it is.
1730
+ // A page that takes no changes (a web app's, a screen's) is opened again as it is.
1564
1731
  if taken as? Bool != true {
1565
1732
  page.reload()
1566
1733
  tell(next, "reloading")
@@ -50,7 +50,7 @@ struct LampGameScreen: View {
50
50
  @MainActor enum LampGamePage {
51
51
  static let files = LampPageFiles(scheme: "lamp-game")
52
52
 
53
- static func make() -> WKWebView { LampPageView.make(files, catchErrors: false, live: true) }
53
+ static func make() -> WKWebView { LampPageView.make(files, catchErrors: false, live: true, gameChanges: true) }
54
54
  }
55
55
 
56
56
  #if os(macOS)