@lampdotdev/lamp 0.36.2-nightly.19 → 0.36.2-nightly.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,14 +26,14 @@ Mac app, `projectOf`), or any folder with an `index.html`; without one, the fold
26
26
  | `lamp use key w 2` | Tries it as a person would, then looks: `click`, `type`, `key` (several at once with `w+d`), `drag`, `scroll`, `wait`, and in a game on Lamp's runtime `stick` and `button`, which hold Lamp's own controller as Lamp's app does (`window.__lampDrive`) and give it back when the person plays. Several moves with `then` between them go one after another, with one look at the end. |
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
- | `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. |
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. The app's icon goes with it (`iconOf`: a square `icon.png` or `apple-touch-icon.png` beside the page, a PNG its index.html names as its icon, or Lamp's `.lamp/icon.png`, made 512 pixels a side when bigger), for its page and the browser's tab. |
30
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
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
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. |
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. |
@@ -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
@@ -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('AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.', null, AI_HELP);
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, and `help`, when
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
- // 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
755
- // route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
756
- if (capability === 'act' || capability === 'hear') {
757
- return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
758
- return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
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
- return post('/ai/status', {}, 10000).then(function (reply) {
762
- if (reply[capability] === true) return { available: true, reason: '' };
763
- var detail = reply.details && reply.details[capability];
764
- return no(detail && detail.reason);
765
- }, function (e) { return no(e && e.message, e && e.help); });
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('AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.', null, AI_HELP);
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, and `help`, when
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
- // 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
755
- // route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
756
- if (capability === 'act' || capability === 'hear') {
757
- return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
758
- return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
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
- return post('/ai/status', {}, 10000).then(function (reply) {
762
- if (reply[capability] === true) return { available: true, reason: '' };
763
- var detail = reply.details && reply.details[capability];
764
- return no(detail && detail.reason);
765
- }, function (e) { return no(e && e.message, e && e.help); });
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
- {"platform":"mac","instance":"12A4CA51-92CD-4391-8AD0-548165ABFCAE","exe":"ZqxAppName","name":"Zqx App Name"}
1
+ {"platform":"mac","name":"Zqx App Name","instance":"09B1F6F7-D080-4175-9B91-C53B1B014BBF","exe":"ZqxAppName"}
@@ -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('AI here works in Chrome or in Lamp, or with Lamp\'s command line: lamp play and this page\'s link.', null, AI_HELP);
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, and `help`, when
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
- // 'act' is asked of /ai/act itself: available only when this lamp.js has it, the app around the page lets the
755
- // route through (an older one answers Not found) and the AI that answers the app can take it. 'hear' the same.
756
- if (capability === 'act' || capability === 'hear') {
757
- return post('/ai/' + capability, { probe: true }, 10000).then(function (reply) {
758
- return reply[capability] === true ? { available: true, reason: '' } : no(reply.reason || reply.why);
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
- return post('/ai/status', {}, 10000).then(function (reply) {
762
- if (reply[capability] === true) return { available: true, reason: '' };
763
- var detail = reply.details && reply.details[capability];
764
- return no(detail && detail.reason);
765
- }, function (e) { return no(e && e.message, e && e.help); });
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
@@ -7,15 +7,16 @@
7
7
 
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { createHash } from "node:crypto";
10
- import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
- import { basename, dirname, join, resolve } from "node:path";
12
+ import { basename, dirname, join, resolve, sep } from "node:path";
13
13
  import { deflateRawSync } from "node:zlib";
14
14
  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
18
  import { exportApp, sourceLinkOf } from "./lampapp.mjs";
19
+ import { scaled, sizeOf } from "./png.mjs";
19
20
 
20
21
  export { siteOrigin };
21
22
 
@@ -175,6 +176,40 @@ const megabytes = (bytes) => `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ?
175
176
  * are what the link says (`name`, `words`, and `line`, `by` and `asks` when given), `picture` a PNG or JPEG of the
176
177
  * page or null, `shape` "fill" or the page's width over its height. Answers the link and what went up.
177
178
  */
179
+ /**
180
+ * The app's own icon for its link: a square PNG the page has (icon.png or apple-touch-icon.png beside it, or a PNG its
181
+ * index.html names as its icon), else the one Lamp keeps in its own app's folder (.lamp/icon.png). Made 512 pixels a side
182
+ * when it's bigger than the site takes. Null when there's none that's square: a page's own picture is never squashed.
183
+ */
184
+ export function iconOf(layout) {
185
+ const names = ["icon.png", "apple-touch-icon.png"];
186
+ try {
187
+ const html = readFileSync(join(layout.root, "index.html"), "utf8");
188
+ for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
189
+ const href = /\bhref=["']([^"'?#]+\.png)["']/i.exec(tag)?.[1];
190
+ if (/\brel=["'][^"']*\bicon\b/i.test(tag) && href && !/^[a-z]+:|^\/\//i.test(href)) names.push(href.replace(/^\.?\//, ""));
191
+ }
192
+ } catch {}
193
+ const candidates = [...names.map((name) => join(layout.root, name)), join(layout.folder, ".lamp", "icon.png")];
194
+ // Only a file really in the app's folder, links followed: a name like ../elsewhere.png or a link out of the folder
195
+ // would put someone's other picture on a public page.
196
+ let home;
197
+ try { home = realpathSync(layout.folder); } catch { return null; }
198
+ for (const file of candidates) {
199
+ let real;
200
+ try { real = realpathSync(file); } catch { continue; }
201
+ if (!real.startsWith(home + sep)) continue;
202
+ let bytes;
203
+ try { bytes = readFileSync(real); } catch { continue; }
204
+ const size = sizeOf(bytes);
205
+ if (!size || size.width !== size.height || size.width < 16) continue;
206
+ if (size.width <= 512 && bytes.length <= LIMITS.icon) return bytes;
207
+ const smaller = scaled(bytes, 512);
208
+ if (smaller && smaller.length <= LIMITS.icon) return smaller;
209
+ }
210
+ return null;
211
+ }
212
+
178
213
  export async function share({ layout, words, picture = null, shape = "fill", site = siteOrigin(), said = () => {}, file = true, anyway = false, github, fromLamp = false }) {
179
214
  // The folder's own link when it has one on this site, changed in place; a link that's gone is made again.
180
215
  let earlier = readLink(layout.folder);
@@ -200,6 +235,9 @@ export async function share({ layout, words, picture = null, shape = "fill", sit
200
235
  ...(words.asks?.length ? { asks: words.asks } : {}), ...(picture ? { picture: picture.toString("base64") } : {}),
201
236
  ...(repo ? { github: repo } : {}),
202
237
  };
238
+ // The app's icon, which its page shows beside its name and in the browser's tab, as Lamp's Share Link sends it.
239
+ const icon = iconOf(layout);
240
+ if (icon) body.icon = icon.toString("base64");
203
241
  // The link this app came from, when it's a copy of one shared there: its page says so.
204
242
  const from = sourceLinkOf(layout.folder, site, earlier?.id);
205
243
  if (from) body.from = from;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lampdotdev/lamp",
3
- "version": "0.36.2-nightly.19",
3
+ "version": "0.36.2-nightly.22",
4
4
  "description": "Runs a web app or game from its folder and puts each saved change into the open page.",
5
5
  "type": "module",
6
6
  "bin": {