workoffdesk 0.0.0-stage → 1.0.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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SECURITY.md +50 -0
- package/dist/build.json +1 -0
- package/dist/server/agents/claude/accounts.js +105 -0
- package/dist/server/agents/claude/hooks/asks.js +135 -0
- package/dist/server/agents/claude/hooks/hooks.js +114 -0
- package/dist/server/agents/claude/hooks/status.js +34 -0
- package/dist/server/agents/claude/hooks/tracker.js +79 -0
- package/dist/server/agents/claude/offers/commands.js +219 -0
- package/dist/server/agents/claude/offers/files.js +51 -0
- package/dist/server/agents/claude/offers/initialize.js +59 -0
- package/dist/server/agents/claude/offers/models.js +20 -0
- package/dist/server/agents/claude/screen/choices.js +189 -0
- package/dist/server/agents/claude/screen/compacting.js +20 -0
- package/dist/server/agents/claude/screen/live.js +63 -0
- package/dist/server/agents/claude/screen/panel.js +140 -0
- package/dist/server/agents/claude/screen/prompt.js +23 -0
- package/dist/server/agents/claude/screen/screen.js +79 -0
- package/dist/server/agents/claude/terminals.js +110 -0
- package/dist/server/agents/claude/transcript/chat.js +254 -0
- package/dist/server/agents/claude/transcript/transcriptFile.js +194 -0
- package/dist/server/agents/claude/transcript/transcripts.js +210 -0
- package/dist/server/agents/claude/transcript/turn.js +136 -0
- package/dist/server/agents/claude/transcript/watch.js +60 -0
- package/dist/server/agents/claude/usage.js +36 -0
- package/dist/server/agents/codex/agent.js +263 -0
- package/dist/server/agents/codex/appServer.js +38 -0
- package/dist/server/agents/codex/items.js +226 -0
- package/dist/server/agents/codex/notifications.js +192 -0
- package/dist/server/agents/codex/rpc.js +121 -0
- package/dist/server/agents/codex/screen.js +5 -0
- package/dist/server/agents/codex/threads.js +208 -0
- package/dist/server/agents/types.js +1 -0
- package/dist/server/cli.js +49 -0
- package/dist/server/commands/account.js +61 -0
- package/dist/server/commands/attach.js +27 -0
- package/dist/server/commands/common.js +114 -0
- package/dist/server/commands/doctor.js +43 -0
- package/dist/server/commands/install.js +169 -0
- package/dist/server/commands/open.js +31 -0
- package/dist/server/commands/qr.js +33 -0
- package/dist/server/commands/serve.js +45 -0
- package/dist/server/commands/service.js +87 -0
- package/dist/server/commands/setup.js +36 -0
- package/dist/server/commands/setupWeb.js +87 -0
- package/dist/server/commands/status.js +32 -0
- package/dist/server/commands/wizard/probe.js +19 -0
- package/dist/server/commands/wizard/server.js +162 -0
- package/dist/server/http/app.js +144 -0
- package/dist/server/http/auth/guard.js +78 -0
- package/dist/server/http/auth/lockout.js +41 -0
- package/dist/server/http/auth/passkey.js +151 -0
- package/dist/server/http/auth/password.js +23 -0
- package/dist/server/http/auth/sessions.js +46 -0
- package/dist/server/http/routes/appUpdate.js +97 -0
- package/dist/server/http/routes/auth.js +79 -0
- package/dist/server/http/routes/chat.js +216 -0
- package/dist/server/http/routes/claude.js +69 -0
- package/dist/server/http/routes/codex.js +58 -0
- package/dist/server/http/routes/context.js +1 -0
- package/dist/server/http/routes/health.js +56 -0
- package/dist/server/http/routes/journal.js +35 -0
- package/dist/server/http/routes/mac.js +39 -0
- package/dist/server/http/routes/media.js +83 -0
- package/dist/server/http/routes/push.js +78 -0
- package/dist/server/http/routes/sessions.js +104 -0
- package/dist/server/http/routes/web.js +28 -0
- package/dist/server/http/serve.js +129 -0
- package/dist/server/http/sessionSocket.js +198 -0
- package/dist/server/ios-update/appUpdate.js +95 -0
- package/dist/server/ios-update/bundlePatch.js +56 -0
- package/dist/server/ios-update/bundlePatch.worker.js +11 -0
- package/dist/server/journal/db.js +243 -0
- package/dist/server/journal/derive.js +50 -0
- package/dist/server/journal/index.js +256 -0
- package/dist/server/journal/ingest.js +151 -0
- package/dist/server/notify/apns.js +126 -0
- package/dist/server/notify/push.js +109 -0
- package/dist/server/ops/awake.js +10 -0
- package/dist/server/ops/build.js +40 -0
- package/dist/server/ops/doctor.js +110 -0
- package/dist/server/ops/lastOp.js +34 -0
- package/dist/server/ops/problems.js +54 -0
- package/dist/server/ops/release.js +206 -0
- package/dist/server/ops/report.js +40 -0
- package/dist/server/ops/service.js +39 -0
- package/dist/server/ops/shutdown.js +12 -0
- package/dist/server/sessions/control.js +97 -0
- package/dist/server/sessions/events.js +77 -0
- package/dist/server/sessions/machine/accounts.js +55 -0
- package/dist/server/sessions/machine/input.js +208 -0
- package/dist/server/sessions/machine/lifecycle.js +357 -0
- package/dist/server/sessions/machine/moves.js +71 -0
- package/dist/server/sessions/machine/state.js +71 -0
- package/dist/server/sessions/machine/status.js +257 -0
- package/dist/server/sessions/machine/types.js +2 -0
- package/dist/server/sessions/machine.js +300 -0
- package/dist/server/sessions/outbox.js +194 -0
- package/dist/server/sessions/panes.js +60 -0
- package/dist/server/sessions/sessionName.js +7 -0
- package/dist/server/sessions/terminalEnv.js +40 -0
- package/dist/server/system/config.js +71 -0
- package/dist/server/system/dirs.js +54 -0
- package/dist/server/system/errors.js +37 -0
- package/dist/server/system/log.js +40 -0
- package/dist/server/system/messages.js +378 -0
- package/dist/server/system/node.js +6 -0
- package/dist/server/system/paths.js +51 -0
- package/dist/server/system/store.js +43 -0
- package/dist/server/system/tmux.js +198 -0
- package/dist/server/system/version.js +16 -0
- package/dist/server/uploads/fileUpload.js +171 -0
- package/dist/server/uploads/media.js +139 -0
- package/dist/web/assets/Setup-DinzDcF1.js +1 -0
- package/dist/web/assets/main-C8GiLoJy.css +1 -0
- package/dist/web/assets/main-w7i8PvU7.js +132 -0
- package/dist/web/icon-180.png +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon.svg +6 -0
- package/dist/web/index.html +25 -0
- package/dist/web/manifest.webmanifest +15 -0
- package/dist/web/sw.js +8 -0
- package/dist/web/theme.js +11 -0
- package/package.json +77 -4
- package/scripts/smoke.mjs +130 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { findTranscript } from '../../agents/claude/transcript/transcriptFile.js';
|
|
3
|
+
import { listCommands } from '../../agents/claude/offers/commands.js';
|
|
4
|
+
import { listFiles } from '../../agents/claude/offers/files.js';
|
|
5
|
+
import { listModels } from '../../agents/claude/offers/models.js';
|
|
6
|
+
import { readMentionedImage } from '../../uploads/media.js';
|
|
7
|
+
const MESSAGE_ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
8
|
+
// A command, and what it is given, if anything (`/effort high`, `/model opus[1m]`).
|
|
9
|
+
const COMMAND = /^\/[a-z][\w:-]{0,40}( [\w.:[\]-]{1,40})?$/;
|
|
10
|
+
// Enough for a slider pushed from one end to the other, or a page of scrolling.
|
|
11
|
+
const KEYS_MAX = 200;
|
|
12
|
+
// What the chat of a session shows: the conversation, its images, what is on screen and the answers to it.
|
|
13
|
+
export const register = (app, ctx) => {
|
|
14
|
+
const { deps, machineOr404 } = ctx;
|
|
15
|
+
// The message as the journal now has it, for the page to apply like any row.
|
|
16
|
+
const message = (session, qid) => deps.machine.message(session, qid);
|
|
17
|
+
app.get('/api/m/:machine/sessions/:id/choices', async (req, reply) => {
|
|
18
|
+
const m = machineOr404(req, reply);
|
|
19
|
+
if (!m)
|
|
20
|
+
return reply;
|
|
21
|
+
if (!(await m.exists(req.params.id)))
|
|
22
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
23
|
+
return m.onScreen(req.params.id);
|
|
24
|
+
});
|
|
25
|
+
app.get('/api/m/:machine/sessions/:id/screen', async (req, reply) => {
|
|
26
|
+
const m = machineOr404(req, reply);
|
|
27
|
+
if (!m)
|
|
28
|
+
return reply;
|
|
29
|
+
if (!(await m.exists(req.params.id)))
|
|
30
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
31
|
+
return { text: await m.screenText(req.params.id) };
|
|
32
|
+
});
|
|
33
|
+
// What can follow a `/` in the session, as Claude Code lists it; a shell takes none.
|
|
34
|
+
app.get('/api/m/:machine/sessions/:id/commands', async (req, reply) => {
|
|
35
|
+
const m = machineOr404(req, reply);
|
|
36
|
+
if (!m)
|
|
37
|
+
return reply;
|
|
38
|
+
const s = (await m.listSessions()).find((x) => x.id === req.params.id);
|
|
39
|
+
if (!s)
|
|
40
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
41
|
+
if (s.kind !== 'claude' || !deps.claudeDir)
|
|
42
|
+
return { commands: [] };
|
|
43
|
+
return { commands: await listCommands({ claudeDir: deps.claudeDir, cwd: s.cwd, claudeBin: deps.claudeBin }) };
|
|
44
|
+
});
|
|
45
|
+
// The models /model takes in the session, as Claude Code lists them for its folder; a shell takes none.
|
|
46
|
+
app.get('/api/m/:machine/sessions/:id/models', async (req, reply) => {
|
|
47
|
+
const m = machineOr404(req, reply);
|
|
48
|
+
if (!m)
|
|
49
|
+
return reply;
|
|
50
|
+
const s = (await m.listSessions()).find((x) => x.id === req.params.id);
|
|
51
|
+
if (!s)
|
|
52
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
53
|
+
if (s.kind !== 'claude' || !deps.claudeBin || !s.cwd)
|
|
54
|
+
return { models: [] };
|
|
55
|
+
return { models: await listModels(deps.claudeBin, s.cwd) };
|
|
56
|
+
});
|
|
57
|
+
// The files of the session's folder an `@` can name.
|
|
58
|
+
app.get('/api/m/:machine/sessions/:id/files', async (req, reply) => {
|
|
59
|
+
const m = machineOr404(req, reply);
|
|
60
|
+
if (!m)
|
|
61
|
+
return reply;
|
|
62
|
+
const s = (await m.listSessions()).find((x) => x.id === req.params.id);
|
|
63
|
+
if (!s)
|
|
64
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
65
|
+
return { files: s.cwd ? await listFiles(s.cwd) : [] };
|
|
66
|
+
});
|
|
67
|
+
// What the composer sends Claude: typed in now, or held, in order, while Claude cannot take it.
|
|
68
|
+
app.post('/api/m/:machine/sessions/:id/messages', async (req, reply) => {
|
|
69
|
+
const m = machineOr404(req, reply);
|
|
70
|
+
if (!m)
|
|
71
|
+
return reply;
|
|
72
|
+
const text = req.body?.text;
|
|
73
|
+
if (typeof text !== 'string' || !text)
|
|
74
|
+
return reply.code(400).send({ error: 'text required' });
|
|
75
|
+
// The page names each message, so one sent again after its answer was lost is taken once.
|
|
76
|
+
const given = req.body?.id;
|
|
77
|
+
if (given !== undefined && (typeof given !== 'string' || !MESSAGE_ID.test(given)))
|
|
78
|
+
return reply.code(400).send({ error: 'bad message id' });
|
|
79
|
+
if (!(await m.exists(req.params.id)))
|
|
80
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
81
|
+
const qid = given ?? randomBytes(8).toString('hex');
|
|
82
|
+
return { queued: await m.sendMessage(req.params.id, text, qid), message: message(req.params.id, qid) };
|
|
83
|
+
});
|
|
84
|
+
// A slash command typed at Claude's prompt, as a key on the page would; nothing is typed while a dialog
|
|
85
|
+
// has the keyboard, where its Enter would take whatever the dialog has selected.
|
|
86
|
+
app.post('/api/m/:machine/sessions/:id/command', async (req, reply) => {
|
|
87
|
+
const m = machineOr404(req, reply);
|
|
88
|
+
if (!m)
|
|
89
|
+
return reply;
|
|
90
|
+
const command = req.body?.command;
|
|
91
|
+
if (typeof command !== 'string' || !COMMAND.test(command))
|
|
92
|
+
return reply.code(400).send({ error: 'bad command' });
|
|
93
|
+
if (!(await m.exists(req.params.id)))
|
|
94
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
95
|
+
const { ran, screen } = await m.runCommand(req.params.id, command);
|
|
96
|
+
return screen ? { ran, screen } : { ran };
|
|
97
|
+
});
|
|
98
|
+
// Keys pressed in a dialog on screen, sent as a request rather than on the session's line, which a slow
|
|
99
|
+
// phone line can hold for seconds while it looks open; the answer brings the screen after them.
|
|
100
|
+
app.post('/api/m/:machine/sessions/:id/keys', async (req, reply) => {
|
|
101
|
+
const m = machineOr404(req, reply);
|
|
102
|
+
if (!m)
|
|
103
|
+
return reply;
|
|
104
|
+
const keys = req.body?.keys;
|
|
105
|
+
if (typeof keys !== 'string' || !keys || keys.length > KEYS_MAX)
|
|
106
|
+
return reply.code(400).send({ error: 'bad keys' });
|
|
107
|
+
if (!(await m.exists(req.params.id)))
|
|
108
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
109
|
+
return { screen: await m.press(req.params.id, keys) };
|
|
110
|
+
});
|
|
111
|
+
// Closes the dialog on screen, and only it: what the ✕ under a dialog does.
|
|
112
|
+
app.post('/api/m/:machine/sessions/:id/close', async (req, reply) => {
|
|
113
|
+
const m = machineOr404(req, reply);
|
|
114
|
+
if (!m)
|
|
115
|
+
return reply;
|
|
116
|
+
if (!(await m.exists(req.params.id)))
|
|
117
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
118
|
+
return { screen: await m.close(req.params.id) };
|
|
119
|
+
});
|
|
120
|
+
// Takes back a held message; its text comes back, to put in the composer again.
|
|
121
|
+
app.delete('/api/m/:machine/sessions/:id/messages/:qid', async (req, reply) => {
|
|
122
|
+
const m = machineOr404(req, reply);
|
|
123
|
+
if (!m)
|
|
124
|
+
return reply;
|
|
125
|
+
const text = m.unsend(req.params.id, req.params.qid);
|
|
126
|
+
if (text === null)
|
|
127
|
+
return reply.code(404).send({ error: 'already sent' });
|
|
128
|
+
return { text, message: message(req.params.id, req.params.qid) };
|
|
129
|
+
});
|
|
130
|
+
// A waiting message goes now, out of its turn, or moves a place earlier or later.
|
|
131
|
+
app.post('/api/m/:machine/sessions/:id/messages/:qid', async (req, reply) => {
|
|
132
|
+
const m = machineOr404(req, reply);
|
|
133
|
+
if (!m)
|
|
134
|
+
return reply;
|
|
135
|
+
const move = req.body?.move;
|
|
136
|
+
if (move !== undefined && move !== -1 && move !== 1)
|
|
137
|
+
return reply.code(400).send({ error: 'bad move' });
|
|
138
|
+
const done = move === undefined ? await m.sendNow(req.params.id, req.params.qid) : m.moveQueued(req.params.id, req.params.qid, move);
|
|
139
|
+
if (!done)
|
|
140
|
+
return reply.code(404).send({ error: 'already sent' });
|
|
141
|
+
return { message: message(req.params.id, req.params.qid) };
|
|
142
|
+
});
|
|
143
|
+
// Answers the choice on screen: `index` and `label` take one option, with `text` typed into it when it
|
|
144
|
+
// is a field for an answer of one's own; `ticked` and `labels` send a question that takes several with
|
|
145
|
+
// those ticked. Options are named as well as numbered, so a tap on options that have since changed
|
|
146
|
+
// presses nothing.
|
|
147
|
+
app.post('/api/m/:machine/sessions/:id/choices', async (req, reply) => {
|
|
148
|
+
const m = machineOr404(req, reply);
|
|
149
|
+
if (!m)
|
|
150
|
+
return reply;
|
|
151
|
+
const { index, label, text, ticked, labels } = req.body ?? {};
|
|
152
|
+
const several = ticked !== undefined || labels !== undefined;
|
|
153
|
+
const isIndex = (i) => Number.isSafeInteger(i) && i >= 0;
|
|
154
|
+
if (several) {
|
|
155
|
+
if (!Array.isArray(ticked) || !ticked.every(isIndex) || !Array.isArray(labels) || !labels.every((l) => typeof l === 'string')) {
|
|
156
|
+
return reply.code(400).send({ error: 'ticked and labels required' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
else if (!isIndex(index) || typeof label !== 'string') {
|
|
160
|
+
return reply.code(400).send({ error: 'index and label required' });
|
|
161
|
+
}
|
|
162
|
+
else if (text !== undefined && (typeof text !== 'string' || !text.trim())) {
|
|
163
|
+
return reply.code(400).send({ error: 'bad text' });
|
|
164
|
+
}
|
|
165
|
+
if (!(await m.exists(req.params.id)))
|
|
166
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
167
|
+
const pressed = several
|
|
168
|
+
? await m.tick(req.params.id, ticked, labels)
|
|
169
|
+
: await m.choose(req.params.id, index, label, text);
|
|
170
|
+
if (!pressed)
|
|
171
|
+
return reply.code(409).send({ error: 'the options have changed' });
|
|
172
|
+
return reply.code(204).send();
|
|
173
|
+
});
|
|
174
|
+
// Answers what the session waits on the person for (OnScreen.asks); 409 when it is over, answered at the
|
|
175
|
+
// Mac or passed by, so an answer goes to the ask it was given for or nowhere.
|
|
176
|
+
app.post('/api/m/:machine/sessions/:id/asks/:askId', async (req, reply) => {
|
|
177
|
+
const m = machineOr404(req, reply);
|
|
178
|
+
if (!m)
|
|
179
|
+
return reply;
|
|
180
|
+
const { allow, always } = req.body ?? {};
|
|
181
|
+
if (typeof allow !== 'boolean')
|
|
182
|
+
return reply.code(400).send({ error: 'allow required' });
|
|
183
|
+
if (always !== undefined && (always !== true || !allow))
|
|
184
|
+
return reply.code(400).send({ error: 'bad always' });
|
|
185
|
+
if (!(await m.exists(req.params.id)))
|
|
186
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
187
|
+
const answered = m.answer(req.params.id, req.params.askId, allow ? { allow, ...(always && { always }) } : { allow });
|
|
188
|
+
if (!answered)
|
|
189
|
+
return reply.code(409).send({ error: 'already answered' });
|
|
190
|
+
return reply.code(204).send();
|
|
191
|
+
});
|
|
192
|
+
// An image Claude named in the session, for the chat to show. Paths the transcript does not name are refused.
|
|
193
|
+
app.get('/api/m/:machine/sessions/:id/image', async (req, reply) => {
|
|
194
|
+
const m = machineOr404(req, reply);
|
|
195
|
+
if (!m)
|
|
196
|
+
return reply;
|
|
197
|
+
if (typeof req.query.path !== 'string')
|
|
198
|
+
return reply.code(400).send({ error: 'no path' });
|
|
199
|
+
const meta = m.sessionMeta(req.params.id);
|
|
200
|
+
if (!meta || !(await m.exists(req.params.id)))
|
|
201
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
202
|
+
const transcript = meta.claudeSessionId && deps.claudeProjectsRoot ? await findTranscript(deps.claudeProjectsRoot, meta.claudeSessionId) : null;
|
|
203
|
+
if (!transcript)
|
|
204
|
+
return reply.code(404).send({ error: 'no transcript' });
|
|
205
|
+
const image = await readMentionedImage(transcript, req.query.path);
|
|
206
|
+
if (image === 'refused')
|
|
207
|
+
return reply.code(403).send({ error: 'not an image this session named' });
|
|
208
|
+
if (image === 'gone')
|
|
209
|
+
return reply.code(404).send({ error: 'image gone' });
|
|
210
|
+
return reply
|
|
211
|
+
.header('content-type', image.type)
|
|
212
|
+
.header('x-content-type-options', 'nosniff')
|
|
213
|
+
.header('cache-control', 'private, max-age=300')
|
|
214
|
+
.send(image.bytes);
|
|
215
|
+
});
|
|
216
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { findTranscript, readChat, readChatBefore } from '../../agents/claude/transcript/transcriptFile.js';
|
|
2
|
+
import { heldInBackground, listProjectDirs, listRecentClaudeSessions } from '../../agents/claude/transcript/transcripts.js';
|
|
3
|
+
import { isDir } from '../../system/dirs.js';
|
|
4
|
+
// The Claude conversations people had on this Mac lately, and where each is open now.
|
|
5
|
+
export async function recentClaude(deps) {
|
|
6
|
+
const { machine } = deps;
|
|
7
|
+
if (!deps.claudeProjectsRoot)
|
|
8
|
+
return [];
|
|
9
|
+
const [recent, sessions, held, onMac] = await Promise.all([
|
|
10
|
+
listRecentClaudeSessions(deps.claudeProjectsRoot),
|
|
11
|
+
machine.listSessions(),
|
|
12
|
+
deps.claudeJobsRoot ? heldInBackground(deps.claudeJobsRoot) : () => false,
|
|
13
|
+
machine.openOnMac(),
|
|
14
|
+
]);
|
|
15
|
+
const open = new Map(sessions.filter((s) => s.claudeSessionId).map((s) => [s.claudeSessionId, s.id]));
|
|
16
|
+
return recent.map((r) => {
|
|
17
|
+
const there = onMac.get(r.sessionId);
|
|
18
|
+
return {
|
|
19
|
+
...r,
|
|
20
|
+
title: machine.claudeName(r.sessionId) ?? r.title,
|
|
21
|
+
openIn: open.get(r.sessionId) ?? null,
|
|
22
|
+
background: held(r.sessionId),
|
|
23
|
+
onMac: there ? { status: there.status, terminals: there.claudes.length } : null,
|
|
24
|
+
takingOver: machine.takingOver(r.sessionId),
|
|
25
|
+
folderGone: !!r.cwd && !isDir(r.cwd),
|
|
26
|
+
agent: 'claude',
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// Claude sessions on this Mac to pick up again, and the folders worth starting one in.
|
|
31
|
+
export const register = (app, ctx) => {
|
|
32
|
+
const { deps } = ctx;
|
|
33
|
+
const { machine } = deps;
|
|
34
|
+
app.get('/api/claude/recent', () => recentClaude(deps));
|
|
35
|
+
// Stops waiting to take a conversation over from the Mac.
|
|
36
|
+
app.delete('/api/claude/sessions/:sid/take-over', async (req, reply) => machine.cancelTakeOver(req.params.sid) ? reply.code(204).send() : reply.code(404).send({ error: 'not waiting' }));
|
|
37
|
+
// A Claude session's conversation, by its session id, to read before picking it up here; the same shape
|
|
38
|
+
// as a session's chat. Only a transcript under Claude Code's projects folder is read.
|
|
39
|
+
app.get('/api/claude/sessions/:sid/chat', async (req, reply) => {
|
|
40
|
+
const { sid } = req.params;
|
|
41
|
+
const path = deps.claudeProjectsRoot ? await findTranscript(deps.claudeProjectsRoot, sid) : null;
|
|
42
|
+
if (!path)
|
|
43
|
+
return reply.code(404).send({ error: 'no such conversation' });
|
|
44
|
+
const from = Number(req.query.from ?? 0);
|
|
45
|
+
const before = req.query.before === undefined ? null : Number(req.query.before);
|
|
46
|
+
if (!Number.isSafeInteger(from) || from < 0)
|
|
47
|
+
return reply.code(400).send({ error: 'bad from' });
|
|
48
|
+
if (before !== null && (!Number.isSafeInteger(before) || before < 0))
|
|
49
|
+
return reply.code(400).send({ error: 'bad before' });
|
|
50
|
+
if (req.query.sid !== sid)
|
|
51
|
+
return { sid, ...(await readChat(path, 0)) };
|
|
52
|
+
return { sid, ...(await (before === null ? readChat(path, from) : readChatBefore(path, before))) };
|
|
53
|
+
});
|
|
54
|
+
// The Claude accounts on this Mac, the one new sessions run on, and a pick of another.
|
|
55
|
+
app.get('/api/claude/accounts', () => machine.claudeAccountsView());
|
|
56
|
+
app.put('/api/claude/account', async (req, reply) => {
|
|
57
|
+
if (typeof req.body?.id !== 'string')
|
|
58
|
+
return reply.code(400).send({ error: 'bad id' });
|
|
59
|
+
const view = await machine.pickClaudeAccount(req.body.id);
|
|
60
|
+
req.log.info({ account: req.body.id }, 'claude account picked');
|
|
61
|
+
return view;
|
|
62
|
+
});
|
|
63
|
+
app.post('/api/claude/account/move', async (req) => {
|
|
64
|
+
const view = await machine.moveToAccountInUse();
|
|
65
|
+
req.log.info({ moving: view.moving }, 'open sessions moving to the claude account in use');
|
|
66
|
+
return view;
|
|
67
|
+
});
|
|
68
|
+
app.get('/api/claude/projects', async () => listProjectDirs(await recentClaude(deps), deps.pinnedDirs ?? []));
|
|
69
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isDir } from '../../system/dirs.js';
|
|
2
|
+
import { recentClaude } from './claude.js';
|
|
3
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
|
+
// How many items a read of a Codex conversation gives at most: the latest, or those before a place.
|
|
5
|
+
const PAGE = 400;
|
|
6
|
+
// The Codex conversations people had on this Mac, in the shape of the Claude ones: background is "open in
|
|
7
|
+
// another Codex app", which cannot be gone on with here.
|
|
8
|
+
async function recentCodex(deps) {
|
|
9
|
+
const { machine } = deps;
|
|
10
|
+
const [threads, sessions] = await Promise.all([machine.codexOnMac().catch(() => []), machine.listSessions()]);
|
|
11
|
+
const open = new Map(sessions.filter((s) => s.codexThreadId).map((s) => [s.codexThreadId, s.id]));
|
|
12
|
+
return threads.map((t) => ({
|
|
13
|
+
sessionId: t.id,
|
|
14
|
+
cwd: t.cwd || null,
|
|
15
|
+
title: t.title,
|
|
16
|
+
lastActivity: t.lastActivity,
|
|
17
|
+
automated: false,
|
|
18
|
+
openIn: open.get(t.id) ?? null,
|
|
19
|
+
background: t.holder?.at === 'elsewhere',
|
|
20
|
+
lastPrompt: t.lastPrompt,
|
|
21
|
+
onMac: t.holder?.at === 'terminal' ? { status: t.busy ? 'busy' : 'idle', terminals: 1 } : null,
|
|
22
|
+
takingOver: machine.takingOver(t.id),
|
|
23
|
+
folderGone: !!t.cwd && !isDir(t.cwd),
|
|
24
|
+
agent: 'codex',
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
// Codex threads on this Mac to read and pick up, and the list of every agent's conversations here.
|
|
28
|
+
export const register = (app, ctx) => {
|
|
29
|
+
const { deps } = ctx;
|
|
30
|
+
const { machine } = deps;
|
|
31
|
+
app.get('/api/recent', async () => {
|
|
32
|
+
const [claude, codex] = await Promise.all([recentClaude(deps), recentCodex(deps)]);
|
|
33
|
+
return [...claude, ...codex].sort((a, b) => b.lastActivity - a.lastActivity);
|
|
34
|
+
});
|
|
35
|
+
// A thread's conversation, to read before picking it up here; the same shape and paging as a Claude one's,
|
|
36
|
+
// with an item's place in the thread for a byte's in the transcript.
|
|
37
|
+
app.get('/api/codex/threads/:id/chat', async (req, reply) => {
|
|
38
|
+
const { id } = req.params;
|
|
39
|
+
if (!UUID.test(id))
|
|
40
|
+
return reply.code(400).send({ error: 'bad thread id' });
|
|
41
|
+
const from = Number(req.query.from ?? 0);
|
|
42
|
+
const before = req.query.before === undefined ? null : Number(req.query.before);
|
|
43
|
+
if (!Number.isSafeInteger(from) || from < 0)
|
|
44
|
+
return reply.code(400).send({ error: 'bad from' });
|
|
45
|
+
if (before !== null && (!Number.isSafeInteger(before) || before < 0))
|
|
46
|
+
return reply.code(400).send({ error: 'bad before' });
|
|
47
|
+
const items = (await machine.codexChat(id)).map((e) => e.item);
|
|
48
|
+
const end = items.length;
|
|
49
|
+
const page = (start, stop) => ({ sid: id, items: items.slice(start, stop), from: start, next: stop, skipped: false });
|
|
50
|
+
if (req.query.sid !== id || from > end)
|
|
51
|
+
return page(Math.max(0, end - PAGE), end);
|
|
52
|
+
if (before !== null)
|
|
53
|
+
return page(Math.max(0, Math.min(before, end) - PAGE), Math.min(before, end));
|
|
54
|
+
return page(from, end);
|
|
55
|
+
});
|
|
56
|
+
// Stops waiting to take a thread over from the Mac.
|
|
57
|
+
app.delete('/api/codex/threads/:id/take-over', async (req, reply) => machine.cancelTakeOver(req.params.id) ? reply.code(204).send() : reply.code(404).send({ error: 'not waiting' }));
|
|
58
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const PUBLIC = { config: { public: true } };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { VERSION } from '../../system/version.js';
|
|
2
|
+
import { PUBLIC } from './context.js';
|
|
3
|
+
// For a supervisor or a deploy to ask "are you up, and which build": nothing about sessions or the user.
|
|
4
|
+
export const register = (app, ctx) => {
|
|
5
|
+
const { store, machine } = ctx.deps;
|
|
6
|
+
// Requests that arrive while a check runs share it: the route is public, and each check starts tmux.
|
|
7
|
+
let running = null;
|
|
8
|
+
const check = () => (running ??= machine.listSessions().finally(() => (running = null)));
|
|
9
|
+
app.get('/healthz', PUBLIC, async (_req, reply) => {
|
|
10
|
+
try {
|
|
11
|
+
store.get();
|
|
12
|
+
await check();
|
|
13
|
+
return { ok: true, version: VERSION };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return reply.code(503).send({ ok: false });
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
// A signed-in device's requests that took it long (libs/client slow.ts), logged beside the server's own:
|
|
20
|
+
// the server answers in milliseconds what a phone may wait seconds for.
|
|
21
|
+
app.post('/api/slow', async (req, reply) => {
|
|
22
|
+
const slow = req.body?.slow;
|
|
23
|
+
if (!Array.isArray(slow) || slow.length > SLOW_MAX)
|
|
24
|
+
return reply.code(400).send({ error: 'bad report' });
|
|
25
|
+
for (const s of slow) {
|
|
26
|
+
if (!isSlow(s))
|
|
27
|
+
continue;
|
|
28
|
+
req.log.info({ method: s.method, path: s.path.slice(0, 200), ms: s.ms, outcome: s.outcome, device: req.headers['user-agent'] }, 'slow on device');
|
|
29
|
+
}
|
|
30
|
+
return reply.code(204).send();
|
|
31
|
+
});
|
|
32
|
+
// A phone's chat that came to rest at its scroll view's end with the last row still under the dock
|
|
33
|
+
// (mobile ChatList): what the list, the scroll view and the screen each measured then.
|
|
34
|
+
app.post('/api/chat-end', async (req, reply) => {
|
|
35
|
+
const numbers = req.body?.numbers;
|
|
36
|
+
if (!isNumbers(numbers))
|
|
37
|
+
return reply.code(400).send({ error: 'bad report' });
|
|
38
|
+
req.log.info({ ...numbers, device: req.headers['user-agent'] }, 'chat end hidden on device');
|
|
39
|
+
return reply.code(204).send();
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
const NUMBERS_MAX = 16;
|
|
43
|
+
const isNumbers = (x) => typeof x === 'object' &&
|
|
44
|
+
x !== null &&
|
|
45
|
+
!Array.isArray(x) &&
|
|
46
|
+
Object.keys(x).length <= NUMBERS_MAX &&
|
|
47
|
+
Object.entries(x).every(([k, v]) => /^[a-zA-Z]{1,24}$/.test(k) && (v === null || Number.isFinite(v)));
|
|
48
|
+
const SLOW_MAX = 20;
|
|
49
|
+
const isSlow = (s) => {
|
|
50
|
+
const x = s;
|
|
51
|
+
return (typeof x?.method === 'string' &&
|
|
52
|
+
typeof x.path === 'string' &&
|
|
53
|
+
x.path.startsWith('/api/') &&
|
|
54
|
+
Number.isSafeInteger(x.ms) &&
|
|
55
|
+
(Number.isSafeInteger(x.outcome) || x.outcome === 'network'));
|
|
56
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const CONV = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2
|
+
const count = (s) => (s === undefined ? 0 : /^\d{1,15}$/.test(s) ? Number(s) : NaN);
|
|
3
|
+
// A session's journal for a copy to catch up on: the rows above the version it holds, and pages of older
|
|
4
|
+
// items of a conversation (docs/design/2026-10-06-session-journal-design.md).
|
|
5
|
+
export const register = (app, ctx) => {
|
|
6
|
+
const { deps, machineOr404 } = ctx;
|
|
7
|
+
const { machine } = deps;
|
|
8
|
+
app.get('/api/m/:machine/sessions/:id/journal', async (req, reply) => {
|
|
9
|
+
const m = machineOr404(req, reply);
|
|
10
|
+
if (!m)
|
|
11
|
+
return reply;
|
|
12
|
+
const v = count(req.query.v);
|
|
13
|
+
if (!Number.isSafeInteger(v))
|
|
14
|
+
return reply.code(400).send({ error: 'bad version' });
|
|
15
|
+
if (!(await m.exists(req.params.id)))
|
|
16
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
17
|
+
// An answer is never behind the transcript, nor the screen (a copy that asks has no line reading it).
|
|
18
|
+
if (machine.sessionMeta(req.params.id)?.kind === 'claude')
|
|
19
|
+
await m.onScreen(req.params.id).catch(() => { });
|
|
20
|
+
await machine.ingest(req.params.id);
|
|
21
|
+
return machine.journal.sync(req.params.id, req.query.epoch, v);
|
|
22
|
+
});
|
|
23
|
+
app.get('/api/m/:machine/sessions/:id/journal/items', async (req, reply) => {
|
|
24
|
+
const m = machineOr404(req, reply);
|
|
25
|
+
if (!m)
|
|
26
|
+
return reply;
|
|
27
|
+
const before = req.query.before === undefined ? NaN : count(req.query.before);
|
|
28
|
+
const conv = req.query.conv ?? '';
|
|
29
|
+
if (!Number.isSafeInteger(before) || !CONV.test(conv))
|
|
30
|
+
return reply.code(400).send({ error: 'conv and before required' });
|
|
31
|
+
if (!(await m.exists(req.params.id)))
|
|
32
|
+
return reply.code(404).send({ error: 'no such session' });
|
|
33
|
+
return machine.journal.older(req.params.id, conv, before);
|
|
34
|
+
});
|
|
35
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { Conflict } from '../../system/errors.js';
|
|
3
|
+
import { messagesFor } from '../../system/messages.js';
|
|
4
|
+
import { beginOp, opRunning } from '../../ops/lastOp.js';
|
|
5
|
+
import { recentProblems } from '../../ops/problems.js';
|
|
6
|
+
import { macReport } from '../../ops/report.js';
|
|
7
|
+
import { rollbackTarget } from '../../ops/release.js';
|
|
8
|
+
// The This Mac page: what the Mac's checks say, which build runs, what went wrong lately, and a restart or a
|
|
9
|
+
// rollback, done by a helper process since a server cannot restart itself.
|
|
10
|
+
export const register = (app, ctx) => {
|
|
11
|
+
const ops = ctx.deps.ops;
|
|
12
|
+
if (!ops)
|
|
13
|
+
return;
|
|
14
|
+
app.get('/api/mac', async (req) => {
|
|
15
|
+
const languages = [req.query.lang ?? '', ...String(req.headers['accept-language'] ?? '').split(',').map((l) => l.split(';')[0].trim())];
|
|
16
|
+
return macReport(ops, messagesFor(languages.filter(Boolean)));
|
|
17
|
+
});
|
|
18
|
+
app.get('/api/mac/problems', async () => recentProblems(join(ops.home, 'logs/workoffdesk.log')));
|
|
19
|
+
const hand = (op) => {
|
|
20
|
+
if (!ops.managed)
|
|
21
|
+
throw new Conflict('this server was not started by launchd', 'not-managed');
|
|
22
|
+
if (opRunning(ops.home))
|
|
23
|
+
throw new Conflict('a restart or rollback is already running', 'op-busy');
|
|
24
|
+
beginOp(ops.home, op, ops.spawnOp(op));
|
|
25
|
+
};
|
|
26
|
+
app.post('/api/mac/restart', async (req, reply) => {
|
|
27
|
+
hand('restart');
|
|
28
|
+
req.log.info('restart asked for from a screen');
|
|
29
|
+
return reply.code(202).send({});
|
|
30
|
+
});
|
|
31
|
+
app.post('/api/mac/rollback', async (req, reply) => {
|
|
32
|
+
const to = rollbackTarget(ops.home);
|
|
33
|
+
if (!to || req.body?.to !== to)
|
|
34
|
+
throw new Conflict('the release before is not the one named', 'rollback-moved');
|
|
35
|
+
hand('rollback');
|
|
36
|
+
req.log.info({ to }, 'rollback asked for from a screen');
|
|
37
|
+
return reply.code(202).send({});
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { InvalidRequest } from '../../system/errors.js';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { appendPart, byteRange, finishPart, PART_MAX, partSize, videoCover, videoFile } from '../../uploads/fileUpload.js';
|
|
4
|
+
import { readUpload, saveImage } from '../../uploads/media.js';
|
|
5
|
+
// Images and files sent from the phone, for Claude to read.
|
|
6
|
+
export const register = (app, ctx) => {
|
|
7
|
+
if (ctx.deps.media)
|
|
8
|
+
registerMediaRoutes(app, ctx.deps.media);
|
|
9
|
+
};
|
|
10
|
+
const UPLOAD_LIMIT = 30 * 1024 * 1024;
|
|
11
|
+
// Images arrive as the raw file, and what they are is judged from the bytes alone: the Content-Type is
|
|
12
|
+
// dropped unread, as phones send it missing, empty (React Native) or generic.
|
|
13
|
+
function registerMediaRoutes(app, media) {
|
|
14
|
+
// An empty body is not parsed at all.
|
|
15
|
+
const raw = (body) => (Buffer.isBuffer(body) ? body : Buffer.alloc(0));
|
|
16
|
+
void app.register(async (upload) => {
|
|
17
|
+
upload.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
|
|
18
|
+
upload.post('/api/uploads', {
|
|
19
|
+
bodyLimit: UPLOAD_LIMIT,
|
|
20
|
+
onRequest: async (req) => {
|
|
21
|
+
delete req.headers['content-type'];
|
|
22
|
+
},
|
|
23
|
+
}, async (req, reply) => reply.code(201).send({ path: await saveImage(media.uploadsDir, raw(req.body)) }));
|
|
24
|
+
// A part of a file, appended where the Mac's copy ends; `upload-offset` says where the phone thinks that is.
|
|
25
|
+
upload.put('/api/uploads/parts/:id', {
|
|
26
|
+
bodyLimit: PART_MAX,
|
|
27
|
+
onRequest: async (req) => {
|
|
28
|
+
delete req.headers['content-type'];
|
|
29
|
+
},
|
|
30
|
+
}, async (req) => {
|
|
31
|
+
const at = Number(req.headers['upload-offset']);
|
|
32
|
+
if (!Number.isSafeInteger(at) || at < 0)
|
|
33
|
+
throw new InvalidRequest('upload-offset is not a byte count');
|
|
34
|
+
return { size: await appendPart(media.uploadsDir, req.params.id, at, raw(req.body)) };
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
// How much of a file the Mac has, to go on from.
|
|
38
|
+
app.get('/api/uploads/parts/:id', async (req) => ({ size: await partSize(media.uploadsDir, req.params.id) }));
|
|
39
|
+
// The file is whole: where it is kept, and stills of it when it is a video.
|
|
40
|
+
app.post('/api/uploads/parts/:id/done', async (req, reply) => {
|
|
41
|
+
const { name, size } = req.body ?? {};
|
|
42
|
+
if (typeof name !== 'string' || typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0)
|
|
43
|
+
throw new InvalidRequest('name and size are needed');
|
|
44
|
+
return reply.code(201).send(await finishPart(media.uploadsDir, req.params.id, name, size));
|
|
45
|
+
});
|
|
46
|
+
// A video's first still, shown for it.
|
|
47
|
+
app.get('/api/uploads/:folder/cover', async (req, reply) => {
|
|
48
|
+
const cover = await videoCover(media.uploadsDir, req.params.folder);
|
|
49
|
+
if (!cover)
|
|
50
|
+
return reply.code(404).send({ error: 'no cover' });
|
|
51
|
+
return reply.header('content-type', 'image/jpeg').header('cache-control', 'private, max-age=31536000, immutable').send(cover);
|
|
52
|
+
});
|
|
53
|
+
// A video sent from the phone, played on the phone in the ranges its player asks for.
|
|
54
|
+
app.get('/api/uploads/:folder/video', async (req, reply) => {
|
|
55
|
+
const video = await videoFile(media.uploadsDir, req.params.folder);
|
|
56
|
+
return video ? sendVideo(video, req.headers.range, reply) : reply.code(404).send({ error: 'no such file' });
|
|
57
|
+
});
|
|
58
|
+
// Names are unique, so the phone keeps what it has seen.
|
|
59
|
+
app.get('/api/uploads/:name', async (req, reply) => {
|
|
60
|
+
const image = await readUpload(media.uploadsDir, req.params.name);
|
|
61
|
+
if (!image)
|
|
62
|
+
return reply.code(404).send({ error: 'no such image' });
|
|
63
|
+
return reply
|
|
64
|
+
.header('content-type', image.type)
|
|
65
|
+
.header('x-content-type-options', 'nosniff')
|
|
66
|
+
.header('cache-control', 'private, max-age=31536000, immutable')
|
|
67
|
+
.send(image.bytes);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function sendVideo(video, rangeHeader, reply) {
|
|
71
|
+
const range = byteRange(rangeHeader, video.size);
|
|
72
|
+
if (range === 'unsatisfiable')
|
|
73
|
+
return reply.code(416).header('content-range', `bytes */${video.size}`).send();
|
|
74
|
+
const { start, end } = range ?? { start: 0, end: video.size - 1 };
|
|
75
|
+
if (range)
|
|
76
|
+
reply.code(206).header('content-range', `bytes ${start}-${end}/${video.size}`);
|
|
77
|
+
return reply
|
|
78
|
+
.header('content-type', video.type)
|
|
79
|
+
.header('accept-ranges', 'bytes')
|
|
80
|
+
.header('content-length', String(end - start + 1))
|
|
81
|
+
.header('cache-control', 'private, no-store')
|
|
82
|
+
.send(video.size ? createReadStream(video.file, { start, end }) : '');
|
|
83
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The phones that get a notification when Claude needs an answer.
|
|
2
|
+
export const register = (app, ctx) => {
|
|
3
|
+
const { deps, now } = ctx;
|
|
4
|
+
const { store, pusher } = deps;
|
|
5
|
+
app.get('/api/push/key', async (_req, reply) => {
|
|
6
|
+
const vapid = store.get().vapid;
|
|
7
|
+
return vapid ? { publicKey: vapid.publicKey } : reply.code(404).send({ error: 'push not configured' });
|
|
8
|
+
});
|
|
9
|
+
app.post('/api/push/subscribe', async (req, reply) => {
|
|
10
|
+
const sub = parseSubscription(req.body);
|
|
11
|
+
if (!sub)
|
|
12
|
+
return reply.code(400).send({ error: 'invalid subscription' });
|
|
13
|
+
store.update((s) => {
|
|
14
|
+
s.pushSubs = [...s.pushSubs.filter((x) => x.endpoint !== sub.endpoint), { ...sub, createdAt: now() }];
|
|
15
|
+
});
|
|
16
|
+
return reply.code(204).send();
|
|
17
|
+
});
|
|
18
|
+
app.delete('/api/push/subscribe', async (req, reply) => {
|
|
19
|
+
const endpoint = req.body?.endpoint;
|
|
20
|
+
store.update((s) => {
|
|
21
|
+
s.pushSubs = s.pushSubs.filter((x) => x.endpoint !== endpoint);
|
|
22
|
+
});
|
|
23
|
+
return reply.code(204).send();
|
|
24
|
+
});
|
|
25
|
+
// The iPhone app: ready says whether this server holds a key to reach it.
|
|
26
|
+
app.get('/api/push/apns', async () => ({ ready: !!pusher?.toPhones }));
|
|
27
|
+
app.post('/api/push/apns', async (req, reply) => {
|
|
28
|
+
const d = parseDevice(req.body);
|
|
29
|
+
if (!d)
|
|
30
|
+
return reply.code(400).send({ error: 'invalid device' });
|
|
31
|
+
// A phone tells its token on every start and whenever iOS hands it out: what is known already is not
|
|
32
|
+
// written again.
|
|
33
|
+
const was = store.get().apnsDevices.find((x) => x.token === d.token);
|
|
34
|
+
if (was && was.topic === d.topic && was.words.waiting === d.words.waiting && was.words.idle === d.words.idle)
|
|
35
|
+
return reply.code(204).send();
|
|
36
|
+
store.update((s) => {
|
|
37
|
+
const was = s.apnsDevices.find((x) => x.token === d.token);
|
|
38
|
+
s.apnsDevices = [...s.apnsDevices.filter((x) => x.token !== d.token), { ...d, host: was?.host, createdAt: was?.createdAt ?? now() }];
|
|
39
|
+
});
|
|
40
|
+
return reply.code(204).send();
|
|
41
|
+
});
|
|
42
|
+
app.delete('/api/push/apns', async (req, reply) => {
|
|
43
|
+
const token = req.body?.token;
|
|
44
|
+
store.update((s) => {
|
|
45
|
+
s.apnsDevices = s.apnsDevices.filter((x) => x.token !== token);
|
|
46
|
+
});
|
|
47
|
+
return reply.code(204).send();
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
const words = (v) => typeof v === 'string' && v.length > 0 && v.length <= 200;
|
|
51
|
+
function parseDevice(body) {
|
|
52
|
+
const b = body;
|
|
53
|
+
if (!b || typeof b.token !== 'string' || !/^[0-9a-f]{64,200}$/i.test(b.token))
|
|
54
|
+
return null;
|
|
55
|
+
if (typeof b.topic !== 'string' || !/^[A-Za-z0-9.-]{1,155}$/.test(b.topic))
|
|
56
|
+
return null;
|
|
57
|
+
if (!words(b.words?.waiting) || !words(b.words?.idle))
|
|
58
|
+
return null;
|
|
59
|
+
return { token: b.token, topic: b.topic, words: { waiting: b.words.waiting, idle: b.words.idle } };
|
|
60
|
+
}
|
|
61
|
+
function parseSubscription(body) {
|
|
62
|
+
const b = body;
|
|
63
|
+
if (!b || typeof b.endpoint !== 'string' || b.endpoint.length > 2048)
|
|
64
|
+
return null;
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(b.endpoint);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (url.protocol !== 'https:')
|
|
73
|
+
return null;
|
|
74
|
+
const { p256dh, auth } = b.keys ?? {};
|
|
75
|
+
if (typeof p256dh !== 'string' || typeof auth !== 'string' || !p256dh || !auth)
|
|
76
|
+
return null;
|
|
77
|
+
return { endpoint: b.endpoint, keys: { p256dh, auth } };
|
|
78
|
+
}
|