@lampdotdev/lamp 0.36.2-nightly.17 → 0.36.2-nightly.21
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 +5 -2
- package/Resources/skills/app-ai/SKILL.md +1 -1
- package/Resources/skills/web-app/SKILL.md +4 -1
- package/THIRD_PARTY_NOTICES.md +0 -26
- package/cli/app-ai.mjs +6 -8
- package/cli/calls.mjs +2 -2
- package/cli/lampapp.mjs +27 -3
- package/cli/lamps/lamp-for-games.js +60 -13
- package/cli/lamps/lamp.js +60 -13
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -1
- package/cli/lamps/web-starter/lamp.js +60 -13
- package/cli/share.mjs +14 -6
- package/package.json +1 -1
- package/site/lib/shared-wishes.js +22 -7
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
|
|
|
33
33
|
| `lamp unshare` | Takes the link down. |
|
|
34
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
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. |
|
|
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; lamp.js reaches the port only once that's granted, the page has a token, or the person asks (`LampAI.connect()` from a button, or a real AI call), so the browser doesn't ask as the page loads. 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. |
|
|
37
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. |
|
|
38
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. |
|
|
39
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. |
|
|
@@ -222,7 +222,10 @@ of the link's own.
|
|
|
222
222
|
errors isn't shared without `--anyway`.
|
|
223
223
|
- **The link's secret**, which is what lets it be changed or taken down, is kept with lamp's state for the folder
|
|
224
224
|
(`link.json`, readable only by the person), never in the folder.
|
|
225
|
-
- **
|
|
225
|
+
- **The app's file** goes too (`exportApp`, as `lamp export` makes it), as Lamp's Share Link card sends it, unless
|
|
226
|
+
`--no-file`. Its own place on its credits' chain names the link it went up with (`link`, `lamp.dev/w/<id>`).
|
|
227
|
+
- **Where it came from:** a copy of an app someone shared (`lamp play`, `lamp unpack`) has their link on its chain, so
|
|
228
|
+
its own share names that link (`from`, `sourceLinkOf`), and its page says "from" them.
|
|
226
229
|
- **`LAMP_SITE`** names another site to share to, for trying this against a copy of the site (`npm run dev` in
|
|
227
230
|
`site/` keeps links in memory). The tests run against the site's handlers the same way, and never reach lamp.dev.
|
|
228
231
|
|
|
@@ -86,7 +86,7 @@ else { status = result.summary }
|
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
- Show the feature where `await LampAI.status(.act)` says it's available, with what answers and where its `processing`
|
|
89
|
-
is (`remote` for a service, Claude Code and Codex included
|
|
89
|
+
is (`remote` for a service, Claude Code and Codex included). When it isn't, keep
|
|
90
90
|
the feature visible with its `reason` and a "Set up in Lamp" button (`LampAI.openSetup(for: .act)`), or "Open Lamp" /
|
|
91
91
|
"Update Lamp" when `setup` says so; keep the typed draft, and every manual control working. Check again when the app
|
|
92
92
|
comes back to the front, and never send the draft by itself after setup. Available is no guarantee: show the actual
|
|
@@ -20,7 +20,10 @@ opening comment for the whole list. What it gives:
|
|
|
20
20
|
on it, check `LampAI.status('text')` and, when it can't answer, show its `reason` as it's given: lamp.js words it
|
|
21
21
|
for where the page is (away from the Mac's Wi‑Fi, Lamp closed, a link on lamp.dev), so don't write your own. Where it
|
|
22
22
|
gives `help` (a page away from Lamp), add a link to it beside the reason, "How to turn on AI", so someone who has never
|
|
23
|
-
heard of Lamp finds out how.
|
|
23
|
+
heard of Lamp finds out how. Where it answers `connect: true` (a page on lamp.dev that hasn't reached this computer
|
|
24
|
+
yet), also show a small "Connect AI" button that calls `LampAI.connect(capability)` with the same kind of AI you asked
|
|
25
|
+
`status` about (`LampAI.connect('pictures')` for pictures) and use the answer it gives as `status`'s;
|
|
26
|
+
never call `connect()` as the page loads, since it makes the browser ask the person.
|
|
24
27
|
- **LampAI.act**: the person asks the app to do something in their own words, and the app's own reads and writes do
|
|
25
28
|
it; "Doing things for the person" below, with **LampVoice** and **LampAI.hear** for saying it.
|
|
26
29
|
- **LampStore**: what the app saves, as JSON on the device. Once something is saved, only ever add to it: a new field
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -28,32 +28,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
28
28
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
29
|
SOFTWARE.
|
|
30
30
|
|
|
31
|
-
## llama.cpp
|
|
32
|
-
|
|
33
|
-
https://github.com/ggml-org/llama.cpp (llama-server and its libraries)
|
|
34
|
-
|
|
35
|
-
MIT License
|
|
36
|
-
|
|
37
|
-
Copyright (c) 2023-2026 The ggml authors
|
|
38
|
-
|
|
39
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
40
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
41
|
-
in the Software without restriction, including without limitation the rights
|
|
42
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
43
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
44
|
-
furnished to do so, subject to the following conditions:
|
|
45
|
-
|
|
46
|
-
The above copyright notice and this permission notice shall be included in all
|
|
47
|
-
copies or substantial portions of the Software.
|
|
48
|
-
|
|
49
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
50
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
51
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
52
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
53
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
54
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
55
|
-
SOFTWARE.
|
|
56
|
-
|
|
57
31
|
## Lobe Icons
|
|
58
32
|
|
|
59
33
|
https://github.com/lobehub/lobe-icons (`@lobehub/icons-static-png`, the provider logos in Settings)
|
package/cli/app-ai.mjs
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// page's.
|
|
12
12
|
//
|
|
13
13
|
// Every route is asked one of two ways and read into the same calls-or-reply: a chat with OpenAI-style tools
|
|
14
|
-
// (OpenRouter
|
|
15
|
-
//
|
|
14
|
+
// (OpenRouter: `chatBody`, `readMessage`), or one prompt whose answer must fit the step's JSON shape (Claude Code, Codex:
|
|
15
|
+
// `planPrompt`, `planSchema`, `readPlan`).
|
|
16
16
|
|
|
17
17
|
export const MAX_TOOLS = 24, MAX_CALLS = 8, MAX_STEPS = 4, MAX_BODY = 256 * 1024, MAX_CHOICES = 4, MAX_CHOICE = 24;
|
|
18
18
|
/** The longest a step may take, whatever its route, before its call is taken to have stopped answering (`ACT_STEP_SECONDS`
|
|
@@ -320,8 +320,8 @@ function framing(r, tag) {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
/**
|
|
323
|
-
* An OpenAI-style chat with tools, Lamp's `reply` among them, for OpenRouter
|
|
324
|
-
*
|
|
323
|
+
* An OpenAI-style chat with tools, Lamp's `reply` among them, for OpenRouter, whose `model` it names:
|
|
324
|
+
* `require_parameters` keeps OpenRouter on providers that take tools and `tool_choice` as
|
|
325
325
|
* given, and the models offered for apps' text take only "auto" and "none" there, so an answer may also be words without
|
|
326
326
|
* a call, which is the reply; a step asked for a reply only says "none".
|
|
327
327
|
*/
|
|
@@ -369,15 +369,13 @@ export function planSchema(r) {
|
|
|
369
369
|
|
|
370
370
|
/**
|
|
371
371
|
* The step as one prompt, for a route that takes text and answers in `planSchema`'s shape: the tools described, then the
|
|
372
|
-
* turn so far, every earlier call and its result fenced as outside text.
|
|
373
|
-
* for a model nothing holds to it.
|
|
372
|
+
* turn so far, every earlier call and its result fenced as outside text.
|
|
374
373
|
*/
|
|
375
|
-
export function planPrompt(r, tag, retry
|
|
374
|
+
export function planPrompt(r, tag, retry) {
|
|
376
375
|
let { system, user: prompt } = framing(r, tag);
|
|
377
376
|
system += "\n\n" + PLAN;
|
|
378
377
|
if (r.final) system += '\nOnly a reply is possible now: answer {"reply": {...}}.';
|
|
379
378
|
else system += "\n\nThe app's tools:\n" + r.tools.map((tool) => `- ${tool.name}: ${tool.reads ? "Looks up, changes nothing. " : "Changes the app's data. "}${tool.about}\n Arguments: ${json(toolSchema(tool))}`).join("\n");
|
|
380
|
-
if (schemaInPrompt) system += "\n\nThe JSON Schema your answer must fit:\n" + json(planSchema(r));
|
|
381
379
|
r.steps.forEach((step, i) => {
|
|
382
380
|
prompt += `\n\nYour answer ${i + 1} called:\n` + json(step.calls.map((c) => ({ name: c.name, args: c.args })));
|
|
383
381
|
prompt += "\nWhat happened, in that order:\n" + fence(tag, json(step.calls.map((c) => ({ ...(step.results[c.id] ?? skipped), name: c.name }))));
|
package/cli/calls.mjs
CHANGED
|
@@ -423,12 +423,12 @@ export const CALLS = {
|
|
|
423
423
|
|
|
424
424
|
/**
|
|
425
425
|
* What a step's route is asked: `way` "chat" is a chat with tools (`{body}`, OpenRouter's own when `model` names one),
|
|
426
|
-
* "plan" one prompt held to the step's shape (`{system, prompt, schema}`)
|
|
426
|
+
* and "plan" one prompt held to the step's shape (`{system, prompt, schema}`).
|
|
427
427
|
*/
|
|
428
428
|
async actAsk(given) {
|
|
429
429
|
const step = given.step, tag = text(given, "tag"), retry = typeof given.retry === "string" ? given.retry : null;
|
|
430
430
|
if (given.way === "chat") return { body: chatBody(step, tag, retry, typeof given.model === "string" ? given.model : undefined) };
|
|
431
|
-
return { ...planPrompt(step, tag, retry
|
|
431
|
+
return { ...planPrompt(step, tag, retry), schema: planSchema(step) };
|
|
432
432
|
},
|
|
433
433
|
|
|
434
434
|
/** A step's answer read and checked: `message` from a chat with tools, or `plan`'s text. `{answer}` or `{problem}`. */
|
package/cli/lampapp.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
|
|
|
8
8
|
import { inflateRawSync } from "node:zlib";
|
|
9
9
|
import { Problem, RESOURCES, manifestOf, projectOf, stateOf } from "./state.mjs";
|
|
10
10
|
import { LIMITS, nameOf, zipOf } from "./share.mjs";
|
|
11
|
+
import { isID } from "../site/lib/shared-wishes.js";
|
|
11
12
|
|
|
12
13
|
// What a Lamp app file may hold, written once in Resources/share-rules.json, which Lamp's app reads too (`ShareRules`):
|
|
13
14
|
// the sizes another Lamp opens, the names a license goes by, and the shapes of keys a shared file never carries.
|
|
@@ -214,9 +215,10 @@ function identityOf(folder) {
|
|
|
214
215
|
* and lamp-app.json, which says what it is. Never Lamp's own files, which the Mac that opens it writes, nor anything
|
|
215
216
|
* hidden. Refused when a license its credits name is gone, when something in it is shaped like a key, or when it's
|
|
216
217
|
* more than another Lamp opens. Work its credits give no license for goes only with `anyway`: its author may not want
|
|
217
|
-
* it passed on.
|
|
218
|
+
* it passed on. `link` is the lamp.dev link the file goes up with (`share`), which this app's place in the chain carries,
|
|
219
|
+
* so a copy of it shared on can say which link it came from (`sourceLinkOf`).
|
|
218
220
|
*/
|
|
219
|
-
export function exportApp(given, { anyway = false } = {}) {
|
|
221
|
+
export function exportApp(given, { anyway = false, link = null } = {}) {
|
|
220
222
|
const project = projectOf(given), folder = project.folder, manifest = manifestOf(folder);
|
|
221
223
|
// Where the app's own record is (its credits, licenses, notes, files and the Swift beside its page): the folder, or
|
|
222
224
|
// for a site laid out as Lamp puts one on GitHub, `.lamp-wish/` beside the page (`StaticSite.layOut`).
|
|
@@ -291,7 +293,7 @@ export function exportApp(given, { anyway = false } = {}) {
|
|
|
291
293
|
|
|
292
294
|
// Whoever opens the file is remixing this app: it's added to the chain its credits carry, as one link if it's
|
|
293
295
|
// already the last (`Credits.remixed`).
|
|
294
|
-
const me = { name, wish: identityOf(folder) }, chain = Array.isArray(credits?.remixOf) ? credits.remixOf.filter((link) => link && typeof link.name === "string") : [];
|
|
296
|
+
const me = { name, wish: identityOf(folder), ...(link ? { link } : {}) }, chain = Array.isArray(credits?.remixOf) ? credits.remixOf.filter((link) => link && typeof link.name === "string") : [];
|
|
295
297
|
const last = chain.at(-1);
|
|
296
298
|
if (last && (last.wish ? last.wish === me.wish : !last.starter && last.name === me.name)) chain[chain.length - 1] = { ...last, ...me, url: last.url, commit: last.commit };
|
|
297
299
|
else chain.push(me);
|
|
@@ -324,6 +326,28 @@ export function exportApp(given, { anyway = false } = {}) {
|
|
|
324
326
|
return { name, kind, bytes, files: files.size };
|
|
325
327
|
}
|
|
326
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The id of the link on `site` that the app in the folder came from, for a share to name (`from`): the last app in its
|
|
331
|
+
* credits' chain that went up with a link there (`exportApp`'s `link`), other than `own`, the folder's own link. Null for
|
|
332
|
+
* an app that came from no link. An unpacked copy keeps the app's identity, so its first share replaces that entry's
|
|
333
|
+
* link in the file it sends; the folder's own credits still name the one it came from.
|
|
334
|
+
*/
|
|
335
|
+
export function sourceLinkOf(folder, site, own = null) {
|
|
336
|
+
const app = existsSync(join(folder, ".lamp-wish", "lamp-app.json")) ? join(folder, ".lamp-wish") : folder;
|
|
337
|
+
let chain;
|
|
338
|
+
try { chain = JSON.parse(readFileSync(join(app, "credits.json"), "utf8"))?.remixOf; } catch { return null; }
|
|
339
|
+
if (!Array.isArray(chain)) return null;
|
|
340
|
+
let origin;
|
|
341
|
+
try { origin = new URL(site).origin; } catch { return null; }
|
|
342
|
+
for (const entry of [...chain].reverse()) {
|
|
343
|
+
let url;
|
|
344
|
+
try { url = new URL(entry?.link); } catch { continue; }
|
|
345
|
+
const id = /^\/w\/([^/]+)$/.exec(url.pathname)?.[1];
|
|
346
|
+
if (url.origin === origin && isID(id) && id !== own) return id;
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
327
351
|
/** An object with its keys in order, at every level, as Lamp's own encoder writes its files. */
|
|
328
352
|
function sortedKeys(value) {
|
|
329
353
|
if (Array.isArray(value)) return value.map(sortedKeys);
|
|
@@ -254,6 +254,26 @@
|
|
|
254
254
|
var ua = (window.navigator && navigator.userAgent) || '';
|
|
255
255
|
return /Safari\//.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS/.test(ua);
|
|
256
256
|
}
|
|
257
|
+
var SAFARI_AI = 'AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.';
|
|
258
|
+
|
|
259
|
+
// Reaching this computer makes the browser ask the person about it, so a shared page doesn't reach out just to
|
|
260
|
+
// see whether there's an AI: only once the browser already lets it, the page has paired before, or the person
|
|
261
|
+
// has asked for it (LampAI.connect, from a button, or a real AI call).
|
|
262
|
+
var nearbyAsked = false;
|
|
263
|
+
function nearbyAllowed() {
|
|
264
|
+
// Connected before in this browser (kept with the page's saved things): the browser has had its say already.
|
|
265
|
+
if (nearbyAsked || read('nearby.token', null) || read('nearby.connected', false)) return Promise.resolve(true);
|
|
266
|
+
var permissions = window.navigator && navigator.permissions;
|
|
267
|
+
if (!permissions || typeof permissions.query !== 'function') return Promise.resolve(false);
|
|
268
|
+
// Chrome has named this permission more than one way; any that's granted will do, and one it doesn't know is unknown.
|
|
269
|
+
var names = ['local-network-access', 'loopback-network', 'local-network'];
|
|
270
|
+
return Promise.all(names.map(function (name) {
|
|
271
|
+
try {
|
|
272
|
+
return Promise.resolve(permissions.query({ name: name })).then(function (s) { return !!s && s.state === 'granted'; }, function () { return false; });
|
|
273
|
+
} catch (e) { return false; }
|
|
274
|
+
})).then(function (states) { return states.indexOf(true) !== -1; });
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
function nearby(path, body, timeout, deadline) {
|
|
258
278
|
var text = JSON.stringify(body);
|
|
259
279
|
function go(t) { return exchange(NEARBY, path, t, text, deadline ? Math.max(1000, deadline - Date.now()) : timeout); }
|
|
@@ -276,7 +296,7 @@
|
|
|
276
296
|
return done.then(null, function (e) {
|
|
277
297
|
if (e instanceof Failure) throw e;
|
|
278
298
|
// Safari never lets a page on the web reach this computer this way, so Lamp or lamp ai open here wouldn't help.
|
|
279
|
-
if (safari()) throw new Failure(
|
|
299
|
+
if (safari()) throw new Failure(SAFARI_AI, null, AI_HELP);
|
|
280
300
|
throw new Failure('AI isn\'t available here. Open Lamp on this computer, or with Lamp\'s command line, run lamp ai.', null, AI_HELP);
|
|
281
301
|
});
|
|
282
302
|
}
|
|
@@ -738,31 +758,58 @@
|
|
|
738
758
|
* a feature depends on it, and show the feature with its `reason` as it's given rather than words of the app's
|
|
739
759
|
* own, or hiding it or failing at it, and where it gives `help` (a page away from Lamp), a link to it beside the
|
|
740
760
|
* reason: "How to turn on AI". A call that fails for that reason has `needsSetup`, or `help`, set on its error.
|
|
761
|
+
* On a page shared on lamp.dev that hasn't reached this computer yet, `status` asks nothing and answers
|
|
762
|
+
* `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
|
|
763
|
+
* the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
|
|
764
|
+
* call `connect()` as the page loads.
|
|
741
765
|
*/
|
|
742
766
|
var LampAI = {
|
|
743
767
|
Failure: Failure,
|
|
744
768
|
|
|
745
769
|
/** Whether this kind of AI ('text', 'decisions', 'pictures', 'act' or 'hear') can answer now: { available, reason,
|
|
746
|
-
* help }, where `reason` is the short line to show when it can't, written for where the page is,
|
|
747
|
-
* it's there, the page that says how to turn AI on, to link to beside it
|
|
770
|
+
* help, connect }, where `reason` is the short line to show when it can't, written for where the page is, `help`,
|
|
771
|
+
* when it's there, the page that says how to turn AI on, to link to beside it, and `connect`, when true, that
|
|
772
|
+
* a button calling `LampAI.connect()` would find out (a page on lamp.dev, before it has reached this computer). */
|
|
748
773
|
status: function (capability) {
|
|
749
774
|
var no = function (reason, help) {
|
|
750
775
|
var said = { available: false, reason: reason || 'AI isn\'t set up for this here.' };
|
|
751
776
|
if (help) said.help = help;
|
|
752
777
|
return said;
|
|
753
778
|
};
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
return
|
|
779
|
+
if (!bridge && shared()) {
|
|
780
|
+
return nearbyAllowed().then(function (allowed) {
|
|
781
|
+
if (allowed) return ask(capability);
|
|
782
|
+
if (safari()) return no(SAFARI_AI, AI_HELP);
|
|
783
|
+
return { available: false, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
return ask(capability);
|
|
787
|
+
function ask(capability) {
|
|
788
|
+
// 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
|
|
789
|
+
// route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
|
|
790
|
+
if (capability === 'act' || capability === 'hear') {
|
|
791
|
+
return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
|
|
792
|
+
return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
|
|
793
|
+
}, function (e) { return no(e && e.message, e && e.help); });
|
|
794
|
+
}
|
|
795
|
+
return post('/ai/status', {}, 10000).then(function (reply) {
|
|
796
|
+
if (reply[capability] === true) return { available: true, reason: '' };
|
|
797
|
+
var detail = reply.details && reply.details[capability];
|
|
798
|
+
return no(detail && detail.reason);
|
|
759
799
|
}, function (e) { return no(e && e.message, e && e.help); });
|
|
760
800
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
/** On a page shared on lamp.dev, reaches the AI on this computer, so the browser asks the person then: call it
|
|
804
|
+
* from a button ("Connect AI") shown when `status` answers `connect: true`, never as the page loads. Gives what
|
|
805
|
+
* `status` gives for that kind of AI ('text' unless named). Anywhere else it's just `status`. */
|
|
806
|
+
connect: function (capability) {
|
|
807
|
+
nearbyAsked = true;
|
|
808
|
+
return LampAI.status(capability || 'text').then(function (said) {
|
|
809
|
+
// Reached it (whatever it said of this kind of AI): later visits look without asking first.
|
|
810
|
+
if (said.available || !said.help) write('nearby.connected', true);
|
|
811
|
+
return said;
|
|
812
|
+
});
|
|
766
813
|
},
|
|
767
814
|
|
|
768
815
|
/** Whether this kind of AI can answer now, as `status` says. */
|
package/cli/lamps/lamp.js
CHANGED
|
@@ -254,6 +254,26 @@
|
|
|
254
254
|
var ua = (window.navigator && navigator.userAgent) || '';
|
|
255
255
|
return /Safari\//.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS/.test(ua);
|
|
256
256
|
}
|
|
257
|
+
var SAFARI_AI = 'AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.';
|
|
258
|
+
|
|
259
|
+
// Reaching this computer makes the browser ask the person about it, so a shared page doesn't reach out just to
|
|
260
|
+
// see whether there's an AI: only once the browser already lets it, the page has paired before, or the person
|
|
261
|
+
// has asked for it (LampAI.connect, from a button, or a real AI call).
|
|
262
|
+
var nearbyAsked = false;
|
|
263
|
+
function nearbyAllowed() {
|
|
264
|
+
// Connected before in this browser (kept with the page's saved things): the browser has had its say already.
|
|
265
|
+
if (nearbyAsked || read('nearby.token', null) || read('nearby.connected', false)) return Promise.resolve(true);
|
|
266
|
+
var permissions = window.navigator && navigator.permissions;
|
|
267
|
+
if (!permissions || typeof permissions.query !== 'function') return Promise.resolve(false);
|
|
268
|
+
// Chrome has named this permission more than one way; any that's granted will do, and one it doesn't know is unknown.
|
|
269
|
+
var names = ['local-network-access', 'loopback-network', 'local-network'];
|
|
270
|
+
return Promise.all(names.map(function (name) {
|
|
271
|
+
try {
|
|
272
|
+
return Promise.resolve(permissions.query({ name: name })).then(function (s) { return !!s && s.state === 'granted'; }, function () { return false; });
|
|
273
|
+
} catch (e) { return false; }
|
|
274
|
+
})).then(function (states) { return states.indexOf(true) !== -1; });
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
function nearby(path, body, timeout, deadline) {
|
|
258
278
|
var text = JSON.stringify(body);
|
|
259
279
|
function go(t) { return exchange(NEARBY, path, t, text, deadline ? Math.max(1000, deadline - Date.now()) : timeout); }
|
|
@@ -276,7 +296,7 @@
|
|
|
276
296
|
return done.then(null, function (e) {
|
|
277
297
|
if (e instanceof Failure) throw e;
|
|
278
298
|
// Safari never lets a page on the web reach this computer this way, so Lamp or lamp ai open here wouldn't help.
|
|
279
|
-
if (safari()) throw new Failure(
|
|
299
|
+
if (safari()) throw new Failure(SAFARI_AI, null, AI_HELP);
|
|
280
300
|
throw new Failure('AI isn\'t available here. Open Lamp on this computer, or with Lamp\'s command line, run lamp ai.', null, AI_HELP);
|
|
281
301
|
});
|
|
282
302
|
}
|
|
@@ -738,31 +758,58 @@
|
|
|
738
758
|
* a feature depends on it, and show the feature with its `reason` as it's given rather than words of the app's
|
|
739
759
|
* own, or hiding it or failing at it, and where it gives `help` (a page away from Lamp), a link to it beside the
|
|
740
760
|
* reason: "How to turn on AI". A call that fails for that reason has `needsSetup`, or `help`, set on its error.
|
|
761
|
+
* On a page shared on lamp.dev that hasn't reached this computer yet, `status` asks nothing and answers
|
|
762
|
+
* `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
|
|
763
|
+
* the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
|
|
764
|
+
* call `connect()` as the page loads.
|
|
741
765
|
*/
|
|
742
766
|
var LampAI = {
|
|
743
767
|
Failure: Failure,
|
|
744
768
|
|
|
745
769
|
/** Whether this kind of AI ('text', 'decisions', 'pictures', 'act' or 'hear') can answer now: { available, reason,
|
|
746
|
-
* help }, where `reason` is the short line to show when it can't, written for where the page is,
|
|
747
|
-
* it's there, the page that says how to turn AI on, to link to beside it
|
|
770
|
+
* help, connect }, where `reason` is the short line to show when it can't, written for where the page is, `help`,
|
|
771
|
+
* when it's there, the page that says how to turn AI on, to link to beside it, and `connect`, when true, that
|
|
772
|
+
* a button calling `LampAI.connect()` would find out (a page on lamp.dev, before it has reached this computer). */
|
|
748
773
|
status: function (capability) {
|
|
749
774
|
var no = function (reason, help) {
|
|
750
775
|
var said = { available: false, reason: reason || 'AI isn\'t set up for this here.' };
|
|
751
776
|
if (help) said.help = help;
|
|
752
777
|
return said;
|
|
753
778
|
};
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
return
|
|
779
|
+
if (!bridge && shared()) {
|
|
780
|
+
return nearbyAllowed().then(function (allowed) {
|
|
781
|
+
if (allowed) return ask(capability);
|
|
782
|
+
if (safari()) return no(SAFARI_AI, AI_HELP);
|
|
783
|
+
return { available: false, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
return ask(capability);
|
|
787
|
+
function ask(capability) {
|
|
788
|
+
// 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
|
|
789
|
+
// route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
|
|
790
|
+
if (capability === 'act' || capability === 'hear') {
|
|
791
|
+
return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
|
|
792
|
+
return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
|
|
793
|
+
}, function (e) { return no(e && e.message, e && e.help); });
|
|
794
|
+
}
|
|
795
|
+
return post('/ai/status', {}, 10000).then(function (reply) {
|
|
796
|
+
if (reply[capability] === true) return { available: true, reason: '' };
|
|
797
|
+
var detail = reply.details && reply.details[capability];
|
|
798
|
+
return no(detail && detail.reason);
|
|
759
799
|
}, function (e) { return no(e && e.message, e && e.help); });
|
|
760
800
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
/** On a page shared on lamp.dev, reaches the AI on this computer, so the browser asks the person then: call it
|
|
804
|
+
* from a button ("Connect AI") shown when `status` answers `connect: true`, never as the page loads. Gives what
|
|
805
|
+
* `status` gives for that kind of AI ('text' unless named). Anywhere else it's just `status`. */
|
|
806
|
+
connect: function (capability) {
|
|
807
|
+
nearbyAsked = true;
|
|
808
|
+
return LampAI.status(capability || 'text').then(function (said) {
|
|
809
|
+
// Reached it (whatever it said of this kind of AI): later visits look without asking first.
|
|
810
|
+
if (said.available || !said.help) write('nearby.connected', true);
|
|
811
|
+
return said;
|
|
812
|
+
});
|
|
766
813
|
},
|
|
767
814
|
|
|
768
815
|
/** Whether this kind of AI can answer now, as `status` says. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"exe":"ZqxAppName","
|
|
1
|
+
{"exe":"ZqxAppName","name":"Zqx App Name","instance":"66001C8E-04AF-4B31-9585-64F312C02625","platform":"mac"}
|
|
@@ -254,6 +254,26 @@
|
|
|
254
254
|
var ua = (window.navigator && navigator.userAgent) || '';
|
|
255
255
|
return /Safari\//.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox|FxiOS/.test(ua);
|
|
256
256
|
}
|
|
257
|
+
var SAFARI_AI = 'AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.';
|
|
258
|
+
|
|
259
|
+
// Reaching this computer makes the browser ask the person about it, so a shared page doesn't reach out just to
|
|
260
|
+
// see whether there's an AI: only once the browser already lets it, the page has paired before, or the person
|
|
261
|
+
// has asked for it (LampAI.connect, from a button, or a real AI call).
|
|
262
|
+
var nearbyAsked = false;
|
|
263
|
+
function nearbyAllowed() {
|
|
264
|
+
// Connected before in this browser (kept with the page's saved things): the browser has had its say already.
|
|
265
|
+
if (nearbyAsked || read('nearby.token', null) || read('nearby.connected', false)) return Promise.resolve(true);
|
|
266
|
+
var permissions = window.navigator && navigator.permissions;
|
|
267
|
+
if (!permissions || typeof permissions.query !== 'function') return Promise.resolve(false);
|
|
268
|
+
// Chrome has named this permission more than one way; any that's granted will do, and one it doesn't know is unknown.
|
|
269
|
+
var names = ['local-network-access', 'loopback-network', 'local-network'];
|
|
270
|
+
return Promise.all(names.map(function (name) {
|
|
271
|
+
try {
|
|
272
|
+
return Promise.resolve(permissions.query({ name: name })).then(function (s) { return !!s && s.state === 'granted'; }, function () { return false; });
|
|
273
|
+
} catch (e) { return false; }
|
|
274
|
+
})).then(function (states) { return states.indexOf(true) !== -1; });
|
|
275
|
+
}
|
|
276
|
+
|
|
257
277
|
function nearby(path, body, timeout, deadline) {
|
|
258
278
|
var text = JSON.stringify(body);
|
|
259
279
|
function go(t) { return exchange(NEARBY, path, t, text, deadline ? Math.max(1000, deadline - Date.now()) : timeout); }
|
|
@@ -276,7 +296,7 @@
|
|
|
276
296
|
return done.then(null, function (e) {
|
|
277
297
|
if (e instanceof Failure) throw e;
|
|
278
298
|
// Safari never lets a page on the web reach this computer this way, so Lamp or lamp ai open here wouldn't help.
|
|
279
|
-
if (safari()) throw new Failure(
|
|
299
|
+
if (safari()) throw new Failure(SAFARI_AI, null, AI_HELP);
|
|
280
300
|
throw new Failure('AI isn\'t available here. Open Lamp on this computer, or with Lamp\'s command line, run lamp ai.', null, AI_HELP);
|
|
281
301
|
});
|
|
282
302
|
}
|
|
@@ -738,31 +758,58 @@
|
|
|
738
758
|
* a feature depends on it, and show the feature with its `reason` as it's given rather than words of the app's
|
|
739
759
|
* own, or hiding it or failing at it, and where it gives `help` (a page away from Lamp), a link to it beside the
|
|
740
760
|
* reason: "How to turn on AI". A call that fails for that reason has `needsSetup`, or `help`, set on its error.
|
|
761
|
+
* On a page shared on lamp.dev that hasn't reached this computer yet, `status` asks nothing and answers
|
|
762
|
+
* `connect: true`: show a small "Connect AI" button beside its reason that calls `LampAI.connect(capability)` with
|
|
763
|
+
* the same kind of AI (`connect('pictures')` for pictures), and use what that gives as `status`'s answer. Never
|
|
764
|
+
* call `connect()` as the page loads.
|
|
741
765
|
*/
|
|
742
766
|
var LampAI = {
|
|
743
767
|
Failure: Failure,
|
|
744
768
|
|
|
745
769
|
/** Whether this kind of AI ('text', 'decisions', 'pictures', 'act' or 'hear') can answer now: { available, reason,
|
|
746
|
-
* help }, where `reason` is the short line to show when it can't, written for where the page is,
|
|
747
|
-
* it's there, the page that says how to turn AI on, to link to beside it
|
|
770
|
+
* help, connect }, where `reason` is the short line to show when it can't, written for where the page is, `help`,
|
|
771
|
+
* when it's there, the page that says how to turn AI on, to link to beside it, and `connect`, when true, that
|
|
772
|
+
* a button calling `LampAI.connect()` would find out (a page on lamp.dev, before it has reached this computer). */
|
|
748
773
|
status: function (capability) {
|
|
749
774
|
var no = function (reason, help) {
|
|
750
775
|
var said = { available: false, reason: reason || 'AI isn\'t set up for this here.' };
|
|
751
776
|
if (help) said.help = help;
|
|
752
777
|
return said;
|
|
753
778
|
};
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
return
|
|
779
|
+
if (!bridge && shared()) {
|
|
780
|
+
return nearbyAllowed().then(function (allowed) {
|
|
781
|
+
if (allowed) return ask(capability);
|
|
782
|
+
if (safari()) return no(SAFARI_AI, AI_HELP);
|
|
783
|
+
return { available: false, connect: true, reason: 'Connect to the AI on this computer to use this.', help: AI_HELP };
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
return ask(capability);
|
|
787
|
+
function ask(capability) {
|
|
788
|
+
// 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
|
|
789
|
+
// route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
|
|
790
|
+
if (capability === 'act' || capability === 'hear') {
|
|
791
|
+
return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
|
|
792
|
+
return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
|
|
793
|
+
}, function (e) { return no(e && e.message, e && e.help); });
|
|
794
|
+
}
|
|
795
|
+
return post('/ai/status', {}, 10000).then(function (reply) {
|
|
796
|
+
if (reply[capability] === true) return { available: true, reason: '' };
|
|
797
|
+
var detail = reply.details && reply.details[capability];
|
|
798
|
+
return no(detail && detail.reason);
|
|
759
799
|
}, function (e) { return no(e && e.message, e && e.help); });
|
|
760
800
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
801
|
+
},
|
|
802
|
+
|
|
803
|
+
/** On a page shared on lamp.dev, reaches the AI on this computer, so the browser asks the person then: call it
|
|
804
|
+
* from a button ("Connect AI") shown when `status` answers `connect: true`, never as the page loads. Gives what
|
|
805
|
+
* `status` gives for that kind of AI ('text' unless named). Anywhere else it's just `status`. */
|
|
806
|
+
connect: function (capability) {
|
|
807
|
+
nearbyAsked = true;
|
|
808
|
+
return LampAI.status(capability || 'text').then(function (said) {
|
|
809
|
+
// Reached it (whatever it said of this kind of AI): later visits look without asking first.
|
|
810
|
+
if (said.available || !said.help) write('nearby.connected', true);
|
|
811
|
+
return said;
|
|
812
|
+
});
|
|
766
813
|
},
|
|
767
814
|
|
|
768
815
|
/** Whether this kind of AI can answer now, as `status` says. */
|
package/cli/share.mjs
CHANGED
|
@@ -15,11 +15,13 @@ import { NOTES_FILE, Problem, stateOf } from "./state.mjs";
|
|
|
15
15
|
import { ANY_BROWSER, CONTROLLERS, CONTROLLERS_FILE, DEVICE_FILE, lampJS, LIBRARIES } from "./dev.mjs";
|
|
16
16
|
import { LIMITS, FILE_LIMITS, PAGE_LIMITS } from "../site/lib/shared-wishes.js";
|
|
17
17
|
import { ideasOf, makerOf, pageUp, publishLink, removeFile, removeIdea, removeLink, replyIdea, sendFile, siteOrigin } from "./link.mjs";
|
|
18
|
-
import { exportApp } from "./lampapp.mjs";
|
|
18
|
+
import { exportApp, sourceLinkOf } from "./lampapp.mjs";
|
|
19
19
|
|
|
20
20
|
export { siteOrigin };
|
|
21
21
|
|
|
22
22
|
const linkFileOf = (folder) => join(stateOf(folder), "link.json");
|
|
23
|
+
/** A link by its id alone, which the site sends on to its address now: what a file names, so a rename never breaks it. */
|
|
24
|
+
const stableLink = (site, id) => `${site}/w/${id}`;
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -174,9 +176,13 @@ const megabytes = (bytes) => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ?
|
|
|
174
176
|
* page or null, `shape` "fill" or the page's width over its height. Answers the link and what went up.
|
|
175
177
|
*/
|
|
176
178
|
export async function share({ layout, words, picture = null, shape = "fill", site = siteOrigin(), said = () => {}, file = true, anyway = false, github, fromLamp = false }) {
|
|
179
|
+
// The folder's own link when it has one on this site, changed in place; a link that's gone is made again.
|
|
180
|
+
let earlier = readLink(layout.folder);
|
|
181
|
+
if (earlier && earlier.site !== site) earlier = null;
|
|
177
182
|
// The app's own file goes with the link, as Lamp's Share Link sends it, so whoever follows the link can open it in
|
|
178
|
-
// Lamp too: made first, with Share…'s checks, so a page that can't be shared (a key in it) puts nothing up.
|
|
179
|
-
|
|
183
|
+
// Lamp too: made first, with Share…'s checks, so a page that can't be shared (a key in it) puts nothing up. It names
|
|
184
|
+
// the link it goes up with, so a copy shared on says it came from this one: made again below if that's a new link.
|
|
185
|
+
let app = file ? exportApp(layout.folder, { anyway, link: earlier ? stableLink(site, earlier.id) : null }) : null;
|
|
180
186
|
const files = pageFiles(layout);
|
|
181
187
|
const unpacked = files.reduce((total, [, bytes]) => total + bytes.length, 0);
|
|
182
188
|
if (!files.some(([path]) => path === "index.html")) throw new Problem("the page has no index.html at its top");
|
|
@@ -194,9 +200,9 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
|
|
|
194
200
|
...(words.asks?.length ? { asks: words.asks } : {}), ...(picture ? { picture: picture.toString("base64") } : {}),
|
|
195
201
|
...(repo ? { github: repo } : {}),
|
|
196
202
|
};
|
|
197
|
-
// The
|
|
198
|
-
|
|
199
|
-
if (
|
|
203
|
+
// The link this app came from, when it's a copy of one shared there: its page says so.
|
|
204
|
+
const from = sourceLinkOf(layout.folder, site, earlier?.id);
|
|
205
|
+
if (from) body.from = from;
|
|
200
206
|
// A file an earlier share sent stays on the record until it's taken away, so a retry after a failed removal tries again.
|
|
201
207
|
// A link that's a Lamp's (`fromLamp`, the app in its own folder) stays so in every write, a share that fails partway too.
|
|
202
208
|
let link = { site, ...(await publishLink({ site, body, earlier, account: accountFor(site)?.key })), ...words, shape, ...(earlier?.file ? { file: true } : {}),
|
|
@@ -204,6 +210,8 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
|
|
|
204
210
|
// Kept before the page goes up: a link whose page didn't make it is still this folder's to send again or take down.
|
|
205
211
|
writeLink(layout.folder, link);
|
|
206
212
|
|
|
213
|
+
// A new link (or one made again) has its id only now, which the file carries.
|
|
214
|
+
if (app && link.id !== earlier?.id) app = exportApp(layout.folder, { anyway, link: stableLink(site, link.id) });
|
|
207
215
|
// The file, or none: a link that carried one and shouldn't now has it taken away (and its page with it, sent next).
|
|
208
216
|
if (app) {
|
|
209
217
|
link.file = true;
|
package/package.json
CHANGED
|
@@ -137,11 +137,11 @@ function release(value) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
|
|
140
|
-
* from before them), so a change keeps the ones the link has. */
|
|
140
|
+
* from before them), so a change keeps the ones the link has; `from` too, the id of the link the app is a copy of. */
|
|
141
141
|
const GITHUB_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
|
|
142
142
|
export function readShare(body) {
|
|
143
143
|
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
144
|
-
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github"];
|
|
144
|
+
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github", "from"];
|
|
145
145
|
if (Object.keys(body).some((key) => !known.includes(key))) return null;
|
|
146
146
|
const name = tidy(body.name, LIMITS.name);
|
|
147
147
|
const words = tidy(body.words, LIMITS.words, { lines: true });
|
|
@@ -159,9 +159,10 @@ export function readShare(body) {
|
|
|
159
159
|
const icon = picture(body.icon, LIMITS.icon, { square: true });
|
|
160
160
|
// The app's public repository on GitHub, as "owner/name", which the page links to: nothing else is taken.
|
|
161
161
|
const github = body.github == null || body.github === "" ? null : GITHUB_REPO.test(body.github) ? body.github : undefined;
|
|
162
|
-
|
|
162
|
+
const from = body.from == null ? undefined : isID(body.from) ? body.from : null;
|
|
163
|
+
if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined || from === null) return null;
|
|
163
164
|
return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
|
|
164
|
-
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github };
|
|
165
|
+
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github, from };
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
/** What anyone may see of a shared wish: never its secret. */
|
|
@@ -772,8 +773,11 @@ export function createShareHandler({ env = process.env, fetcher = fetch, store,
|
|
|
772
773
|
const picture = share.picture && { url: await put("picture", share.picture), width: share.picture.width, height: share.picture.height };
|
|
773
774
|
const icon = share.icon && { url: await put("icon", share.icon) };
|
|
774
775
|
const time = now().toISOString();
|
|
776
|
+
// The link the app is a copy of, kept only while there's such a link and it isn't this one: a share that doesn't
|
|
777
|
+
// say, or names one that's gone, keeps what the record had.
|
|
778
|
+
const from = share.from && share.from !== wishID && (await kept.get(share.from)) ? share.from : null;
|
|
775
779
|
const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape, github: share.github,
|
|
776
|
-
...(share.releases ? { releases: share.releases } : {}), picture: picture || null, icon: icon || null };
|
|
780
|
+
...(share.releases ? { releases: share.releases } : {}), ...(from ? { from } : {}), picture: picture || null, icon: icon || null };
|
|
777
781
|
// Its name under the account stays what it was first given, whatever the app is called later.
|
|
778
782
|
const named = earlier?.owner ? { owner: earlier.owner, app: earlier.app }
|
|
779
783
|
: owner ? { owner, app: await claimAppName(kept, owner, words.name, wishID) } : {};
|
|
@@ -1287,10 +1291,21 @@ export function createIdeaMailHandler({ env = process.env, fetcher = fetch, stor
|
|
|
1287
1291
|
}
|
|
1288
1292
|
|
|
1289
1293
|
/** A shared wish for the page and its card, or null when there's none by that id (or it can't be read now). */
|
|
1290
|
-
export async function findWish(id, { env = process.env, fetcher = fetch, store } = {}) {
|
|
1294
|
+
export async function findWish(id, { env = process.env, fetcher = fetch, store, origin = null } = {}) {
|
|
1291
1295
|
if (!isID(id)) return null;
|
|
1292
|
-
|
|
1296
|
+
let kept, record;
|
|
1297
|
+
try { kept = store ?? cloudStore(env, fetcher); record = await kept.get(id); }
|
|
1293
1298
|
catch { return undefined; }
|
|
1299
|
+
const wish = shown(record);
|
|
1300
|
+
// Asked with the site's origin (the page): the link it came from, as that link is known now, `from` on its page. One
|
|
1301
|
+
// taken down since, or that can't be read just now, is left out.
|
|
1302
|
+
if (wish && origin && isID(record.from) && record.from !== id) {
|
|
1303
|
+
try {
|
|
1304
|
+
const source = await kept.get(record.from);
|
|
1305
|
+
if (source) wish.from = { url: linkOf(origin, source), label: source.owner && source.app ? `${source.owner}/${source.app}` : source.name };
|
|
1306
|
+
} catch {}
|
|
1307
|
+
}
|
|
1308
|
+
return wish;
|
|
1294
1309
|
}
|
|
1295
1310
|
|
|
1296
1311
|
/** Takes a shared wish down without its secret: for the Lamp team, when a link shows what it mustn't. */
|