samagotchi 0.112.0 → 0.113.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd818cf8fcf56a7040bdf84299d75a547b2031eb7bea13a1fdb087d12ed051d0
4
- data.tar.gz: 27890e5a3913053d11b5240c66f335ac66e0c5b4e55a9c0410b7a885ec9319d3
3
+ metadata.gz: fc980123c32fef67a2b47defad94735286ba983537b227ba79961cf6b15f1f1a
4
+ data.tar.gz: 5768a7d054c2a36abd746b6a19b7126fa6e992915bbae6040d2990b1903754bf
5
5
  SHA512:
6
- metadata.gz: 44e69d35e152608b949071ca95d22367115c8524c284378a1e854ab98636e40f8f53bd5644bd816bfa903a2f3125e3c09a2f297999d4c251ee1bed6bf7a10e52
7
- data.tar.gz: d47e33d2b044590f507f3f551b179c60aa848e11ca79c9543d3f1af2a4b199f9ffee9bfa590488f66a0b439c65e7600a05c4c2caa335b4c26534782929d6c89e
6
+ metadata.gz: 906dcb1ea43aeb750b201f75ef585648c609c89e77b0a8ff2bc06e11aa06ece5bec4b7734a1ecff888099952c90e7898c1dc91d64fbe52b4ddbbf102ddefff3a
7
+ data.tar.gz: 05a0edaf338b7a33bb3ffa73a4b590bff8498d789004c12c3f85a7e52ccb2516c4a58c332ae7fb203b9e04131fb36d1b5bb8c1f0f2fc2373e03ef6ded9ccd366
data/CHANGELOG.md CHANGED
@@ -8,6 +8,29 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.113.0] - 2026-10-25
12
+
13
+ ### Added
14
+
15
+ - chi web: in the phone's Home Screen app the bell opens a notifications sheet: this device's switch, then every
16
+ device chi web pushes to with its last delivery, a "Long turns done" switch each and Remove; devices from an
17
+ earlier address are one line.
18
+ - `chi web push ls|rm ID|test [ID]`: list the phones chi web pushes to, remove one, or send a test notification,
19
+ from the terminal (chi web need not run; never prints a push address or keys).
20
+
21
+ ### Fixed
22
+
23
+ - chi web behind an auth proxy: a browser on this machine opening `127.0.0.1:<port>` got the token page with a paste
24
+ box that cannot work there (loopback takes the token as `Authorization: Bearer` only, so no cookie is traded); the
25
+ 401 page now says chi web opens at its public address and links it, and the API's 401 detail says the same.
26
+ - `chi web --tunnel tailscale`: a Ctrl-C while it turned Tailscale Serve on left port 443 forwarding to chi web's port
27
+ after it exited, and a second Ctrl-C during the teardown could cut it short with 443 still on: both now wait for the
28
+ command and turn 443 off.
29
+ - `chi web --tunnel tailscale` turns Tailscale Serve on only once chi web has bound its port: a program that took the
30
+ port between the free-port check and the bind was reachable from the tailnet until chi web gave up.
31
+
32
+ Update with `chi update` (the system bundle 0.113.0), then restart `chi web`.
33
+
11
34
  ## [0.112.0] - 2026-10-25
12
35
 
13
36
  ### Added
@@ -4291,7 +4314,8 @@ and long-lived sessions.
4291
4314
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
4292
4315
  hotkey panel that send selected text or the clipboard to your sessions.
4293
4316
 
4294
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.112.0...HEAD
4317
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.113.0...HEAD
4318
+ [0.113.0]: https://github.com/dm1try/samagotchi/compare/v0.112.0...v0.113.0
4295
4319
  [0.112.0]: https://github.com/dm1try/samagotchi/compare/v0.111.1...v0.112.0
4296
4320
  [0.111.1]: https://github.com/dm1try/samagotchi/compare/v0.111.0...v0.111.1
4297
4321
  [0.111.0]: https://github.com/dm1try/samagotchi/compare/v0.110.0...v0.111.0
data/bin/chi CHANGED
@@ -97,6 +97,14 @@ if (path, klass = SUBCOMMANDS[ARGV[0]])
97
97
  exit Samagotchi.const_get(klass).new(ARGV[1..] || []).run
98
98
  end
99
99
 
100
+ # `chi web push ls|rm|test`: this machine's Web Push devices. Not a
101
+ # SUBCOMMANDS key: `chi web` itself is RunOptions' (and a remote's refusal
102
+ # came above: chi web runs on this machine only).
103
+ if ARGV[0] == "web" && ARGV[1] == "push"
104
+ require "samagotchi/push_command"
105
+ exit Samagotchi::PushCommand.new(ARGV[2..]).run
106
+ end
107
+
100
108
  # `chi scratch`: the run options below, for a session that leaves nothing
101
109
  # behind (TerminalUI scratch:). The word goes before OptionParser sees it.
102
110
  scratch = ARGV[0] == "scratch"
data/docs/cli.md CHANGED
@@ -14,6 +14,7 @@
14
14
  - `chi web [--port 4567] [--open] [--scope=all]` — start the Web UI (single localhost port session control plane) on this git project's sessions (`--scope=all`, or a folder in no repo: every session); if a chi web already runs on the port, print (with `--open`, open) its page for this folder and exit. Something else on the port (an older chi web too) exits 1 with "port N is in use"
15
15
  - `chi web --web-host lan` — the Web UI on your home network too, for your phone: a link with an access token and its QR code (see [chi web on your phone](#chi-web-on-your-phone)); `chi web --new-token` replaces the token
16
16
  - `chi web --tunnel tailscale` — the Web UI over https on your tailnet, for your phone anywhere: Tailscale Serve (never Funnel) on this machine's `<machine>.<tailnet>.ts.net` name for this run, the owner your Tailscale login, turned off on exit (see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet)); `--tunnel` without `web` exits 2
17
+ - `chi web push ls [--format json]`, `chi web push rm ID`, `chi web push test [ID]` — the phones chi web pushes to (see [Phone notifications](configuration.md#phone-notifications-web-push)): list them (short id, label, origin, added, last delivery, done turns, last error; `stale` for one made on another public URL than chi web's now: never sent to), remove one, or send a test notification to ID or to every device not stale. chi web need not run; nothing printed holds a device's push address or keys. A usage error exits 2; a store others can read, a corrupt one, an unknown device or a failed test push exit 1. This machine only: `chi --remote X web push` exits 2
17
18
  - `chi web --web-markdown` — opt in to sanitized Markdown rendering for completed assistant messages, the stage's asking text and a resolved question card's reply
18
19
  - `chi web --web-view turn` — draw turns with the turn view (each turn as one block of steps, the running one at the bottom of the history) instead of the default stage view (the running turn pinned above the composer); `?view=stage|turn` on the page URL overrides it (see [Web views](#web-views))
19
20
  - `chi web --no-web-working-set` — turns the stage view's working set off (on by default; `--web-working-set` turns a config's `web.working_set: false` back on): what each turn brought into the model's context, in the stage and under each finished turn; `?working_set=1|0` on the page URL overrides it (see [The working set](#the-working-set))
@@ -1179,8 +1180,9 @@ access token", paste the token there (the part of the link after
1179
1180
  name on your tailnet (Tailscale Serve) for its run and prints its link's QR
1180
1181
  code; see [Tailscale Serve](configuration.md#tailscale-serve-your-tailnet).
1181
1182
  - Notifications on the phone, also while chi web isn't open there: on the tunnel's https link, Add to Home Screen,
1182
- open the Chi app and tap its bell; approvals and questions then push to the phone (see
1183
- [Phone notifications](configuration.md#phone-notifications-web-push)).
1183
+ open the Chi app, tap its bell and turn on **This device**; approvals, questions and failed turns then push to the
1184
+ phone. The same sheet lists the other devices (a long-turns-done switch each, Remove), and `chi web push ls|rm|test`
1185
+ does it from the Mac (see [Phone notifications](configuration.md#phone-notifications-web-push)).
1184
1186
 
1185
1187
  ### Remote sessions
1186
1188
 
@@ -1618,7 +1618,8 @@ What changes once `web.public_url` is set:
1618
1618
  its defaults) would otherwise look like this machine. On this machine the page opens through the proxy too: a
1619
1619
  request that didn't come through it takes the token only as `Authorization: Bearer` (no cookie: a cookie for
1620
1620
  127.0.0.1 would go to every server on any local port; a phone on the LAN, with `web.host: lan`, keeps its LAN link
1621
- and cookie). chi's own probe of the port (a second `chi web`, `chi share`,
1621
+ and cookie). A browser on this machine that opens the port directly gets a 401 page with no paste box: it links
1622
+ `web.public_url`, the address to open instead. chi's own probe of the port (a second `chi web`, `chi share`,
1622
1623
  `chi update`) sends no token; chi web answers it the few fields it checks. Links and the start line come from this
1623
1624
  machine's config, never from whatever answers on the port.
1624
1625
  - **A request through the proxy** (it carries `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, a known identity header,
@@ -1733,8 +1734,9 @@ Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reac
1733
1734
  ```
1734
1735
 
1735
1736
  - It checks before it changes anything: Tailscale is connected, this machine has its `ts.net` name and a certificate
1736
- for it, and what Serve already has on port 443. Nothing there: it runs
1737
- `tailscale serve --bg --https=443 http://127.0.0.1:<port>`. Exactly that one handler for this port (a leftover of an
1737
+ for it, and what Serve already has on port 443. Nothing there: once chi web has bound its port (so Serve never
1738
+ forwards your tailnet to another program that took the port first), it runs
1739
+ `tailscale serve --bg --https=443 http://127.0.0.1:<port>`, then prints its lines. Exactly that one handler for this port (a leftover of an
1738
1740
  earlier run killed hard, or step 3 done by hand): it adopts it. Anything else on 443 (another port, other paths, TCP
1739
1741
  forwarding, Funnel) is refused and left alone. Serve's other ports are never touched.
1740
1742
  - The owner is `web.proxy.owner` if config.yml has it, else this machine's own Tailscale login (a tagged machine has
@@ -1742,7 +1744,9 @@ Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reac
1742
1744
  config.yml: a `web.public_url` or `web.proxy.identity_header` that says otherwise (in config.yml or the environment)
1743
1745
  is refused, so a config from step 4 with this machine's name works as it is.
1744
1746
  - On exit (Ctrl-C, a kill, closing the terminal) it runs `tailscale serve --https=443 off`, but only while 443 still
1745
- serves exactly what it set; if you changed 443 meanwhile it leaves it and says so. Killed hard (`kill -9`, a power
1747
+ serves exactly what it set; if you changed 443 meanwhile it leaves it and says so. A Ctrl-C while it turns Serve on
1748
+ or off waits for that command (at most 15 s each) and the teardown still runs; a teardown that fails is one warning
1749
+ line. Killed hard (`kill -9`, a power
1746
1750
  loss) the serve stays on: Serve answers 502 for the name while nothing listens on the port, and whatever later
1747
1751
  listens on 127.0.0.1:<port> is reachable from your tailnet; the next `chi web --tunnel tailscale` adopts it and
1748
1752
  turns it off on exit, a plain `chi web` doesn't look.
@@ -1784,9 +1788,17 @@ What it needs:
1784
1788
  - No Apple account or registration: chi web signs its pushes with a key of its own (VAPID).
1785
1789
 
1786
1790
  Turning it on, on the phone: open the `Proxied:` link in Safari → Share → **Add to Home Screen** → open the **Chi**
1787
- app → tap the **bell** at the top → Allow. A test notification arrives at once. Tap the
1788
- bell again to turn it off on that phone. In a browser tab the bell stays the tab's own notifications (while the tab
1789
- is in the background), as before.
1791
+ app → tap the **bell** at the top → turn on **This device** in the sheet → Allow. A test notification arrives at once.
1792
+ In a browser tab the bell stays the tab's own notifications (while the tab is in the background), as before.
1793
+
1794
+ **The devices** (the bell's sheet in the app; a bottom sheet on the phone): this device first, with its switch (off
1795
+ removes it from chi web and is the same as the bell turned off), then every other device chi web pushes to: its label,
1796
+ the last delivery and the last error, a **Long turns done** switch each, and **Remove** (it can turn its bell on again
1797
+ to come back). Devices made on an earlier public URL show as one line ("1 more device from an earlier address"): they
1798
+ are never sent to; `chi web push rm ID` removes them. From the Mac: `chi web push ls` lists them, `chi web push test
1799
+ [ID]` sends a test notification, `chi web push rm ID` removes one ([CLI](cli.md)). A device iOS dropped silently still
1800
+ shows a recent delivery (Apple keeps accepting pushes for it): if one stops getting notifications, switch it off and on
1801
+ there; the new subscription replaces the dead one.
1790
1802
 
1791
1803
  - **What is sent**: the session's name and the reason ("needs approval", "needs an answer", "hit its step limit",
1792
1804
  "needs you", "turn failed", "done in N s"), never the question or the command, as the tab's notification on the
@@ -1798,8 +1810,7 @@ is in the background), as before.
1798
1810
  - **When**: the same things the tab notifies for, still open 3 s after they came (3.5 s for a delegated child's
1799
1811
  approval, which its parent may relay instead); one push each. A delegated child's failed or finished turn is its
1800
1812
  parent's to report. **Done turns** (10 s or longer, or one nobody started: a delegate's report, a context change, a
1801
- finished task) are **off** by default, per device; the switch comes with the devices list (for now:
1802
- `PATCH /api/push/subscriptions/ID {"prefs": {"done": true}}`, the id from `GET /api/push`'s `devices`).
1813
+ finished task) are **off** by default, per device: the **Long turns done** switch in the devices sheet turns them on.
1803
1814
  - **Quiet while you look**: nothing is pushed while any chi web page (a tab on the Mac, or the app) is in front and
1804
1815
  was touched in the last 2 minutes; what came meanwhile is never pushed later (the page has it). A page left in
1805
1816
  front untouched stops quieting the phone after about 2 minutes.
@@ -4,8 +4,8 @@ chi web pushes to the owner's phone when a session needs them, for what the tab
4
4
  question, a step-limit question, a card with actions, a failed turn, and (per device, off by default) a done one. The
5
5
  phone runs chi web as a Home Screen app (iOS 16.4+), its bell subscribes the device, and chi web, the only sender,
6
6
  pushes through the device's push service (Apple's `web.push.apple.com`). The user side is in
7
- [configuration.md](../configuration.md#phone-notifications-web-push). This page is how it is built (P1 and P2a of the
8
- plan; the devices list in the page and `chi web push` come later).
7
+ [configuration.md](../configuration.md#phone-notifications-web-push). This page is how it is built (P1, P2a and P2b of the
8
+ plan: P2b is the devices sheet and `chi web push`).
9
9
 
10
10
  ```
11
11
  SessionHub ──emit──▶ Notifier#call(event) ──Queue──▶ sender thread
@@ -55,6 +55,8 @@ files, one per machine, never in config.yml:
55
55
  endpoint's SHA-256: the only name of a device outside the store. `Sub#public_h` (id, label, origin, the dates,
56
56
  `last_error`, `prefs: {done:}`) and `#inspect` never carry the endpoint or the keys; neither do the log lines.
57
57
  `prefs` is `{"done": false}` until `Store#set_prefs(id, done:, origin:)` (the PATCH route) sets it; `Sub#done?`.
58
+ `Store#remove_device(id, origin:)` removes by short id only on that origin (nil: any; the DELETE route, `chi web
59
+ push rm`); `#subscriptions!` is `#subscriptions` that raises `Corrupt` instead of reading none (the CLI says so).
58
60
  - `sent.json`: `{"version": 1, "keys": {KEY: iso time}}`, the attention keys already handled (a device took the
59
61
  show, or it was skipped while a page was in front, or capped), written by `Store#mark_sent` under `store.lock`,
60
62
  keys older than a day (`SENT_KEEP`) pruned on each write. `Store#sent_keys` reads it; one that doesn't read is
@@ -201,7 +203,10 @@ public URL, or `web.push.enabled: false`):
201
203
  - `POST /api/push/subscriptions {subscription: {endpoint, keys}, replaces?: id}` → 201 `{id, label}`; the label is
202
204
  chi's own ("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser); `replaces`
203
205
  drops the device's earlier entry (`Store#add`).
204
- - `DELETE /api/push/subscriptions {endpoint}` → 204.
206
+ - `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off.
207
+ - `DELETE /api/push/subscriptions/ID` → 204, 404 `unknown_device` (another origin's, or not a short id); logs
208
+ `push_removed`. The devices sheet's Remove for another device; this device's own row never uses it (its switch is
209
+ the bell, so the page's state and the store stay one).
205
210
  - `PATCH /api/push/subscriptions/ID {prefs: {done: true|false}}` → 200 the device's `Sub#public_h`; 404
206
211
  `unknown_device` (not this origin's, or not a short id: the id is a `([^/]+)` capture checked in the handler, as
207
212
  `participant_gate_spec`'s `path_for` fills only those), 400 `invalid_prefs`; logs `push_prefs`. ROUTES' first
@@ -209,7 +214,8 @@ public URL, or `web.push.enabled: false`):
209
214
  - `POST /api/push/presence {page, front}` → 204 (also with push off: a desk tab has nothing to be told), 400
210
215
  `invalid_presence`.
211
216
  - `POST /api/push/test {endpoint?}` → a test push to that device or every one on this origin, answered per device
212
- (`{devices: [{id, label, status, code, ms}]}`); the bell sends one after subscribing.
217
+ (`{devices: [{id, label, status, code, ms}]}`); the bell sends one after subscribing. The payload is
218
+ `Push::TEST_PAYLOAD` (TTL `TEST_TTL`), shared with `chi web push test`.
213
219
 
214
220
  With push off the subscription writes and the test answer 409 `push_off`.
215
221
 
@@ -240,6 +246,24 @@ a test push; a tap when on unsubscribes and deletes, and forgets the id, unless
240
246
  delete (revoked meanwhile): then the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title count stays. In a browser tab, or with
241
247
  chi web not pushing, the bell is the in-tab one, unchanged (no duplicate desktop pushes).
242
248
 
249
+ **The devices sheet** (`public/push_devices.js`, `createPushDevices` on `chip_popover.js`'s shell: a bottom sheet
250
+ under 600 px, else a popover at the bell; made once chi web says it pushes, so a tab never gets one): in push mode
251
+ the bell's tap opens it (`toggleNotify` → `sheet.toggle()`), and it reads `GET /api/push?endpoint=` on each open.
252
+ This device's row has the switch, the bell's own `togglePush` (permission from that tap; off forgets
253
+ `PUSH_DEVICE_KEY`), never Remove; every device has a **Long turns done** switch (`PATCH …/ID {prefs: {done}}`),
254
+ another device Remove (`DELETE …/ID`); `GET`'s `stale` is one line. One change at a time, the list read again after
255
+ each; a failure is said in the sheet (and the bell's tooltip). Pure `deviceRows` / `staleText` / `sheetModel` /
256
+ `sheetTemplate` run under node (push_devices.test.js); notifications.js takes the sheet as a `devices` factory.
257
+
258
+ **`chi web push`** (`lib/samagotchi/push_command.rb`, `PushCommand`): `ls [--format json]`, `rm ID`, `test [ID]` on
259
+ this machine's `Store` directly (chi web need not run). "Stale" is another origin than
260
+ `PushCommand.current_origin`: the running chi web's (`Web::Running.live_public_url`), else `web.public_url`'s, none
261
+ marked when neither. `test` builds a `Client` per device (`Push.load_key`, `Push.contact(sub.origin)`), stamps the
262
+ delivery (`Store#mark`) and removes a `:gone` device, as the route does. bin/chi branches on `ARGV[0..1] == web
263
+ push` after the `SUBCOMMANDS` dispatch and before `RunOptions.parse` (which refuses a stray word after `web`); it is
264
+ no `SUBCOMMANDS` key (`web` is RunOptions', and the contract spec would want a TABLE/MACHINE entry). `chi --remote X
265
+ web push …` is refused before that by `SessionAccess::Dispatch` (`web` is local only).
266
+
243
267
  **The device's tidy-up** (`syncDevice`, the Home Screen app with the bell on): closes the shown notifications whose
244
268
  tag is a waiting key (`push.js isWaitingTag`, so `chi:test` and a failed or done turn's stay) and isn't open in the
245
269
  page's `byId` (`push.js openPushKeys`: `Attention.open_keys`'s rule), and sets or clears the app
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.112.0
3
+ version: 0.113.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "cli/command"
6
+ require_relative "cli/exit"
7
+ require_relative "cli/flags"
8
+ require_relative "config"
9
+ require_relative "web/push"
10
+ require_relative "web/running"
11
+
12
+ module Samagotchi
13
+ # `chi web push`: this machine's Web Push devices (Web::Push::Store),
14
+ # without chi web running: list them, remove one, send a test push.
15
+ # Never prints an endpoint or keys: a device is its short id and chi's
16
+ # label. bin/chi branches here on `web push` before RunOptions (which
17
+ # would refuse the word); `chi --remote X web push` is refused before
18
+ # that (chi web runs on this machine only). See docs/cli.md.
19
+ class PushCommand
20
+ include CLI::Command
21
+
22
+ USAGE = <<~TEXT
23
+ Usage: chi web push <ls|rm|test> [options]
24
+ ls [--format json] the devices chi web pushes to: id, label, origin, added, last delivery,
25
+ last error, done turns; "stale" for another origin's (never sent to)
26
+ rm ID remove a device (it turns the bell on again to come back)
27
+ test [ID] a test push to ID, or to every device not stale (chi web need not run)
28
+ A device is added by the bell in the phone's Home Screen app (docs/configuration.md, Phone notifications).
29
+ TEXT
30
+
31
+ SUBCOMMANDS = %w[ls rm test].freeze
32
+
33
+ FLAGS = {
34
+ "ls" => CLI::Flags.new(help: HELP_WORDS) { |f| f.value "--format" },
35
+ "rm" => CLI::Flags.new(help: HELP_WORDS),
36
+ "test" => CLI::Flags.new(help: HELP_WORDS)
37
+ }.freeze
38
+
39
+ # @param store [Web::Push::Store, nil] this machine's by default
40
+ # @param origin [#call] → the public origin chi web runs on now (the
41
+ # running one's, else web.public_url's), nil when neither
42
+ # @param client [#call, nil] (sub) → a Client for its origin; one
43
+ # signed with the store's key by default
44
+ def initialize(argv, stdout: $stdout, stderr: $stderr, store: nil, origin: nil, client: nil)
45
+ @argv = argv.dup
46
+ @stdout = stdout
47
+ @stderr = stderr
48
+ @store = store || Web::Push::Store.new
49
+ @origin = origin || -> { PushCommand.current_origin }
50
+ @client = client
51
+ end
52
+
53
+ # The running chi web's https origin (a --tunnel's too), else
54
+ # web.public_url's; nil when neither.
55
+ def self.current_origin
56
+ Web::Running.live_public_url&.origin || Web::PublicUrl.from_config(url: Config.get("web.public_url"))&.origin
57
+ rescue Web::PublicUrl::Invalid
58
+ nil
59
+ end
60
+
61
+ # @return [Integer] 0 done, 1 refused (a store others can read, a
62
+ # corrupt one, an unknown device, a failed test), 2 usage
63
+ def run
64
+ @sub = @argv[0]
65
+ if @sub.nil? || HELP_WORDS.include?(@sub)
66
+ @stdout.puts(USAGE)
67
+ return 0
68
+ end
69
+ return usage_error("unknown subcommand #{@sub}") unless SUBCOMMANDS.include?(@sub)
70
+
71
+ parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
72
+ return parsed if parsed.is_a?(Integer)
73
+
74
+ send(:"run_#{@sub}", parsed.options, parsed.args)
75
+ rescue Web::Push::Store::Insecure, Web::Push::Store::Corrupt, Web::Push::Refused => e
76
+ error_line("#{command_name}: #{e.message}")
77
+ CLI::Exit::FAILED
78
+ end
79
+
80
+ private
81
+
82
+ def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi web push #{@sub}" : "chi web push"
83
+ def usage_on_error = nil
84
+
85
+ def run_ls(options, args)
86
+ return usage_error("ls takes no arguments") unless args.empty?
87
+
88
+ format = options[:format]
89
+ return usage_error("unknown format #{format.inspect}: use --format text|json") unless [nil, "text", "json"].include?(format)
90
+
91
+ subs = devices
92
+ origin = @origin.call
93
+ if format == "json"
94
+ @stdout.puts(JSON.generate(subs.map { |sub| sub.public_h.merge(stale: stale?(sub, origin)) }))
95
+ elsif subs.empty?
96
+ @stdout.puts("no devices (turn the bell on in the phone's Home Screen app)")
97
+ else
98
+ print_table(subs, origin)
99
+ end
100
+ 0
101
+ end
102
+
103
+ def run_rm(_options, args)
104
+ return usage_error("give one device ID (chi web push ls)") unless args.size == 1
105
+
106
+ devices
107
+ id = args.first
108
+ gone = Web::Push::Store.short_id?(id) && @store.remove_device(id)
109
+ return no_device(id) unless gone
110
+
111
+ @stdout.puts("#{gone.id} #{gone.label} removed")
112
+ 0
113
+ end
114
+
115
+ def run_test(_options, args)
116
+ return usage_error("give at most one device ID (chi web push ls)") if args.size > 1
117
+
118
+ subs = devices
119
+ if args.empty?
120
+ origin = @origin.call
121
+ subs = subs.reject { |sub| stale?(sub, origin) }
122
+ if subs.empty?
123
+ error_line("#{command_name}: no device to push to (chi web push ls)")
124
+ return CLI::Exit::FAILED
125
+ end
126
+ else
127
+ subs = subs.select { |sub| sub.id == args.first }
128
+ return no_device(args.first) if subs.empty?
129
+ end
130
+ results = subs.map { |sub| test(sub) }
131
+ results.all? ? 0 : CLI::Exit::FAILED
132
+ end
133
+
134
+ # One test push, its line printed; a :gone device is removed (as the
135
+ # route does). @return [Boolean] delivered
136
+ def test(sub)
137
+ result = client_for(sub).deliver(sub, Web::Push::TEST_PAYLOAD, ttl: Web::Push::TEST_TTL, urgency: "high")
138
+ words = "#{result.status} #{result.code}#{" (#{result.ms} ms)" if result.ms}"
139
+ words += ": #{result.error}" if result.error && !result.ok?
140
+ if result.status == :gone
141
+ @store.remove(sub.id)
142
+ words += ": removed (the push service no longer knows it)"
143
+ else
144
+ @store.mark(sub.id, ok: result.ok?, error: result.ok? ? nil : (result.error || result.code))
145
+ end
146
+ @stdout.puts("#{sub.id} #{sub.label} #{words}")
147
+ result.ok?
148
+ end
149
+
150
+ def client_for(sub)
151
+ return @client.call(sub) if @client
152
+
153
+ Web::Push::Client.new(key: Web::Push.load_key(@store), contact: Web::Push.contact(sub.origin))
154
+ end
155
+
156
+ # The store's devices, after its permission check.
157
+ def devices
158
+ @store.check_permissions!
159
+ @store.subscriptions!
160
+ end
161
+
162
+ def no_device(id)
163
+ error_line("#{command_name}: no device #{id[0, 16]} (chi web push ls)")
164
+ CLI::Exit::FAILED
165
+ end
166
+
167
+ # Stale: made on another origin than chi web's now; none is when that
168
+ # isn't known.
169
+ def stale?(sub, origin) = !origin.nil? && sub.origin != origin
170
+
171
+ def print_table(subs, origin)
172
+ rows = subs.map do |sub|
173
+ [sub.id, sub.label.to_s, sub.origin.to_s, when_text(sub.created_at), when_text(sub.last_ok_at) || "never",
174
+ sub.done? ? "on" : "off", [stale?(sub, origin) ? "stale" : nil, sub.last_error].compact.join(", ")]
175
+ end
176
+ rows.unshift(["ID", "LABEL", "ORIGIN", "ADDED", "LAST OK", "DONE", "NOTE"])
177
+ widths = rows.transpose.map { |column| column.map(&:length).max }
178
+ rows.each { |row| @stdout.puts(row.zip(widths).map { |cell, width| cell.ljust(width) }.join(" ").rstrip) }
179
+ end
180
+
181
+ # An iso time as local "YYYY-MM-DD HH:MM"; nil for none.
182
+ def when_text(iso)
183
+ iso && Time.iso8601(iso).localtime.strftime("%Y-%m-%d %H:%M")
184
+ rescue ArgumentError
185
+ iso
186
+ end
187
+ end
188
+ end
@@ -154,6 +154,7 @@ module Samagotchi
154
154
  chi bootstrap [HOST[:PORT]|URL] first setup: find the model server, write config.yml
155
155
  chi scratch [options] a one-time session in this terminal: nothing is kept
156
156
  chi web [--port PORT] [--open] [--scope=all] the web UI
157
+ chi web push <ls|rm|test> the phones chi web pushes notifications to
157
158
  chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean|stats>
158
159
  chi note [--source NAME] [-m TEXT] ID...|--all background context for sessions
159
160
  chi broadcast [-m TEXT] [--all] [--dry-run] | log | deliver a note for every session it may concern
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.112.0"
4
+ VERSION = "0.113.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -273,6 +273,7 @@ module Samagotchi
273
273
  ["DELETE", %r{\A/api/push/subscriptions\z}, :handle_push_unsubscribe, :owner],
274
274
  # The id checked in the handler (participant_gate_spec fills ([^/]+) captures only).
275
275
  ["PATCH", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_prefs, :owner],
276
+ ["DELETE", %r{\A/api/push/subscriptions/([^/]+)\z}, :handle_push_remove, :owner],
276
277
  ["POST", %r{\A/api/push/test\z}, :handle_push_test, :owner],
277
278
  ["POST", %r{\A/api/push/presence\z}, :handle_push_presence, :owner],
278
279
  ["GET", %r{\A/api/sessions/([^/]+)\z}, :handle_show, :participant, :session],
@@ -617,16 +618,58 @@ module Samagotchi
617
618
  log_refused(refused_peer(req), req.path_info, reason)
618
619
  return error_response(401, "unauthorized", unauthorized_detail(req, reason)) if req.path_info.start_with?("/api/")
619
620
 
620
- body = @public_url ? PUBLIC_UNAUTHORIZED_PAGE : UNAUTHORIZED_PAGE
621
+ body = unauthorized_page(req)
621
622
  [401, { "Content-Type" => "text/html; charset=utf-8", "Content-Length" => body.bytesize.to_s, "Cache-Control" => "no-store" },
622
623
  [body]]
623
624
  end
624
625
 
626
+ # Which page a refused page request gets: the LAN page with no
627
+ # web.public_url, the proxy page for a request that came through it,
628
+ # and for a browser on this machine the one pointing at the public
629
+ # address (#direct_public?): there the paste box can't help.
630
+ def unauthorized_page(req)
631
+ return direct_public_page if direct_public?(req)
632
+ return PUBLIC_UNAUTHORIZED_PAGE if @public_url
633
+
634
+ UNAUTHORIZED_PAGE
635
+ end
636
+
637
+ # A browser on this machine, with web.public_url set: the gate refuses
638
+ # it like every other request, and no trade can fix it here — loopback
639
+ # takes the token as Bearer only, so there is no cookie for it to keep
640
+ # (a cookie for 127.0.0.1 reaches every server on any local port). The
641
+ # page says where to open chi web instead. The origin is this machine's
642
+ # config, not a secret; it is escaped all the same, and no token or
643
+ # link code is in it.
644
+ def direct_public_page
645
+ origin = CGI.escapeHTML(@public_url.origin)
646
+ <<~HTML
647
+ <!doctype html>
648
+ <html lang="en"><head><meta charset="utf-8">
649
+ <meta name="viewport" content="width=device-width, initial-scale=1">
650
+ <title>chi web: open it at its public address</title>
651
+ <style>
652
+ :root { color-scheme: light dark; }
653
+ body { font: 16px/1.5 -apple-system, system-ui, sans-serif; max-width: 30rem; margin: 3rem auto; padding: 0 1rem; }
654
+ a { color: inherit; }
655
+ </style></head>
656
+ <body>
657
+ <h1>chi web</h1>
658
+ <p>chi web is behind an access proxy, so every request needs its access token, this machine's too.
659
+ A browser here can't trade a token for a cookie on 127.0.0.1, so this page can't let you in: open
660
+ chi web at its public address instead.</p>
661
+ <p><a href="#{origin}/">#{origin}/</a></p>
662
+ <p>chi web prints a link to it in its terminal every time it starts.</p>
663
+ </body></html>
664
+ HTML
665
+ end
666
+
625
667
  def unauthorized_detail(req, reason)
626
668
  return "chi web behind the proxy: #{reason || "needs its access token"}" if proxied?(req)
627
- return "chi web with web.public_url needs its access token, on this machine too" if @public_url
669
+ return "chi web on the LAN needs its access token" unless @public_url
628
670
 
629
- "chi web on the LAN needs its access token"
671
+ detail = "chi web with web.public_url needs its access token, on this machine too"
672
+ direct_public?(req) ? "#{detail}; open its public address #{@public_url.origin}/ instead" : detail
630
673
  end
631
674
 
632
675
  # Who a refusal is logged as: the peer, or behind a proxy (every peer
@@ -1183,6 +1183,37 @@ body.has-session.composer-folded #infoBar{border-top-color:transparent}
1183
1183
  .pp-foot{display:flex;align-items:center;gap:8px;justify-content:flex-end;padding-top:4px}
1184
1184
  .pp-view{font-size:12px;color:var(--accent);text-decoration:none}
1185
1185
  .pp-view:hover{text-decoration:underline}
1186
+ /* The Home Screen app's notifications sheet (push_devices.js): the bell opens it; a bottom sheet on a phone. */
1187
+ .push-devices .ctx-pop-head strong{font-family:inherit}
1188
+ .push-devices.sheet{border-radius:14px;padding:14px 16px;font-size:14px}
1189
+ .pd-list{list-style:none;display:flex;flex-direction:column;gap:8px;margin:2px 0;padding:0}
1190
+ .pd-row{display:grid;grid-template-columns:1fr auto;align-items:center;gap:2px 10px;padding:8px 10px;border-radius:10px;background:var(--bg-inset)}
1191
+ .pd-who{display:flex;align-items:center;gap:6px;min-width:0;color:var(--text)}
1192
+ .pd-who b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1193
+ .pd-tag{font-size:10.5px;padding:0 6px;border-radius:999px;background:var(--accent-bg);color:var(--accent);white-space:nowrap}
1194
+ .pd-act{grid-column:2;grid-row:1/3;display:flex;align-items:center}
1195
+ .pd-meta{grid-column:1;font-size:11.5px;color:var(--text-muted)}
1196
+ .pd-error{color:var(--danger-text)}
1197
+ .pd-done{grid-column:1/3;display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;padding-top:6px;border-top:1px solid var(--border);font-size:12px;color:var(--text-3)}
1198
+ .pd-remove{padding:3px 10px;border-radius:7px;font-size:12px;background:var(--bg-raised);color:var(--danger-text);border:1px solid var(--border-strong);cursor:pointer}
1199
+ .pd-remove:hover{color:var(--text-hover)}
1200
+ .pd-switch{appearance:none;-webkit-appearance:none;position:relative;flex-shrink:0;width:34px;height:20px;margin:0;border-radius:999px;background:var(--bg-raised);border:1px solid var(--border-strong);cursor:pointer;transition:background .15s}
1201
+ .pd-switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--text-muted);transition:transform .15s,background .15s}
1202
+ .pd-switch:checked{background:var(--accent-strong);border-color:var(--accent-strong)}
1203
+ .pd-switch:checked::after{transform:translateX(14px);background:var(--on-solid)}
1204
+ .pd-switch:disabled{opacity:.5;cursor:default}
1205
+ .pd-switch:focus-visible{outline:2px solid var(--accent-ring);outline-offset:2px}
1206
+ .pd-done .pd-switch{width:30px;height:18px}
1207
+ .pd-done .pd-switch::after{width:12px;height:12px}
1208
+ .pd-done .pd-switch:checked::after{transform:translateX(12px)}
1209
+ .pd-note{font-size:11.5px;color:var(--text-muted)}
1210
+ .pd-note.pd-error{color:var(--danger-text)}
1211
+ .push-devices.sheet .pd-switch{width:42px;height:26px}
1212
+ .push-devices.sheet .pd-switch::after{width:20px;height:20px}
1213
+ .push-devices.sheet .pd-switch:checked::after{transform:translateX(16px)}
1214
+ .push-devices.sheet .pd-done{font-size:13px}
1215
+ .push-devices.sheet .pd-meta,.push-devices.sheet .pd-note{font-size:12.5px}
1216
+ .push-devices.sheet .pd-remove{padding:6px 12px;font-size:13px}
1186
1217
  .ctx-pop-head{display:flex;align-items:baseline;gap:8px}
1187
1218
  .ctx-pop-head strong{font-family:ui-monospace,monospace;color:var(--text-bright)}
1188
1219
  .ctx-pop-kind{font-size:11px;color:var(--text-muted)}
@@ -12,7 +12,9 @@
12
12
  // leaves the bell as it is in a tab. With the bell on, the app also tidies
13
13
  // the device (syncDevice): it closes the pushed notifications whose
14
14
  // question or card isn't open any more and sets the app's badge, as chi
15
- // web sends no close push (WebKit's silent-push quota).
15
+ // web sends no close push (WebKit's silent-push quota). There the bell
16
+ // opens the notifications sheet (push_devices.js): this device's switch,
17
+ // the other devices with their "long turns done" switch and Remove.
16
18
  //
17
19
  // Every page, in a tab or the app, also tells chi web whether the user is
18
20
  // at it (POST /api/push/presence, reportPresence): chi web pushes nothing
@@ -30,6 +32,7 @@ import {
30
32
  import {
31
33
  isWaitingTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
32
34
  } from "./push.js";
35
+ import { createPushDevices } from "./push_devices.js";
33
36
 
34
37
  export const NOTIFY_KEY = "chi_notify";
35
38
  // The device id chi web gave this app's subscription (POST subscribe's
@@ -53,8 +56,8 @@ const NOTIFY_TITLES = {
53
56
  };
54
57
 
55
58
  const PUSH_TITLES = {
56
- on: "Notifications to this phone: on (a session that needs you or failed, also while the app is closed). Tap to turn off",
57
- off: "Notify this phone when a session needs you or a turn fails, also while the app is closed",
59
+ on: "Notifications to this phone: on (a session that needs you or failed, also while the app is closed). Tap for the devices",
60
+ off: "Notify this phone when a session needs you or a turn fails, also while the app is closed. Tap to turn it on",
58
61
  denied: "Notifications are blocked for this app in the phone's settings",
59
62
  };
60
63
 
@@ -108,6 +111,8 @@ function defaultChannel() {
108
111
  // @param sendPresence (body, leaving) => void (presenceSender's by default)
109
112
  // @param every (fn, ms) => void, a repeating timer (presence's tick)
110
113
  // @param pageId this page load's id in its presence reports
114
+ // @param devices (hooks) => the notifications sheet ({toggle, refresh}),
115
+ // made once chi web says it pushes (push_devices.js by default)
111
116
  // @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
112
117
  export function createNotifications({
113
118
  button,
@@ -124,12 +129,15 @@ export function createNotifications({
124
129
  sendPresence = presenceSender({ nav, api }),
125
130
  every = (fn, ms) => setInterval(fn, ms),
126
131
  pageId = randomTabId(),
132
+ devices = (hooks) => createPushDevices({ ...hooks, doc, win }),
127
133
  }) {
128
134
  let attention = initialAttentionState();
129
135
  // Web Push in the Home Screen app: null until chi web says it pushes
130
136
  // ({reg, key, on}), and for good in a tab or without push.
131
137
  let push = null;
132
138
  let pushBusy = false;
139
+ // The notifications sheet the bell opens in push mode.
140
+ let sheet = null;
133
141
  let syncing = null;
134
142
  let syncAgain = false;
135
143
  // When this page last had the browser check for a newer sw.js.
@@ -186,7 +194,7 @@ export function createNotifications({
186
194
  }
187
195
 
188
196
  async function toggleNotify() {
189
- if (push) return togglePush();
197
+ if (push) return sheet?.toggle();
190
198
  const state = notifyState();
191
199
  if (state === "unsupported" || state === "denied") return;
192
200
  let wanted = state !== "on";
@@ -197,10 +205,11 @@ export function createNotifications({
197
205
  renderNotifyBtn();
198
206
  }
199
207
 
200
- // This device's subscription on (permission from the tap: iOS asks only
201
- // from one), a test push to show it works; or off.
208
+ // This device's subscription on (permission from the tap on the sheet's
209
+ // switch: iOS asks only from one), a test push to show it works; or off.
210
+ // @return the failure's message, or null
202
211
  async function togglePush() {
203
- if (pushBusy || notifyState() === "denied") return;
212
+ if (pushBusy || notifyState() === "denied") return null;
204
213
  pushBusy = true;
205
214
  let failed = null;
206
215
  try {
@@ -228,8 +237,9 @@ export function createNotifications({
228
237
  pushBusy = false;
229
238
  }
230
239
  renderNotifyBtn();
231
- // Said on the bell (its tooltip); the next tap tries again.
240
+ // Said on the bell (its tooltip) and in the sheet; the next tap tries again.
232
241
  if (failed) button.title = `Notifications to this phone failed: ${failed}`;
242
+ return failed && `Notifications to this phone failed: ${failed}`;
233
243
  }
234
244
 
235
245
  // The Home Screen app: its service worker, chi web's key, and whether
@@ -243,6 +253,10 @@ export function createNotifications({
243
253
  const info = await api("GET", `/api/push${query}`);
244
254
  if (!info?.enabled || !info.public_key) return;
245
255
  push = { reg, key: info.public_key, on: !!(sub && info.this) };
256
+ sheet = devices({
257
+ button, api, state: notifyState, toggleThis: togglePush,
258
+ endpoint: async () => (await push.reg.pushManager.getSubscription())?.endpoint || null,
259
+ });
246
260
  // register() has just checked for a newer sw.js.
247
261
  swCheckedAt = now();
248
262
  renderNotifyBtn();
@@ -0,0 +1,187 @@
1
+ // The Home Screen app's notifications sheet (Web Push; notifications.js
2
+ // opens it from the bell when chi web pushes): this device's switch (the
3
+ // bell's subscribe / unsubscribe), then every device chi web pushes to from
4
+ // this origin (GET /api/push's devices): its label, "this device", the last
5
+ // delivery and the last error, a "Long turns done" switch (PATCH
6
+ // /api/push/subscriptions/ID {prefs: {done}}), Remove for another device
7
+ // (DELETE /api/push/subscriptions/ID), and the stale ones (another origin's,
8
+ // never sent to) as one line. A phone gets it as a bottom sheet, a wider
9
+ // window as a popover at the bell (chip_popover.js), small enough to need
10
+ // no scrolling for a few devices.
11
+ //
12
+ // This device's row has the switch and never Remove: turning it off is the
13
+ // bell turned off (the page forgets its device id), so the page's state and
14
+ // chi web's store stay one. A device iOS revoked silently still shows a
15
+ // recent delivery (Apple keeps answering 201): the list can't tell; switch
16
+ // it off and on there.
17
+ //
18
+ // Pure templates and models, plus createPushDevices (the injected api and
19
+ // the popover shell), so the specs run them under node --test.
20
+
21
+ import { html, nothing, render } from "./vendor/lit-html.js";
22
+ import { createChipPopover } from "./chip_popover.js";
23
+ import { relativeTime } from "./format.js";
24
+
25
+ export const DEVICE_PATH = "/api/push/subscriptions";
26
+
27
+ // The rows GET /api/push's +info+ gives, this device first.
28
+ // @return [{id, label, this, last, error, done}]
29
+ export function deviceRows(info, now) {
30
+ const devices = Array.isArray(info?.devices) ? info.devices : [];
31
+ const rows = devices.map((d) => ({
32
+ id: String(d.id || ""),
33
+ label: d.label || d.id || "device",
34
+ this: d.this === true,
35
+ last: d.last_ok_at ? `last delivery ${relativeTime(d.last_ok_at, now)}` : "no delivery yet",
36
+ error: d.last_error ? `last error ${d.last_error}` : "",
37
+ done: d.prefs?.done === true,
38
+ }));
39
+ return [...rows.filter((r) => r.this), ...rows.filter((r) => !r.this)];
40
+ }
41
+
42
+ // "2 more devices from an earlier address, never sent to" (another origin's:
43
+ // a renamed machine or tailnet), "" for none.
44
+ export function staleText(count) {
45
+ const n = Number(count) || 0;
46
+ if (n <= 0) return "";
47
+ return `${n === 1 ? "1 more device" : `${n} more devices`} from an earlier address, never sent to (chi web push rm)`;
48
+ }
49
+
50
+ // What the sheet draws: +state+ the bell's ("on" | "off" | "denied"),
51
+ // +info+ GET /api/push's answer (null before the first), +busy+ a change
52
+ // in flight, +error+ the last one's failure.
53
+ export function sheetModel({ state, info, busy = false, error = "", now = Date.now() }) {
54
+ const rows = deviceRows(info, now);
55
+ const mine = rows.find((r) => r.this) || null;
56
+ const thisRow = mine || {
57
+ id: "", label: "This device", this: true, done: false, error: "",
58
+ last: state === "denied" ? "notifications are blocked for this app in the phone's settings" : "off: nothing is pushed here",
59
+ };
60
+ return {
61
+ on: state === "on" && !!mine,
62
+ denied: state === "denied",
63
+ busy,
64
+ error,
65
+ thisRow,
66
+ others: rows.filter((r) => !r.this),
67
+ stale: staleText(info?.stale),
68
+ loading: !info,
69
+ };
70
+ }
71
+
72
+ const doneSwitch = (row, busy) => html`<label class="pd-done"><span>Long turns done (10 s or more)</span
73
+ ><input type="checkbox" class="pd-switch" role="switch" data-pd="done" data-id=${row.id} .checked=${row.done}
74
+ ?disabled=${busy}></label>`;
75
+
76
+ function rowTemplate(row, model) {
77
+ const mine = row.this;
78
+ const subscribed = !mine || model.on;
79
+ return html`<li class=${`pd-row${mine ? " this" : ""}`} data-id=${row.id || nothing}>
80
+ <div class="pd-who"><b>${row.label}</b>${mine ? html`<span class="pd-tag">this device</span>` : nothing}</div>
81
+ <div class="pd-act">${mine
82
+ ? html`<input type="checkbox" class="pd-switch" role="switch" data-pd="this" aria-label="Notifications to this device"
83
+ .checked=${model.on} ?disabled=${model.busy || model.denied}>`
84
+ : html`<button type="button" class="pd-remove" data-pd="remove" data-id=${row.id} ?disabled=${model.busy}>Remove</button>`}</div>
85
+ <div class="pd-meta">${row.last}${row.error ? html` · <span class="pd-error">${row.error}</span>` : nothing}</div>
86
+ ${subscribed ? doneSwitch(row, model.busy) : nothing}
87
+ </li>`;
88
+ }
89
+
90
+ // The sheet's content (a lit-html template).
91
+ export function sheetTemplate(model) {
92
+ return html`<div class="ctx-pop-head"><strong>Notifications</strong><span class="ctx-pop-kind">devices</span
93
+ ><button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div
94
+ ><ul class="pd-list">${rowTemplate(model.thisRow, model)}${model.others.map((row) => rowTemplate(row, model))}</ul
95
+ >${model.loading ? html`<div class="pd-note">Loading the devices…</div>` : nothing
96
+ }${model.stale ? html`<div class="pd-note">${model.stale}</div>` : nothing
97
+ }${model.error ? html`<div class="pd-note pd-error" role="alert">${model.error}</div>` : nothing
98
+ }<div class="pd-note">Approvals, questions, step limits, cards and failed turns; quiet while a chi web page is in front.</div>`;
99
+ }
100
+
101
+ // The sheet at the bell. +state+() the bell's state; +endpoint+() this
102
+ // device's push endpoint (a Promise; null without a subscription);
103
+ // +toggleThis+() the bell's subscribe / unsubscribe (resolves to its
104
+ // failure's message, or null); +api+ chi web's JSON (notifications.js's).
105
+ // @return {toggle(), refresh(), isOpen(), pop}
106
+ export function createPushDevices({ button, api, state, endpoint, toggleThis, now = () => Date.now(),
107
+ doc = globalThis.document, win = globalThis.window }) {
108
+ let info = null;
109
+ let busy = false;
110
+ let error = "";
111
+ // Each load's turn: an older answer that arrives late is dropped.
112
+ let loads = 0;
113
+
114
+ const draw = () => {
115
+ if (shell.isOpen()) render(sheetTemplate(sheetModel({ state: state(), info, busy, error, now: now() })), shell.pop);
116
+ };
117
+
118
+ const shell = createChipPopover({
119
+ id: "pushDevices", className: "ctx-popover push-devices", width: 340, el: doc.createElement("span"),
120
+ anchor: "#notifyBtn", openers: "#notifyBtn", summary: () => ({}), sheet: true, label: "Notifications", doc, win,
121
+ show: () => render(sheetTemplate(sheetModel({ state: state(), info, busy, error, now: now() })), shell.pop),
122
+ onClose: () => { error = ""; },
123
+ });
124
+
125
+ async function load() {
126
+ const turn = ++loads;
127
+ try {
128
+ const at = await endpoint();
129
+ const answer = await api("GET", `/api/push${at ? `?endpoint=${encodeURIComponent(at)}` : ""}`);
130
+ if (turn !== loads) return;
131
+ info = answer;
132
+ } catch (e) {
133
+ if (turn !== loads) return;
134
+ error = `Can't read the devices: ${e?.message || e}`;
135
+ }
136
+ draw();
137
+ shell.replace();
138
+ }
139
+
140
+ // One change at a time; the list is read again after it.
141
+ async function change(run) {
142
+ if (busy) return;
143
+ busy = true;
144
+ error = "";
145
+ draw();
146
+ try {
147
+ error = (await run()) || "";
148
+ } catch (e) {
149
+ error = e?.message || String(e);
150
+ } finally {
151
+ busy = false;
152
+ }
153
+ await load();
154
+ }
155
+
156
+ shell.pop.addEventListener("change", (e) => {
157
+ const input = e.target.closest?.("[data-pd]");
158
+ if (!input) return;
159
+ if (input.dataset.pd === "this") change(toggleThis);
160
+ else if (input.dataset.pd === "done") {
161
+ const id = input.dataset.id;
162
+ const done = input.checked;
163
+ change(() => api("PATCH", `${DEVICE_PATH}/${encodeURIComponent(id)}`, { prefs: { done } }).then(() => null));
164
+ }
165
+ });
166
+ shell.pop.addEventListener("click", (e) => {
167
+ const remove = e.target.closest?.('[data-pd="remove"]');
168
+ if (!remove) return;
169
+ const id = remove.dataset.id;
170
+ change(() => api("DELETE", `${DEVICE_PATH}/${encodeURIComponent(id)}`).then(() => null));
171
+ });
172
+
173
+ return {
174
+ pop: shell.pop,
175
+ isOpen: shell.isOpen,
176
+ // The bell's tap: open (drawn from what is known, then read again) or close.
177
+ toggle() {
178
+ if (shell.isOpen()) return shell.close();
179
+ shell.open(button);
180
+ return load();
181
+ },
182
+ // The bell's state changed elsewhere: redraw an open sheet.
183
+ refresh: draw,
184
+ // Read the devices again (specs await it).
185
+ load,
186
+ };
187
+ }
@@ -163,6 +163,10 @@ module Samagotchi
163
163
  []
164
164
  end
165
165
 
166
+ # As #subscriptions, but a corrupt file raises (chi web push says so).
167
+ # @raise [Corrupt]
168
+ def subscriptions! = read
169
+
166
170
  # The subscriptions for +origin+ (the ones chi web sends to now).
167
171
  def for_origin(origin) = subscriptions.select { |sub| sub.origin == origin }
168
172
 
@@ -206,6 +210,16 @@ module Samagotchi
206
210
  end
207
211
  end
208
212
 
213
+ # Removes the device with this short id, only one subscribed on
214
+ # +origin+ (nil: any; chi web push rm).
215
+ # @return [Sub, nil] the one removed
216
+ def remove_device(id, origin: nil)
217
+ update do |subs|
218
+ gone = subs.find { |sub| sub.id == id && (origin.nil? || sub.origin == origin) }
219
+ [subs - [gone], gone]
220
+ end
221
+ end
222
+
209
223
  # Sets the device's prefs (+done+: a push for a long turn done).
210
224
  # @param origin [String, nil] only a device subscribed there
211
225
  # @return [Sub, nil] the device as it is now, nil when there is none
@@ -29,6 +29,11 @@ module Samagotchi
29
29
  def initialize(presence: Presence.new, **) = super
30
30
  end
31
31
 
32
+ # The test push (POST /api/push/test, chi web push test): no badge
33
+ # (it would clear the app's) and no open list (no cleanup).
34
+ TEST_PAYLOAD = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }.freeze
35
+ TEST_TTL = 600
36
+
32
37
  module_function
33
38
 
34
39
  # Push runs for +public_url+ (nil: none) when web.push.enabled.
@@ -67,12 +67,25 @@ module Samagotchi
67
67
  end
68
68
 
69
69
  sub = Push::Store.short_id?(id) && @push.store.set_prefs(id, done: prefs["done"], origin: @push.origin)
70
- return error_response(404, "unknown_device", "no device #{id[0, 16]} pushes from this origin") unless sub
70
+ return unknown_device(id) unless sub
71
71
 
72
72
  Log.info(:web, "push_prefs", device: sub.id, done: sub.done?)
73
73
  json_response(200, sub.public_h)
74
74
  end
75
75
 
76
+ # DELETE …/subscriptions/ID → 204: the devices list's Remove for
77
+ # another device (this device's row turns the bell off instead, so
78
+ # the page and the store agree).
79
+ def handle_push_remove(_req, id)
80
+ return push_off unless @push
81
+
82
+ sub = Push::Store.short_id?(id) && @push.store.remove_device(id, origin: @push.origin)
83
+ return unknown_device(id) unless sub
84
+
85
+ Log.info(:web, "push_removed", device: sub.id)
86
+ [204, { "Cache-Control" => "no-store" }, []]
87
+ end
88
+
76
89
  # {endpoint} → 204: this device's bell turned off.
77
90
  def handle_push_unsubscribe(req)
78
91
  return push_off unless @push
@@ -95,9 +108,8 @@ module Samagotchi
95
108
  endpoint = body["endpoint"] if body.is_a?(Hash)
96
109
  subs = @push.store.for_origin(@push.origin)
97
110
  subs = subs.select { |sub| sub.endpoint == endpoint } if endpoint.is_a?(String) && !endpoint.empty?
98
- payload = { v: 1, kind: "show", tag: "chi:test", title: "chi", body: "test notification", url: "/" }
99
111
  results = subs.map do |sub|
100
- result = @push.client.deliver(sub, payload, ttl: 600, urgency: "high")
112
+ result = @push.client.deliver(sub, Push::TEST_PAYLOAD, ttl: Push::TEST_TTL, urgency: "high")
101
113
  Log.info(:web, "push_test", device: sub.id, code: result.code, ms: result.ms)
102
114
  @push.store.remove(sub.id) if result.status == :gone
103
115
  { id: sub.id, label: sub.label, status: result.status.to_s, code: result.code, ms: result.ms }
@@ -116,6 +128,8 @@ module Samagotchi
116
128
  [204, { "Cache-Control" => "no-store" }, []]
117
129
  end
118
130
 
131
+ def unknown_device(id) = error_response(404, "unknown_device", "no device #{id[0, 16]} pushes from this origin")
132
+
119
133
  def push_off
120
134
  error_response(409, "push_off", "push needs an https public URL (chi web --tunnel tailscale, or web.public_url) " \
121
135
  "and web.push.enabled")
@@ -130,7 +130,9 @@ module Samagotchi
130
130
  annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
131
131
  public_url: public_url }
132
132
  started = if plan
133
- with_tunnel(provider, plan) { start(**options, public_notes: provider.start_lines(plan)) }
133
+ with_tunnel(provider, plan) do |up|
134
+ start(**options, public_notes: provider.start_lines(plan), on_bound: up)
135
+ end
134
136
  else
135
137
  start(**options)
136
138
  end
@@ -147,25 +149,43 @@ module Samagotchi
147
149
  end
148
150
 
149
151
  # The tunnel on while the block runs (the server), off after it
150
- # whatever ends it (Ctrl-C, SIGTERM, SIGHUP, a port taken), and only
151
- # once #up succeeded: a refusal before it, or a failed #up, turns off
152
- # nothing (a live tunnel of another chi web stays).
152
+ # whatever ends it (Ctrl-C, SIGTERM, SIGHUP), once #up may have taken
153
+ # effect: a refusal before it turns off nothing (a live tunnel of
154
+ # another chi web stays). The block gets #up to call once the port is
155
+ # bound (start's on_bound), so Serve never forwards the tailnet to a
156
+ # listener that took the port first. A signal during #up waits for it
157
+ # (its command runs in its own process group, and Serve keeps what it
158
+ # set even if chi stopped waiting), so it can't land between the serve
159
+ # going on and the teardown being due; #up is bounded by its timeout.
153
160
  def self.with_tunnel(provider, plan)
154
- provider.up(plan)
155
- upped = true
156
- yield
161
+ attempted = false
162
+ yield(lambda do
163
+ Thread.handle_interrupt(SignalException => :never) do
164
+ attempted = true
165
+ provider.up(plan)
166
+ end
167
+ end)
157
168
  ensure
158
- tunnel_down(provider, plan) if upped
169
+ tunnel_down(provider, plan) if attempted
159
170
  end
160
171
 
161
172
  # The tunnel's teardown, before any line (a hung-up terminal can't
162
173
  # write): a failure is one warning line, never a crash or another exit
163
- # status, and a second Ctrl-C during it only cuts it short.
174
+ # status. A second Ctrl-C (SIGTERM, SIGHUP) during it waits for it
175
+ # (bounded by its commands' timeouts) and is then dropped: the run
176
+ # already ends the way the first signal ends it.
164
177
  def self.tunnel_down(provider, plan)
165
- line = begin
166
- provider.down_line(plan, provider.down(plan))
167
- rescue Tunnel::Refused, SignalException => e
168
- provider.down_warning(plan, e)
178
+ line = nil
179
+ begin
180
+ Thread.handle_interrupt(SignalException => :never) do
181
+ line = begin
182
+ provider.down_line(plan, provider.down(plan))
183
+ rescue Tunnel::Refused => e
184
+ provider.down_warning(plan, e)
185
+ end
186
+ end
187
+ rescue SignalException
188
+ nil
169
189
  end
170
190
  say(line) if line
171
191
  end
@@ -187,11 +207,14 @@ module Samagotchi
187
207
  # (web.public_url, or --tunnel's): every request needs the access token
188
208
  # @param public_notes [Array<String>] lines after the proxied link's
189
209
  # (the tunnel's)
210
+ # @param on_bound [#call, nil] run once the port is bound, before any
211
+ # line (the tunnel's #up); what it raises ends the run
190
212
  # @return [Boolean] false when the port was taken (said so on stderr)
191
213
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
192
214
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
193
215
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
194
- working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [])
216
+ working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [],
217
+ on_bound: nil)
195
218
  config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
196
219
  Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
197
220
  end
@@ -227,7 +250,11 @@ module Samagotchi
227
250
  # Said once the port is bound: a second chi web racing for it gets
228
251
  # the in-use line instead.
229
252
  running_path = Running.path(state_dir: state_dir)
253
+ webrick = nil
254
+ callback_unfinished = false
230
255
  started = lambda do
256
+ callback_unfinished = true
257
+ on_bound&.call
231
258
  record_running(running_path, port, public_url)
232
259
  puts "Chi Web on #{url} (public: #{File.expand_path("public", __dir__)})"
233
260
  puts lan_lines(ip: lan.ip, port: port, token: Token.read(token_path), others: lan.others, public: lan.public) if lan
@@ -244,12 +271,14 @@ module Samagotchi
244
271
  open_url(open_target)
245
272
  end
246
273
  end
274
+ callback_unfinished = false
247
275
  end
248
276
 
249
277
  # The event loops (GET /api/events) end once WEBrick leaves
250
278
  # :Running: it joins every request thread before run returns.
251
279
  Rackup::Handler::WEBrick.run(app, Host: host, Port: port, AccessLog: [], Logger: Log.new($stderr, WEBrick::Log::WARN),
252
280
  StartCallback: started) do |server|
281
+ webrick = server
253
282
  app.server_running = -> { server.status == :Running }
254
283
  listen_lan(server, lan, port) if lan
255
284
  end
@@ -261,6 +290,9 @@ module Samagotchi
261
290
  warn "Error: can't listen on #{lan.ip}:#{port} (#{e.message}); did the address change? Run chi web again"
262
291
  false
263
292
  ensure
293
+ # A start callback that raised (on_bound's refusal, a Ctrl-C) leaves
294
+ # WEBrick :Running with its sockets open.
295
+ webrick&.listeners&.each { |socket| socket.close unless socket.closed? } if callback_unfinished
264
296
  Running.clear(path: running_path) if running_path
265
297
  config_restart_unsubscribe&.call
266
298
  notifier&.stop
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.112.0
4
+ version: 0.113.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -503,6 +503,7 @@ files:
503
503
  - lib/samagotchi/prompt_note.rb
504
504
  - lib/samagotchi/prompt_warmup.rb
505
505
  - lib/samagotchi/proxy_trace.rb
506
+ - lib/samagotchi/push_command.rb
506
507
  - lib/samagotchi/question_answer.rb
507
508
  - lib/samagotchi/question_asked.rb
508
509
  - lib/samagotchi/question_desk.rb
@@ -780,6 +781,7 @@ files:
780
781
  - lib/samagotchi/web/public/plain_line.js
781
782
  - lib/samagotchi/web/public/prompt_history.js
782
783
  - lib/samagotchi/web/public/push.js
784
+ - lib/samagotchi/web/public/push_devices.js
783
785
  - lib/samagotchi/web/public/question_card.js
784
786
  - lib/samagotchi/web/public/question_cards.js
785
787
  - lib/samagotchi/web/public/read_view.js