bom-pi 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/apps/README.md +7 -0
- package/apps/web/README.md +21 -0
- package/apps/web/pocket-adapter.mjs +323 -0
- package/apps/web/public/LICENSE.pocket +21 -0
- package/apps/web/public/activity-fold.js +28 -0
- package/apps/web/public/agent-now.js +37 -0
- package/apps/web/public/agent-tile.js +79 -0
- package/apps/web/public/app.js +975 -0
- package/apps/web/public/apple-touch-icon.png +0 -0
- package/apps/web/public/artifact-bridge.js +687 -0
- package/apps/web/public/avatar.js +87 -0
- package/apps/web/public/back.js +554 -0
- package/apps/web/public/box-keys.js +216 -0
- package/apps/web/public/browser-panel.js +1097 -0
- package/apps/web/public/browser.js +68 -0
- package/apps/web/public/calls.js +139 -0
- package/apps/web/public/chat.js +919 -0
- package/apps/web/public/clock.js +34 -0
- package/apps/web/public/commands.js +578 -0
- package/apps/web/public/composer.js +2068 -0
- package/apps/web/public/diff-parse.js +672 -0
- package/apps/web/public/diff.js +1646 -0
- package/apps/web/public/dock.js +299 -0
- package/apps/web/public/extension-ui.js +2734 -0
- package/apps/web/public/files-panel.js +775 -0
- package/apps/web/public/files.js +368 -0
- package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
- package/apps/web/public/fonts/Geist.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
- package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
- package/apps/web/public/fonts/SOURCES.md +69 -0
- package/apps/web/public/gestures.js +610 -0
- package/apps/web/public/highlight.js +480 -0
- package/apps/web/public/history.js +70 -0
- package/apps/web/public/home.js +169 -0
- package/apps/web/public/icon-192.png +0 -0
- package/apps/web/public/icon-512.png +0 -0
- package/apps/web/public/icon-maskable-512.png +0 -0
- package/apps/web/public/icon.svg +16 -0
- package/apps/web/public/index.html +71 -0
- package/apps/web/public/launcher.js +1293 -0
- package/apps/web/public/linked-sessions.js +55 -0
- package/apps/web/public/live.js +39 -0
- package/apps/web/public/manifest.webmanifest +32 -0
- package/apps/web/public/model-refresh.js +73 -0
- package/apps/web/public/notify.js +251 -0
- package/apps/web/public/peeks.js +920 -0
- package/apps/web/public/pip-attachments.js +182 -0
- package/apps/web/public/pip-window.js +141 -0
- package/apps/web/public/pip.js +1217 -0
- package/apps/web/public/quote-reply.js +109 -0
- package/apps/web/public/quote-text.js +31 -0
- package/apps/web/public/rich.js +403 -0
- package/apps/web/public/run-frame.js +106 -0
- package/apps/web/public/safe-mode.js +71 -0
- package/apps/web/public/seen.js +169 -0
- package/apps/web/public/session-tree.js +117 -0
- package/apps/web/public/sessions.js +2695 -0
- package/apps/web/public/share.js +126 -0
- package/apps/web/public/sheets/appearance.js +820 -0
- package/apps/web/public/sheets/branch.js +353 -0
- package/apps/web/public/sheets/extensions.js +818 -0
- package/apps/web/public/sheets/file.js +318 -0
- package/apps/web/public/sheets/find.js +250 -0
- package/apps/web/public/sheets/folder.js +680 -0
- package/apps/web/public/sheets/gallery.js +2340 -0
- package/apps/web/public/sheets/hooks.js +350 -0
- package/apps/web/public/sheets/memory.js +531 -0
- package/apps/web/public/sheets/menu.js +580 -0
- package/apps/web/public/sheets/message.js +220 -0
- package/apps/web/public/sheets/model.js +412 -0
- package/apps/web/public/sheets/people.js +414 -0
- package/apps/web/public/sheets/pi-sessions.js +264 -0
- package/apps/web/public/sheets/providers.js +525 -0
- package/apps/web/public/sheets/running.js +130 -0
- package/apps/web/public/sheets/schedules.js +71 -0
- package/apps/web/public/sheets/session.js +249 -0
- package/apps/web/public/sheets/shortcuts.js +101 -0
- package/apps/web/public/sheets/spend.js +150 -0
- package/apps/web/public/sheets/text.js +53 -0
- package/apps/web/public/sheets/viewers.js +363 -0
- package/apps/web/public/sheets/worktree.js +50 -0
- package/apps/web/public/sheets.js +410 -0
- package/apps/web/public/signin.js +57 -0
- package/apps/web/public/slots.js +208 -0
- package/apps/web/public/store.js +1760 -0
- package/apps/web/public/style.css +18625 -0
- package/apps/web/public/subagents-board.js +879 -0
- package/apps/web/public/subagents.js +273 -0
- package/apps/web/public/sw.js +169 -0
- package/apps/web/public/taps.js +26 -0
- package/apps/web/public/theme-surface.d.ts +4 -0
- package/apps/web/public/theme-surface.js +266 -0
- package/apps/web/public/theme.js +845 -0
- package/apps/web/public/themes/lantern/colors.toml +25 -0
- package/apps/web/public/themes/lantern/direction.css +60 -0
- package/apps/web/public/themes/lantern/frame.css +254 -0
- package/apps/web/public/themes/lantern/theme.css +10 -0
- package/apps/web/public/themes/lantern/theme.json +7 -0
- package/apps/web/public/themes/pocket/theme.css +189 -0
- package/apps/web/public/themes/pocket/theme.json +6 -0
- package/apps/web/public/themes.js +556 -0
- package/apps/web/public/transcript.js +3348 -0
- package/apps/web/public/trust.js +295 -0
- package/apps/web/public/turns.js +507 -0
- package/apps/web/public/ui.js +1910 -0
- package/apps/web/public/voice.js +345 -0
- package/apps/web/server.mjs +774 -0
- package/package.json +43 -4
- package/packages/cli/src/bom-pi.mjs +452 -0
- package/packages/cli/templates/blank.factory.json +7 -0
- package/packages/cli/templates/dev.factory.json +11 -0
- package/packages/cli/templates/docs.factory.json +12 -0
- package/packages/cli/templates/ops.factory.json +14 -0
- package/packages/cli/templates/triage.factory.json +10 -0
- package/packages/core/src/daemon.mjs +2047 -0
- package/packages/core/src/limit-gate.mjs +192 -0
- package/packages/core/src/rpc-client.mjs +66 -0
- package/systemd/README.md +11 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// The subagents bar, above the message box: this session's subagents at a glance. It shows while one works or has a
|
|
2
|
+
// report on its way to Pi, and after they finish until it is put away. Folded, one line: how many work and how many
|
|
3
|
+
// are done, and what the newest one does. Unfolded, a row each: what it was asked, what it does this moment (its peek,
|
|
4
|
+
// as tiles have), for how long, whether its report is on its way, Stop, and a tap to open it.
|
|
5
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
6
|
+
import { nowDoing } from "./agent-now.js";
|
|
7
|
+
import { useOnScreen } from "./peeks.js";
|
|
8
|
+
import { fromServer } from "./seen.js";
|
|
9
|
+
import { api, attempt, canSteer, navigate, openSheet, store } from "./store.js";
|
|
10
|
+
import { html, Icon, Spinner, timeAgo } from "./ui.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* When each session's bar was put away, in this browser, as the newest thing its subagents did then (the server's clock,
|
|
14
|
+
* as theirs is): it shows again once one does something newer.
|
|
15
|
+
*/
|
|
16
|
+
const AWAY_KEY = "pocket.subagentsAway";
|
|
17
|
+
|
|
18
|
+
function awayMarks() {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(localStorage.getItem(AWAY_KEY) ?? "{}");
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The server's time of the newest thing a subagent did: asked, or answered. */
|
|
27
|
+
const newest = (agent) => Math.max(agent.askedAt ?? 0, agent.answeredAt ?? 0);
|
|
28
|
+
|
|
29
|
+
function putAway(id, agents) {
|
|
30
|
+
const marks = { ...awayMarks(), [id]: Math.max(0, ...agents.map(newest)) };
|
|
31
|
+
// The newest few sessions' marks are enough.
|
|
32
|
+
const kept = Object.entries(marks)
|
|
33
|
+
.sort((a, b) => b[1] - a[1])
|
|
34
|
+
.slice(0, 50);
|
|
35
|
+
|
|
36
|
+
localStorage.setItem(AWAY_KEY, JSON.stringify(Object.fromEntries(kept)));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Unfold the bar, as the top bar's count of working subagents does: for this session only. */
|
|
40
|
+
export const openSubagents = () => store.set({ subagentsOpen: store.state.conversationId });
|
|
41
|
+
|
|
42
|
+
/** Where a subagent is: working, done, stopped, or failed. */
|
|
43
|
+
export const stateOf = (agent) =>
|
|
44
|
+
agent.busy ? "working" : agent.stopped ? "stopped" : agent.failed ? "failed" : "done";
|
|
45
|
+
|
|
46
|
+
/** A failed subagent nobody has dealt with yet: it needs someone, on the board, the rail, and the session list. */
|
|
47
|
+
export const unresolved = (agent) => stateOf(agent) === "failed" && agent.dealt !== true;
|
|
48
|
+
|
|
49
|
+
/** Say a subagent's failure was dealt with: the one this browser shows (by its time), so a newer one still shows. */
|
|
50
|
+
export const dismiss = (agent) =>
|
|
51
|
+
api(`c/${agent.conversationId}/dismiss`, { at: agent.answeredAt ?? 0 });
|
|
52
|
+
|
|
53
|
+
/** A failure shown to someone who can deal with it, who opens the subagent: seen, it needs nobody now. */
|
|
54
|
+
export function seeFailure(agent) {
|
|
55
|
+
if (agent && unresolved(agent) && canSteer()) {
|
|
56
|
+
dismiss(agent).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Open a subagent's conversation, from this session's subagents or the board; a failure it had is seen there. */
|
|
61
|
+
export function openSubagent(id) {
|
|
62
|
+
const { boardAgents, view } = store.state;
|
|
63
|
+
|
|
64
|
+
seeFailure(
|
|
65
|
+
[...(view.subagents ?? []), ...(boardAgents ?? [])].find(
|
|
66
|
+
(agent) => agent.conversationId === id,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
navigate(id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The mark a row starts with, for a subagent that is not working. */
|
|
73
|
+
const MARKS = { done: "✓", stopped: "■", failed: "!" };
|
|
74
|
+
|
|
75
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
76
|
+
|
|
77
|
+
/** How long since `ms`, a time the server took: "45s", "3m 05s", "1h 12m". */
|
|
78
|
+
export function elapsed(ms) {
|
|
79
|
+
const seconds = Math.max(0, Math.round((Date.now() - fromServer(ms)) / 1000));
|
|
80
|
+
|
|
81
|
+
if (seconds < 60) {
|
|
82
|
+
return `${seconds}s`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (seconds < 3600) {
|
|
86
|
+
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds / 60) % 60).padStart(2, "0")}m`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The order of the states, in the dots and the rows: working, then trouble, then done. */
|
|
93
|
+
const RANK = { working: 0, failed: 1, stopped: 2, done: 3 };
|
|
94
|
+
|
|
95
|
+
/** By state; working ones newest asked first, the rest newest answer first. */
|
|
96
|
+
export function ordered(agents) {
|
|
97
|
+
return [...agents].sort(
|
|
98
|
+
(a, b) =>
|
|
99
|
+
RANK[stateOf(a)] - RANK[stateOf(b)] ||
|
|
100
|
+
(a.busy
|
|
101
|
+
? (b.askedAt ?? 0) - (a.askedAt ?? 0)
|
|
102
|
+
: (b.answeredAt ?? 0) - (a.answeredAt ?? 0)),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function AgentRow({ agent }) {
|
|
107
|
+
const stop = () => attempt(() => api(`c/${agent.conversationId}/abort`, {}));
|
|
108
|
+
const status = stateOf(agent);
|
|
109
|
+
// A failure someone dealt with says so, as the board does.
|
|
110
|
+
const dealt = status === "failed" && agent.dealt ? " · dismissed" : "";
|
|
111
|
+
const when = agent.busy
|
|
112
|
+
? agent.askedAt && elapsed(agent.askedAt)
|
|
113
|
+
: agent.answeredAt && `${status} ${timeAgo(fromServer(agent.answeredAt))}${dealt}`;
|
|
114
|
+
|
|
115
|
+
return html`<div class=${`agent-row ${status}`} data-peek=${agent.conversationId}>
|
|
116
|
+
<button class="agent-open" onClick=${() => openSubagent(agent.conversationId)}>
|
|
117
|
+
<span class="agent-mark" aria-hidden="true">
|
|
118
|
+
${agent.busy ? html`<${Spinner} />` : MARKS[status]}
|
|
119
|
+
</span>
|
|
120
|
+
<span class="agent-main">
|
|
121
|
+
<span class="agent-head">
|
|
122
|
+
<span class="agent-name">${agent.name}</span>
|
|
123
|
+
${when && html`<span class="agent-when">${when}</span>`}
|
|
124
|
+
${agent.reporting && html`<span class="agent-chip">report on its way to Pi</span>`}
|
|
125
|
+
</span>
|
|
126
|
+
${agent.busy && html`<span class="agent-now">${nowDoing(agent)}</span>`}
|
|
127
|
+
${status === "failed" && agent.error && html`<span class="agent-error">${agent.error}</span>`}
|
|
128
|
+
${agent.asked && html`<span class="agent-asked">${agent.asked}</span>`}
|
|
129
|
+
</span>
|
|
130
|
+
</button>
|
|
131
|
+
${
|
|
132
|
+
agent.busy &&
|
|
133
|
+
canSteer() &&
|
|
134
|
+
html`<button
|
|
135
|
+
class="icon-button agent-stop"
|
|
136
|
+
aria-label=${`Stop ${agent.name}`}
|
|
137
|
+
title="Stop"
|
|
138
|
+
onClick=${stop}
|
|
139
|
+
>
|
|
140
|
+
<${Icon} name="stop" size=${13} />
|
|
141
|
+
</button>`
|
|
142
|
+
}
|
|
143
|
+
</div>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reports a spend limit holds back: why they wait, and for the owner the way to raise it. They go once it is raised.
|
|
148
|
+
*/
|
|
149
|
+
function Held({ why }) {
|
|
150
|
+
const owner = store.state.me?.role === "owner";
|
|
151
|
+
|
|
152
|
+
return html`<div class="agents-held" role="status">
|
|
153
|
+
<span>Reports wait: ${why}.</span>
|
|
154
|
+
${
|
|
155
|
+
owner
|
|
156
|
+
? html`<button class="link" onClick=${() => openSheet({ type: "spend" })}>
|
|
157
|
+
Raise it
|
|
158
|
+
</button>`
|
|
159
|
+
: html`<span>They go once the owner raises it.</span>`
|
|
160
|
+
}
|
|
161
|
+
</div>`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The bar, while it shows: its hooks watch its rows for peeks, and tick the elapsed times. */
|
|
165
|
+
function Bar({ agents, active, open, held }) {
|
|
166
|
+
const box = useRef(null);
|
|
167
|
+
const [, rerender] = useState(0);
|
|
168
|
+
const count = (status) => agents.filter((agent) => stateOf(agent) === status).length;
|
|
169
|
+
const working = agents.filter((agent) => agent.busy);
|
|
170
|
+
const lead = ordered(working)[0];
|
|
171
|
+
// Trouble first: on a phone the end of the line may not fit.
|
|
172
|
+
const kinds = ["working", "failed", "stopped", "done"].filter((status) => count(status) > 0);
|
|
173
|
+
// "3 subagents working" when one kind says it all; "2 working · 1 done · 1 failed", short for a phone, when not.
|
|
174
|
+
const summary =
|
|
175
|
+
kinds.length === 1
|
|
176
|
+
? `${plural(agents.length, "subagent")} ${kinds[0]}`
|
|
177
|
+
: kinds.map((status) => `${count(status)} ${status}`).join(" · ");
|
|
178
|
+
const shown = ordered(agents);
|
|
179
|
+
|
|
180
|
+
// The rows' and the folded line's peeks: what each working subagent does now.
|
|
181
|
+
useOnScreen(box);
|
|
182
|
+
// Elapsed times tick while one works.
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (working.length === 0) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const timer = setInterval(() => rerender((count) => count + 1), 1000);
|
|
189
|
+
|
|
190
|
+
return () => clearInterval(timer);
|
|
191
|
+
}, [working.length]);
|
|
192
|
+
|
|
193
|
+
// What the button says, the newest one's work too: the label stands for everything in it.
|
|
194
|
+
const label = `Subagents: ${summary}${lead && !open ? `. ${lead.name}: ${nowDoing(lead)}` : ""}`;
|
|
195
|
+
|
|
196
|
+
return html`<div
|
|
197
|
+
class=${`agents-bar ${open ? "open" : ""} ${working.length > 0 ? "busy" : ""}`}
|
|
198
|
+
data-part="subagents-bar"
|
|
199
|
+
ref=${box}
|
|
200
|
+
>
|
|
201
|
+
<div class="agents-top">
|
|
202
|
+
<button
|
|
203
|
+
class="agents-summary"
|
|
204
|
+
aria-label=${label}
|
|
205
|
+
aria-expanded=${open}
|
|
206
|
+
onClick=${() =>
|
|
207
|
+
store.set({ subagentsOpen: open ? null : store.state.conversationId })}
|
|
208
|
+
>
|
|
209
|
+
<span class="agents-dots" aria-hidden="true">
|
|
210
|
+
${shown
|
|
211
|
+
.slice(0, 6)
|
|
212
|
+
.map(
|
|
213
|
+
(agent) => html`<span class=${`agents-dot ${stateOf(agent)}`}></span>`,
|
|
214
|
+
)}
|
|
215
|
+
${shown.length > 6 && html`<span class="agents-more">+${shown.length - 6}</span>`}
|
|
216
|
+
</span>
|
|
217
|
+
<span class="agents-count">${summary}</span>
|
|
218
|
+
${
|
|
219
|
+
lead &&
|
|
220
|
+
!open &&
|
|
221
|
+
html`<span class="agents-lead" data-peek=${lead.conversationId}>
|
|
222
|
+
${lead.name}: ${nowDoing(lead)}
|
|
223
|
+
</span>`
|
|
224
|
+
}
|
|
225
|
+
<span class="agents-chevron"><${Icon} name="chevron" size=${14} /></span>
|
|
226
|
+
</button>
|
|
227
|
+
${
|
|
228
|
+
!active &&
|
|
229
|
+
html`<button
|
|
230
|
+
class="icon-button"
|
|
231
|
+
aria-label="Put the subagents bar away"
|
|
232
|
+
title="Put away"
|
|
233
|
+
onClick=${() => {
|
|
234
|
+
putAway(store.state.conversationId, agents);
|
|
235
|
+
store.set({ subagentsOpen: null });
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<${Icon} name="close" size=${13} />
|
|
239
|
+
</button>`
|
|
240
|
+
}
|
|
241
|
+
</div>
|
|
242
|
+
${held && html`<${Held} why=${held} />`}
|
|
243
|
+
${
|
|
244
|
+
open &&
|
|
245
|
+
html`<div class="agents-list">
|
|
246
|
+
${shown.map((agent) => html`<${AgentRow} key=${agent.name} agent=${agent} />`)}
|
|
247
|
+
</div>`
|
|
248
|
+
}
|
|
249
|
+
</div>`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* This session's subagents, while one works or has a report on its way, and after until put away. Once put away, it
|
|
254
|
+
* counts and lists only those that did something since.
|
|
255
|
+
*/
|
|
256
|
+
export function SubagentsBar() {
|
|
257
|
+
const { view, conversationId, subagentsOpen } = store.state;
|
|
258
|
+
const away = awayMarks()[conversationId] ?? 0;
|
|
259
|
+
const agents = (view.subagents ?? []).filter(
|
|
260
|
+
(agent) => agent.busy || agent.reporting || newest(agent) > away,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
if (agents.length === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return html`<${Bar}
|
|
268
|
+
agents=${agents}
|
|
269
|
+
held=${view.subagentsHeld}
|
|
270
|
+
active=${agents.some((agent) => agent.busy || agent.reporting)}
|
|
271
|
+
open=${subagentsOpen === conversationId}
|
|
272
|
+
/>`;
|
|
273
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Pi Pocket's service worker. It shows push notifications (Pi finished, an approval, a chat message), answers an
|
|
2
|
+
// approval from its notification, opens the right session when one is tapped, and hands things shared to Pi Pocket
|
|
3
|
+
// from other apps to the page. It caches none of the app: the app is edited live.
|
|
4
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
5
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
|
6
|
+
|
|
7
|
+
/** Where shared things wait until the page picks them up; see `web/share.js`. */
|
|
8
|
+
const SHARE_CACHE = "pocket-share";
|
|
9
|
+
|
|
10
|
+
self.addEventListener("push", (event) => {
|
|
11
|
+
let data = {};
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
data = event.data?.json() ?? {};
|
|
15
|
+
} catch {
|
|
16
|
+
data = { title: "Pi Pocket", body: event.data?.text() ?? "" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const approval = data.approval;
|
|
20
|
+
// Allowing from the notification is offered only when the server says the whole call shows and this person may.
|
|
21
|
+
const actions = approval
|
|
22
|
+
? [
|
|
23
|
+
...(approval.allow ? [{ action: "allow", title: "Allow" }] : []),
|
|
24
|
+
{ action: "deny", title: "Deny" },
|
|
25
|
+
]
|
|
26
|
+
: [];
|
|
27
|
+
|
|
28
|
+
event.waitUntil(
|
|
29
|
+
Promise.all([
|
|
30
|
+
self.registration.showNotification(data.title || "Pi Pocket", {
|
|
31
|
+
body: data.body || "",
|
|
32
|
+
tag: data.tag,
|
|
33
|
+
renotify: Boolean(data.tag),
|
|
34
|
+
icon: "/icon.svg",
|
|
35
|
+
actions,
|
|
36
|
+
data: { url: data.url || "/", approval },
|
|
37
|
+
}),
|
|
38
|
+
// A dot on the home-screen icon; the app sets the real count once it opens.
|
|
39
|
+
self.navigator.setAppBadge?.().catch(() => {}),
|
|
40
|
+
]),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Answer an approval with this device's sign-in. Only a failure shows: the notification going away is the answer. */
|
|
45
|
+
async function answerApproval(approval, allow, data) {
|
|
46
|
+
let problem;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`/api/approvals/${encodeURIComponent(approval.id)}`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
credentials: "same-origin",
|
|
52
|
+
headers: { "X-Pocket": "1", "content-type": "application/json" },
|
|
53
|
+
body: JSON.stringify({ allow }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
problem =
|
|
58
|
+
(await response.json().catch(() => ({}))).error ??
|
|
59
|
+
`The server answered ${response.status}.`;
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
problem = "Pi Pocket could not be reached.";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (problem === undefined) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await self.registration.showNotification(`Could not ${allow ? "allow" : "deny"} the call`, {
|
|
70
|
+
body: problem,
|
|
71
|
+
icon: "/icon.svg",
|
|
72
|
+
data: { url: data.url },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function openUrl(url) {
|
|
77
|
+
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
78
|
+
const open = windows.find((client) => new URL(client.url).origin === self.location.origin);
|
|
79
|
+
|
|
80
|
+
// PiP's notifications open its panel over whatever the window shows, rather than take the window elsewhere.
|
|
81
|
+
if (open && new URL(url).searchParams.has("pip")) {
|
|
82
|
+
await open.focus();
|
|
83
|
+
open.postMessage({ type: "pocket.pip" });
|
|
84
|
+
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (open) {
|
|
89
|
+
await open.focus();
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await open.navigate(url);
|
|
93
|
+
|
|
94
|
+
return;
|
|
95
|
+
} catch {
|
|
96
|
+
// Not controlled by this worker yet: open a new window instead.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await self.clients.openWindow(url);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
self.addEventListener("notificationclick", (event) => {
|
|
104
|
+
event.notification.close();
|
|
105
|
+
const data = event.notification.data ?? {};
|
|
106
|
+
|
|
107
|
+
if ((event.action === "allow" || event.action === "deny") && data.approval) {
|
|
108
|
+
event.waitUntil(answerApproval(data.approval, event.action === "allow", data));
|
|
109
|
+
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
event.waitUntil(openUrl(new URL(data.url || "/", self.location.origin).href));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Something shared to Pi Pocket from another app (the manifest's share target) arrives as a form post. Keep it until
|
|
118
|
+
* the page picks it up, then open the page to choose where it goes. Any website could post a form here too, and
|
|
119
|
+
* nothing tells it apart from a share; it only fills in a message the person still reads and sends themselves.
|
|
120
|
+
*/
|
|
121
|
+
async function receiveShare(request) {
|
|
122
|
+
let form;
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
form = await request.formData();
|
|
126
|
+
} catch {
|
|
127
|
+
return Response.redirect("/?share=failed", 303);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const id = crypto.randomUUID();
|
|
131
|
+
const cache = await caches.open(SHARE_CACHE);
|
|
132
|
+
const files = form.getAll("files").filter((file) => file instanceof File && file.size > 0);
|
|
133
|
+
const text = (name) => (typeof form.get(name) === "string" ? form.get(name) : "");
|
|
134
|
+
const meta = {
|
|
135
|
+
title: text("title"),
|
|
136
|
+
text: text("text"),
|
|
137
|
+
url: text("url"),
|
|
138
|
+
files: files.map((file) => ({ name: file.name, type: file.type })),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
await cache.put(
|
|
142
|
+
`/share/${id}/meta`,
|
|
143
|
+
new Response(JSON.stringify(meta), { headers: { "content-type": "application/json" } }),
|
|
144
|
+
);
|
|
145
|
+
await Promise.all(
|
|
146
|
+
files.map((file, index) =>
|
|
147
|
+
cache.put(
|
|
148
|
+
`/share/${id}/file/${index}`,
|
|
149
|
+
new Response(file, {
|
|
150
|
+
headers: { "content-type": file.type || "application/octet-stream" },
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
return Response.redirect(`/?share=${id}`, 303);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
self.addEventListener("fetch", (event) => {
|
|
160
|
+
const url = new URL(event.request.url);
|
|
161
|
+
|
|
162
|
+
if (
|
|
163
|
+
event.request.method === "POST" &&
|
|
164
|
+
url.origin === self.location.origin &&
|
|
165
|
+
url.pathname === "/share"
|
|
166
|
+
) {
|
|
167
|
+
event.respondWith(receiveShare(event.request));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Whether a person just tapped the page or pressed a key. A script cannot make such an event (`isTrusted`), so an action
|
|
2
|
+
// that must come from a person can ask for one. This is the rule that the frame of an artifact keeps for its follow-ups
|
|
3
|
+
// (`ARTIFACT_BRIDGE` in `src/server/http/artifacts.ts`): a tap serves for 5 seconds, and for one action.
|
|
4
|
+
|
|
5
|
+
const TAP_MS = 5000;
|
|
6
|
+
let tapped = -Infinity;
|
|
7
|
+
|
|
8
|
+
const tap = (event) => {
|
|
9
|
+
if (event.isTrusted) {
|
|
10
|
+
tapped = performance.now();
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// One event for each tap: a mouse's press, a finger's or pen's lift, a key's press but not its repeats.
|
|
15
|
+
addEventListener("keydown", (event) => event.key !== "Escape" && !event.repeat && tap(event), true);
|
|
16
|
+
addEventListener("pointerdown", (event) => event.pointerType === "mouse" && tap(event), true);
|
|
17
|
+
addEventListener("pointerup", (event) => event.pointerType !== "mouse" && tap(event), true);
|
|
18
|
+
|
|
19
|
+
/** True when a person tapped or pressed a key in the last 5 seconds, and not for an action already; uses that tap. */
|
|
20
|
+
export function spendTap() {
|
|
21
|
+
const fresh = performance.now() - tapped <= TAP_MS;
|
|
22
|
+
|
|
23
|
+
tapped = -Infinity;
|
|
24
|
+
|
|
25
|
+
return fresh;
|
|
26
|
+
}
|