@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 +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
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
|
|
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
|
-
|
|
58
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
863
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
879
|
-
guard
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
|
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)
|