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 +4 -4
- data/CHANGELOG.md +25 -1
- data/bin/chi +8 -0
- data/docs/cli.md +4 -2
- data/docs/configuration.md +20 -9
- data/docs/internals/web-push.md +28 -4
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/push_command.rb +188 -0
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -3
- data/lib/samagotchi/web/public/index.html +31 -0
- data/lib/samagotchi/web/public/notifications.js +22 -8
- data/lib/samagotchi/web/public/push_devices.js +187 -0
- data/lib/samagotchi/web/push/store.rb +14 -0
- data/lib/samagotchi/web/push.rb +5 -0
- data/lib/samagotchi/web/push_routes.rb +17 -3
- data/lib/samagotchi/web/server.rb +46 -14
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc980123c32fef67a2b47defad94735286ba983537b227ba79961cf6b15f1f1a
|
|
4
|
+
data.tar.gz: 5768a7d054c2a36abd746b6a19b7126fa6e992915bbae6040d2990b1903754bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
1183
|
-
|
|
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
|
|
data/docs/configuration.md
CHANGED
|
@@ -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).
|
|
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:
|
|
1737
|
-
|
|
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.
|
|
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.
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
|
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.
|
data/docs/internals/web-push.md
CHANGED
|
@@ -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
|
|
8
|
-
plan
|
|
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
|
|
@@ -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
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
669
|
+
return "chi web on the LAN needs its access token" unless @public_url
|
|
628
670
|
|
|
629
|
-
"chi web
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/samagotchi/web/push.rb
CHANGED
|
@@ -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
|
|
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,
|
|
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)
|
|
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,
|
|
151
|
-
#
|
|
152
|
-
#
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
|
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 =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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.
|
|
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
|