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,198 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { writeFileAtomic } from './paths.js';
|
|
4
|
+
export const TMUX_CONF = `# Managed by workoffdesk. Edits are overwritten on start.
|
|
5
|
+
set -g mouse on
|
|
6
|
+
set -g history-limit 50000
|
|
7
|
+
set -sg escape-time 0
|
|
8
|
+
set -g status off
|
|
9
|
+
setw -g window-size latest
|
|
10
|
+
set -g set-titles on
|
|
11
|
+
set -g default-terminal xterm-256color
|
|
12
|
+
set -g focus-events on
|
|
13
|
+
`;
|
|
14
|
+
export function writeTmuxConf(path) {
|
|
15
|
+
writeFileAtomic(path, TMUX_CONF);
|
|
16
|
+
}
|
|
17
|
+
// The server also exits on its own once its last session ends, while a command is talking to it.
|
|
18
|
+
const NOT_RUNNING = /no server running|error connecting|No such file or directory|server exited unexpectedly/;
|
|
19
|
+
const NO_SESSION = /can't find session|session not found/;
|
|
20
|
+
const stderrOf = (e) => String(e?.stderr ?? '');
|
|
21
|
+
// Long enough for a loaded Mac; a client still waiting then is killed rather than left behind.
|
|
22
|
+
const TIMEOUT_MS = 10_000;
|
|
23
|
+
// A command tmux did not answer in time. Its server may still carry it out, so a paste may have gone in.
|
|
24
|
+
export class TmuxTimeout extends Error {
|
|
25
|
+
mayHaveRun = true;
|
|
26
|
+
constructor(command) {
|
|
27
|
+
super(`tmux ${command ?? ''} did not answer in time`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class Tmux {
|
|
31
|
+
bin;
|
|
32
|
+
base;
|
|
33
|
+
baseEnv;
|
|
34
|
+
timeoutMs;
|
|
35
|
+
constructor(opts) {
|
|
36
|
+
this.bin = opts.bin ?? 'tmux';
|
|
37
|
+
this.base = ['-L', opts.socket, '-f', opts.conf];
|
|
38
|
+
this.baseEnv = opts.env ?? process.env;
|
|
39
|
+
this.timeoutMs = opts.timeoutMs ?? TIMEOUT_MS;
|
|
40
|
+
}
|
|
41
|
+
// tmux escapes every non-ASCII byte, tabs in -F output included, unless the
|
|
42
|
+
// locale says UTF-8; launchd starts processes with no locale at all.
|
|
43
|
+
env() {
|
|
44
|
+
const env = { ...this.baseEnv };
|
|
45
|
+
delete env.TMUX;
|
|
46
|
+
if (!/utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || '')) {
|
|
47
|
+
delete env.LC_ALL;
|
|
48
|
+
env.LC_CTYPE = 'en_US.UTF-8';
|
|
49
|
+
}
|
|
50
|
+
return env;
|
|
51
|
+
}
|
|
52
|
+
run(args) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let late = false;
|
|
55
|
+
const child = execFile(this.bin, [...this.base, ...args], { env: this.env(), maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
56
|
+
clearTimeout(deadline);
|
|
57
|
+
if (late)
|
|
58
|
+
reject(new TmuxTimeout(args[0]));
|
|
59
|
+
else if (err)
|
|
60
|
+
reject(Object.assign(err, { stderr: String(stderr) }));
|
|
61
|
+
else
|
|
62
|
+
resolve(String(stdout));
|
|
63
|
+
});
|
|
64
|
+
const deadline = setTimeout(() => {
|
|
65
|
+
late = true;
|
|
66
|
+
child.kill('SIGKILL');
|
|
67
|
+
}, this.timeoutMs);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async newSession(name, opts) {
|
|
71
|
+
const envArgs = Object.entries(opts.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
|
|
72
|
+
const { cols, rows } = opts.size ?? { cols: 200, rows: 50 };
|
|
73
|
+
await this.run([
|
|
74
|
+
'new-session', '-d', '-s', name, '-c', opts.cwd, '-x', String(cols), '-y', String(rows),
|
|
75
|
+
...envArgs,
|
|
76
|
+
...(opts.command ? [opts.command] : []),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
async listSessions() {
|
|
80
|
+
let out;
|
|
81
|
+
try {
|
|
82
|
+
out = await this.run([
|
|
83
|
+
'list-sessions', '-F', '#{session_name}\t#{session_created}\t#{session_activity}\t#{session_attached}\t#{pane_pid}',
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
if (NOT_RUNNING.test(stderrOf(e)))
|
|
88
|
+
return [];
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
.split('\n')
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.map((line) => {
|
|
95
|
+
const [name, created, activity, attached, pane] = line.split('\t');
|
|
96
|
+
return { name, created: +created * 1000, activity: +activity * 1000, attached: +attached, panePid: Number(pane) || undefined };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// The process the session's pane runs; undefined when the session is gone.
|
|
100
|
+
async panePid(name) {
|
|
101
|
+
try {
|
|
102
|
+
return Number(await this.run(['display-message', '-p', '-t', `=${name}:`, '#{pane_pid}'])) || undefined;
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (NOT_RUNNING.test(stderrOf(e)) || NO_SESSION.test(stderrOf(e)))
|
|
106
|
+
return undefined;
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Ends what the session's pane runs and starts `command` there instead, in the same session.
|
|
111
|
+
async respawnPane(name, opts) {
|
|
112
|
+
const envArgs = Object.entries(opts.env).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
|
|
113
|
+
await this.run(['respawn-pane', '-k', '-t', `=${name}:`, '-c', opts.cwd, ...envArgs, opts.command]);
|
|
114
|
+
}
|
|
115
|
+
async hasSession(name) {
|
|
116
|
+
return (await this.listSessions()).some((s) => s.name === name);
|
|
117
|
+
}
|
|
118
|
+
async killSession(name) {
|
|
119
|
+
try {
|
|
120
|
+
await this.run(['kill-session', '-t', `=${name}`]);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
if (!NOT_RUNNING.test(stderrOf(e)) && !NO_SESSION.test(stderrOf(e)))
|
|
124
|
+
throw e;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async sendKeys(name, text) {
|
|
128
|
+
await this.run(['send-keys', '-t', `=${name}:`, '-l', text]);
|
|
129
|
+
await this.run(['send-keys', '-t', `=${name}:`, 'Enter']);
|
|
130
|
+
}
|
|
131
|
+
// One tmux command, so nothing can slip in between the check and the cancel.
|
|
132
|
+
async leaveCopyMode(name) {
|
|
133
|
+
await this.run(['if-shell', '-F', '-t', `=${name}:`, '#{pane_in_mode}', 'send-keys -X cancel']);
|
|
134
|
+
}
|
|
135
|
+
async capture(name, lines) {
|
|
136
|
+
return this.run(['capture-pane', '-p', '-e', '-J', '-t', `=${name}:`, '-S', `-${lines}`]);
|
|
137
|
+
}
|
|
138
|
+
// The screen as it is now, as plain text, without the scrollback.
|
|
139
|
+
async screen(name) {
|
|
140
|
+
return this.run(['capture-pane', '-p', '-t', `=${name}:`]);
|
|
141
|
+
}
|
|
142
|
+
// The screen as plain text and up to `lines` of the scrollback above it.
|
|
143
|
+
async screenWithHistory(name, lines) {
|
|
144
|
+
return this.run(['capture-pane', '-p', '-t', `=${name}:`, '-S', `-${lines}`]);
|
|
145
|
+
}
|
|
146
|
+
// The screen as plain text with each wrapped line joined back into one.
|
|
147
|
+
async screenJoined(name) {
|
|
148
|
+
return this.run(['capture-pane', '-p', '-J', '-t', `=${name}:`]);
|
|
149
|
+
}
|
|
150
|
+
// The screen with its colours and dimming as escape sequences.
|
|
151
|
+
async screenStyled(name) {
|
|
152
|
+
return this.run(['capture-pane', '-p', '-e', '-t', `=${name}:`]);
|
|
153
|
+
}
|
|
154
|
+
// The screen as `screen` does, and the scrollback lines just above it, from one capture.
|
|
155
|
+
async screenAndAbove(name, lines) {
|
|
156
|
+
const [text, height] = await Promise.all([
|
|
157
|
+
this.run(['capture-pane', '-p', '-t', `=${name}:`, '-S', `-${lines}`]),
|
|
158
|
+
this.run(['display-message', '-p', '-t', `=${name}:`, '#{pane_height}']),
|
|
159
|
+
]);
|
|
160
|
+
const all = text.replace(/\n$/, '').split('\n');
|
|
161
|
+
const rows = Number(height.trim());
|
|
162
|
+
return { above: all.slice(0, -rows).join('\n'), screen: `${all.slice(-rows).join('\n')}\n` };
|
|
163
|
+
}
|
|
164
|
+
// Text as a terminal pastes it, bracketed for a program that asked for that, then Enter as a key.
|
|
165
|
+
async paste(name, text) {
|
|
166
|
+
const buffer = `workoffdesk-${randomBytes(6).toString('hex')}`;
|
|
167
|
+
const target = `=${name}:`;
|
|
168
|
+
await this.run(['set-buffer', '-b', buffer, '--', text, ';', 'paste-buffer', '-p', '-r', '-d', '-b', buffer, '-t', target, ';', 'send-keys', '-t', target, 'Enter']);
|
|
169
|
+
}
|
|
170
|
+
async sendRaw(name, keys) {
|
|
171
|
+
await this.run(['send-keys', '-t', `=${name}:`, '-l', '--', keys]);
|
|
172
|
+
}
|
|
173
|
+
async alternateOn(name) {
|
|
174
|
+
return (await this.run(['display-message', '-p', '-t', `=${name}:`, '#{alternate_on}'])).trim() === '1';
|
|
175
|
+
}
|
|
176
|
+
// Resizing makes the window's size manual; it is set back to follow whichever terminal attaches next.
|
|
177
|
+
async resizeWindow(name, size) {
|
|
178
|
+
await this.run(['resize-window', '-t', `=${name}:`, '-x', String(size.cols), '-y', String(size.rows)]);
|
|
179
|
+
await this.run(['set-option', '-w', '-t', `=${name}:`, 'window-size', 'latest']);
|
|
180
|
+
}
|
|
181
|
+
async windowSize(name) {
|
|
182
|
+
const out = await this.run(['display-message', '-p', '-t', `=${name}:`, '#{window_width} #{window_height}']);
|
|
183
|
+
const [cols, rows] = out.trim().split(' ').map(Number);
|
|
184
|
+
return { cols, rows };
|
|
185
|
+
}
|
|
186
|
+
async killServer() {
|
|
187
|
+
try {
|
|
188
|
+
await this.run(['kill-server']);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
if (!NOT_RUNNING.test(stderrOf(e)))
|
|
192
|
+
throw e;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
attachArgs(name) {
|
|
196
|
+
return [...this.base, 'attach-session', '-t', `=${name}`];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
// The package this file is part of, found by walking up, as in the source and in a build it sits at a different depth.
|
|
5
|
+
function readVersion() {
|
|
6
|
+
for (let dir = dirname(fileURLToPath(import.meta.url)); dir !== dirname(dir); dir = dirname(dir)) {
|
|
7
|
+
const file = join(dir, 'package.json');
|
|
8
|
+
if (!existsSync(file))
|
|
9
|
+
continue;
|
|
10
|
+
const pkg = JSON.parse(readFileSync(file, 'utf8'));
|
|
11
|
+
if (pkg.name === 'workoffdesk')
|
|
12
|
+
return pkg.version ?? '0.0.0';
|
|
13
|
+
}
|
|
14
|
+
return '0.0.0';
|
|
15
|
+
}
|
|
16
|
+
export const VERSION = readVersion();
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { appendFile, chmod, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { extname, join } from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { Conflict, InvalidRequest } from '../system/errors.js';
|
|
7
|
+
import { pruneOld, stamp } from './media.js';
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
// A file from the phone (a video, a document) comes in parts, each appended where the Mac's copy ends, so a
|
|
10
|
+
// line that drops picks up from there. What it is does not matter: it is kept as it came, under its own name.
|
|
11
|
+
export const PART_MAX = 8 * 1024 * 1024;
|
|
12
|
+
const FILE_MAX = 4 * 1024 * 1024 * 1024;
|
|
13
|
+
const ID = /^[a-z0-9]{16,40}$/;
|
|
14
|
+
// Where a file waits while it comes, and what became of one that is whole, for a finish asked again.
|
|
15
|
+
const PARTS = '.parts';
|
|
16
|
+
const PART_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
|
|
17
|
+
// A video is also given as stills, which Claude can look at: this many at most, at least a second apart.
|
|
18
|
+
const FRAMES_AT_MOST = 30;
|
|
19
|
+
const FRAMES_AT_ONCE = 4;
|
|
20
|
+
const FRAME_WIDTH = 1280;
|
|
21
|
+
const PROBE_MS = 15_000;
|
|
22
|
+
const NAME_MAX = 120;
|
|
23
|
+
// One write at a time for each file, so a part sent again while the first is still being written cannot
|
|
24
|
+
// land twice.
|
|
25
|
+
const busy = new Map();
|
|
26
|
+
function inTurn(id, work) {
|
|
27
|
+
const next = (busy.get(id) ?? Promise.resolve()).then(work, work);
|
|
28
|
+
const settled = next.catch(() => { });
|
|
29
|
+
busy.set(id, settled);
|
|
30
|
+
void settled.then(() => busy.get(id) === settled && busy.delete(id));
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
function partOf(dir, id) {
|
|
34
|
+
if (!ID.test(id))
|
|
35
|
+
throw new InvalidRequest('bad upload id');
|
|
36
|
+
return join(dir, PARTS, id);
|
|
37
|
+
}
|
|
38
|
+
const sizeOf = (file) => stat(file).then((s) => s.size, () => 0);
|
|
39
|
+
// How much of the file the Mac has.
|
|
40
|
+
export const partSize = (dir, id) => sizeOf(partOf(dir, id));
|
|
41
|
+
// Adds bytes where the Mac's copy ends; `at` is where the phone thinks that is. The size after.
|
|
42
|
+
export function appendPart(dir, id, at, bytes) {
|
|
43
|
+
const file = partOf(dir, id);
|
|
44
|
+
return inTurn(id, async () => {
|
|
45
|
+
const size = await sizeOf(file);
|
|
46
|
+
if (at !== size)
|
|
47
|
+
throw new Conflict(`the Mac has ${size} bytes of this file`, 'upload-offset');
|
|
48
|
+
if (size + bytes.length > FILE_MAX)
|
|
49
|
+
throw new InvalidRequest('the file is too large');
|
|
50
|
+
await mkdir(join(dir, PARTS), { recursive: true, mode: 0o700 });
|
|
51
|
+
await appendFile(file, bytes, { mode: 0o600 });
|
|
52
|
+
return size + bytes.length;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// What is safe as a file name: one path segment, no quotes (the path is quoted to Claude), no control
|
|
56
|
+
// characters, not too long.
|
|
57
|
+
export function fileName(name) {
|
|
58
|
+
const clean = [...name.replace(/[/\\"\p{Cc}]+/gu, '_').trim()].slice(-NAME_MAX).join('');
|
|
59
|
+
return clean && clean !== '.' && clean !== '..' && !clean.startsWith('.') ? clean : `file${clean}`;
|
|
60
|
+
}
|
|
61
|
+
// Moves a whole file to a folder of its own under its own name and returns its path, with stills when it is
|
|
62
|
+
// a video. Asked again, it answers the same.
|
|
63
|
+
export function finishPart(dir, id, name, size) {
|
|
64
|
+
const file = partOf(dir, id);
|
|
65
|
+
const record = `${file}.json`;
|
|
66
|
+
return inTurn(id, async () => {
|
|
67
|
+
const done = await readFile(record, 'utf8').then((r) => JSON.parse(r), () => null);
|
|
68
|
+
if (done)
|
|
69
|
+
return done;
|
|
70
|
+
const have = await sizeOf(file);
|
|
71
|
+
if (have !== size)
|
|
72
|
+
throw new Conflict(`the Mac has ${have} of ${size} bytes`, 'upload-incomplete');
|
|
73
|
+
// An empty file never had a part to start it.
|
|
74
|
+
if (!size)
|
|
75
|
+
await mkdir(join(dir, PARTS), { recursive: true, mode: 0o700 }).then(() => writeFile(file, '', { mode: 0o600, flag: 'a' }));
|
|
76
|
+
await pruneOld(dir);
|
|
77
|
+
await pruneParts(dir);
|
|
78
|
+
const home = join(dir, `${stamp(new Date())}-${randomBytes(3).toString('hex')}`);
|
|
79
|
+
await mkdir(home, { mode: 0o700 });
|
|
80
|
+
const path = join(home, fileName(name));
|
|
81
|
+
await rename(file, path);
|
|
82
|
+
await chmod(path, 0o600);
|
|
83
|
+
const sent = { path, frames: await videoFrames(path, join(home, 'frames')) };
|
|
84
|
+
await writeFile(record, JSON.stringify(sent), { mode: 0o600 });
|
|
85
|
+
return sent;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async function pruneParts(dir) {
|
|
89
|
+
const parts = join(dir, PARTS);
|
|
90
|
+
const cutoff = Date.now() - PART_MAX_AGE_MS;
|
|
91
|
+
for (const name of await readdir(parts).catch(() => [])) {
|
|
92
|
+
const file = join(parts, name);
|
|
93
|
+
const at = (await stat(file).catch(() => null))?.mtimeMs ?? Infinity;
|
|
94
|
+
if (at < cutoff)
|
|
95
|
+
await rm(file, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Stills spread over a video, named by when they are (at-01m05s.jpg); null for what is not a video, or when
|
|
99
|
+
// ffmpeg is not there.
|
|
100
|
+
async function videoFrames(file, out) {
|
|
101
|
+
let seconds;
|
|
102
|
+
try {
|
|
103
|
+
const { stdout } = await run('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_type:format=duration', '-of', 'json', file], {
|
|
104
|
+
timeout: PROBE_MS,
|
|
105
|
+
});
|
|
106
|
+
const info = JSON.parse(stdout);
|
|
107
|
+
seconds = Number(info.format?.duration);
|
|
108
|
+
if (!info.streams?.length || !(seconds >= 1))
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const every = Math.max(1, Math.ceil(seconds / FRAMES_AT_MOST));
|
|
115
|
+
const times = [];
|
|
116
|
+
for (let at = 0; at < seconds; at += every)
|
|
117
|
+
times.push(at);
|
|
118
|
+
await mkdir(out, { mode: 0o700 });
|
|
119
|
+
let count = 0;
|
|
120
|
+
const still = async (at) => {
|
|
121
|
+
const name = `at-${String(Math.floor(at / 60)).padStart(2, '0')}m${String(at % 60).padStart(2, '0')}s.jpg`;
|
|
122
|
+
await run('ffmpeg', ['-v', 'error', '-ss', String(at), '-i', file, '-frames:v', '1', '-vf', `scale='min(${FRAME_WIDTH},iw)':-2`, '-q:v', '4', join(out, name)], {
|
|
123
|
+
timeout: PROBE_MS,
|
|
124
|
+
}).then(() => count++, () => { });
|
|
125
|
+
};
|
|
126
|
+
for (let i = 0; i < times.length; i += FRAMES_AT_ONCE)
|
|
127
|
+
await Promise.all(times.slice(i, i + FRAMES_AT_ONCE).map(still));
|
|
128
|
+
if (!count) {
|
|
129
|
+
await rm(out, { recursive: true, force: true });
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return { dir: out, count, every };
|
|
133
|
+
}
|
|
134
|
+
// A file's folder in uploads, as finishPart names it.
|
|
135
|
+
const FOLDER = /^\d{4}-\d{2}-\d{2}-\d{6}-[0-9a-f]{6}$/;
|
|
136
|
+
const FRAMES = 'frames';
|
|
137
|
+
function folderOf(dir, folder) {
|
|
138
|
+
if (!FOLDER.test(folder))
|
|
139
|
+
throw new InvalidRequest('bad folder');
|
|
140
|
+
return join(dir, folder);
|
|
141
|
+
}
|
|
142
|
+
// The first still of a video, which stands for it where it is shown; null for what has none.
|
|
143
|
+
export async function videoCover(dir, folder) {
|
|
144
|
+
const frames = join(folderOf(dir, folder), FRAMES);
|
|
145
|
+
const first = (await readdir(frames).catch(() => [])).filter((n) => n.endsWith('.jpg')).sort()[0];
|
|
146
|
+
return first ? readFile(join(frames, first)) : null;
|
|
147
|
+
}
|
|
148
|
+
// The file a folder was made for: the one thing in it besides the stills.
|
|
149
|
+
async function keptFile(dir, folder) {
|
|
150
|
+
const home = folderOf(dir, folder);
|
|
151
|
+
const name = (await readdir(home).catch(() => [])).find((n) => n !== FRAMES);
|
|
152
|
+
return name ? join(home, name) : null;
|
|
153
|
+
}
|
|
154
|
+
const VIDEO_TYPES = { '.mov': 'video/quicktime', '.mp4': 'video/mp4', '.m4v': 'video/mp4' };
|
|
155
|
+
export async function videoFile(dir, folder) {
|
|
156
|
+
const file = await keptFile(dir, folder);
|
|
157
|
+
if (!file)
|
|
158
|
+
return null;
|
|
159
|
+
const size = await stat(file).then((s) => s.size, () => null);
|
|
160
|
+
return size === null ? null : { file, type: VIDEO_TYPES[extname(file).toLowerCase()] ?? 'application/octet-stream', size };
|
|
161
|
+
}
|
|
162
|
+
// The bytes a Range header asks for, as a video player asks for them; null for the whole file, and
|
|
163
|
+
// 'unsatisfiable' for a range outside it.
|
|
164
|
+
export function byteRange(range, size) {
|
|
165
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range ?? '');
|
|
166
|
+
if (!m || (!m[1] && !m[2]))
|
|
167
|
+
return null;
|
|
168
|
+
const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]));
|
|
169
|
+
const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
|
|
170
|
+
return start <= end && start < size ? { start, end } : 'unsatisfiable';
|
|
171
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { chmod, mkdir, mkdtemp, open, readdir, readFile, realpath, rm, stat, writeFile, } from 'node:fs/promises';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { UnsupportedMedia } from '../system/errors.js';
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
export class NotAnImage extends UnsupportedMedia {
|
|
10
|
+
}
|
|
11
|
+
const HEIF_BRANDS = new Set([
|
|
12
|
+
'heic',
|
|
13
|
+
'heix',
|
|
14
|
+
'heim',
|
|
15
|
+
'heis',
|
|
16
|
+
'hevc',
|
|
17
|
+
'hevx',
|
|
18
|
+
'mif1',
|
|
19
|
+
'msf1',
|
|
20
|
+
]);
|
|
21
|
+
const UPLOAD_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
22
|
+
// Judged by the bytes, never by what the client says the file is.
|
|
23
|
+
export function imageKind(b) {
|
|
24
|
+
const ascii = (from, to) => b.subarray(from, to).toString('latin1');
|
|
25
|
+
if (b.length >= 8 && b.readUInt32BE(0) === 0x89504e47)
|
|
26
|
+
return 'png';
|
|
27
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
|
|
28
|
+
return 'jpg';
|
|
29
|
+
if (ascii(0, 4) === 'GIF8')
|
|
30
|
+
return 'gif';
|
|
31
|
+
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP')
|
|
32
|
+
return 'webp';
|
|
33
|
+
if (ascii(4, 8) === 'ftyp' && HEIF_BRANDS.has(ascii(8, 12)))
|
|
34
|
+
return 'heic';
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export const stamp = (d) => {
|
|
38
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
39
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
40
|
+
};
|
|
41
|
+
export async function pruneOld(dir) {
|
|
42
|
+
const cutoff = Date.now() - UPLOAD_MAX_AGE_MS;
|
|
43
|
+
for (const name of await readdir(dir).catch(() => [])) {
|
|
44
|
+
const file = join(dir, name);
|
|
45
|
+
if (name !== '.parts' && ((await stat(file).catch(() => null))?.mtimeMs ?? Infinity) < cutoff)
|
|
46
|
+
await rm(file, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The name saveImage gives an upload; nothing else is ever read back.
|
|
50
|
+
const UPLOAD_NAME = /^\d{4}-\d{2}-\d{2}-\d{6}-[0-9a-f]{6}\.(png|jpg|gif|webp)$/;
|
|
51
|
+
const MIME = {
|
|
52
|
+
png: 'image/png',
|
|
53
|
+
jpg: 'image/jpeg',
|
|
54
|
+
gif: 'image/gif',
|
|
55
|
+
webp: 'image/webp',
|
|
56
|
+
heic: 'image/heic',
|
|
57
|
+
};
|
|
58
|
+
// An upload to show back on the phone, typed by its bytes; null for any other name or for what is not an image.
|
|
59
|
+
export async function readUpload(dir, name) {
|
|
60
|
+
if (!UPLOAD_NAME.test(name))
|
|
61
|
+
return null;
|
|
62
|
+
const bytes = await readFile(join(dir, name)).catch(() => null);
|
|
63
|
+
const kind = bytes && imageKind(bytes);
|
|
64
|
+
return kind ? { bytes: bytes, type: MIME[kind] } : null;
|
|
65
|
+
}
|
|
66
|
+
// Saves an image sent from the phone and returns its path, for Claude to be pointed at.
|
|
67
|
+
// iPhone photos can be HEIC, which Claude cannot read; those are kept as JPEG.
|
|
68
|
+
export async function saveImage(dir, bytes) {
|
|
69
|
+
const kind = imageKind(bytes);
|
|
70
|
+
if (!kind)
|
|
71
|
+
throw new NotAnImage('only images can be uploaded (PNG, JPEG, GIF, WebP, HEIC)', 'not-an-image');
|
|
72
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
await chmod(dir, 0o700);
|
|
74
|
+
await pruneOld(dir);
|
|
75
|
+
const base = join(dir, `${stamp(new Date())}-${randomBytes(3).toString('hex')}`);
|
|
76
|
+
if (kind !== 'heic') {
|
|
77
|
+
await writeFile(`${base}.${kind}`, bytes, { mode: 0o600, flag: 'wx' });
|
|
78
|
+
return `${base}.${kind}`;
|
|
79
|
+
}
|
|
80
|
+
const work = await mkdtemp(join(tmpdir(), 'workoffdesk-heic-'));
|
|
81
|
+
try {
|
|
82
|
+
const src = join(work, 'in.heic');
|
|
83
|
+
await writeFile(src, bytes, { mode: 0o600 });
|
|
84
|
+
await run('sips', ['-s', 'format', 'jpeg', src, '--out', `${base}.jpg`]);
|
|
85
|
+
await chmod(`${base}.jpg`, 0o600);
|
|
86
|
+
return `${base}.jpg`;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await rm(work, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const NAMED_IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
|
93
|
+
const NAMED_IMAGE_MAX = 20 * 1024 * 1024;
|
|
94
|
+
const SEARCH_CHUNK = 1024 * 1024;
|
|
95
|
+
// Whether a transcript holds `text` as it is written into JSON, read a piece at a time.
|
|
96
|
+
async function mentions(transcript, text, chunk) {
|
|
97
|
+
const needle = Buffer.from(JSON.stringify(text).slice(1, -1));
|
|
98
|
+
const file = await open(transcript, 'r').catch(() => null);
|
|
99
|
+
if (!file)
|
|
100
|
+
return false;
|
|
101
|
+
try {
|
|
102
|
+
const buf = Buffer.alloc(chunk + needle.length);
|
|
103
|
+
let kept = 0;
|
|
104
|
+
for (;;) {
|
|
105
|
+
const { bytesRead } = await file.read(buf, kept, chunk, null);
|
|
106
|
+
if (!bytesRead)
|
|
107
|
+
return false;
|
|
108
|
+
const end = kept + bytesRead;
|
|
109
|
+
if (buf.subarray(0, end).includes(needle))
|
|
110
|
+
return true;
|
|
111
|
+
kept = Math.min(needle.length - 1, end);
|
|
112
|
+
buf.copy(buf, 0, end - kept, end);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await file.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// An image Claude named in a session (in what it said, or a file it read), to show in the chat. Only a
|
|
120
|
+
// path written as such in the session's transcript is read, and only what is an image both by the name it
|
|
121
|
+
// has once links are followed and by its bytes.
|
|
122
|
+
export async function readMentionedImage(transcript, path, opts = {}) {
|
|
123
|
+
const absolute = path.startsWith('~/') ? join(opts.home ?? homedir(), path.slice(2)) : path;
|
|
124
|
+
if (!absolute.startsWith('/'))
|
|
125
|
+
return 'refused';
|
|
126
|
+
if (!(await mentions(transcript, path, opts.chunk ?? SEARCH_CHUNK)))
|
|
127
|
+
return 'refused';
|
|
128
|
+
const real = await realpath(absolute).catch((e) => (e?.code === 'ENOENT' ? null : undefined));
|
|
129
|
+
if (real === null)
|
|
130
|
+
return 'gone';
|
|
131
|
+
if (!real || !NAMED_IMAGE.test(real))
|
|
132
|
+
return 'refused';
|
|
133
|
+
const info = await stat(real);
|
|
134
|
+
if (!info.isFile() || info.size > (opts.maxBytes ?? NAMED_IMAGE_MAX))
|
|
135
|
+
return 'refused';
|
|
136
|
+
const bytes = await readFile(real);
|
|
137
|
+
const kind = imageKind(bytes);
|
|
138
|
+
return kind ? { bytes, type: MIME[kind] } : 'refused';
|
|
139
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{c as _,t,A as z,e as $,j as e,r as i,C as q,I as G}from"./main-w7i8PvU7.js";const k=["checks","account","address","service","phone"],O=["macos","node","tmux","claude"],R=s=>s.checks.filter(n=>O.includes(n.id)&&!n.ok);function L(s){return R(s).length?"checks":s.user?s.origin?s.checks.some(n=>n.id==="service"&&n.ok)?"phone":"service":"address":"account"}const P="setup-token";function M(s,n){const u=s.replace(/^#/,"");try{return u&&n.setItem(P,u),u||n.getItem(P)}catch{return u||null}}const F=12,W={getItem:s=>sessionStorage.getItem(s),setItem:(s,n)=>sessionStorage.setItem(s,n),removeItem:s=>sessionStorage.removeItem(s)},S=M(location.hash,W);location.hash&&history.replaceState(null,"",location.pathname);const{api:D}=_({base:()=>location.origin,headers:()=>S?{"x-setup-token":S}:{},onUnauthorized:()=>{}}),w=(s,n)=>D(s,{method:"POST",json:n}),v=s=>s instanceof z&&s.status===403?t.setupNoToken:$(s,t.failed,t.setupGone);function C({busy:s,children:n}){return s?e.jsx("span",{className:"spinner small"}):e.jsx(e.Fragment,{children:n})}function Y({state:s,reload:n,next:u}){const[a,r]=i.useState(!1),l=R(s),d=s.checks.filter(o=>O.includes(o.id)),x=async()=>{r(!0),await n(),r(!1)};return e.jsxs(e.Fragment,{children:[e.jsx("p",{className:"setup-note",children:l.length?t.setupChecksFix:t.setupChecksNote}),e.jsx("div",{className:"group",children:[...l,...d.filter(o=>o.ok)].map(o=>e.jsx(q,{check:o},o.id))}),e.jsx("div",{className:"setup-actions",children:l.length?e.jsx("button",{className:"btn primary",onClick:x,disabled:a,children:e.jsx(C,{busy:a,children:t.setupCheckAgain})}):e.jsx("button",{className:"btn primary",onClick:u,children:t.setupContinue})})]})}function J({state:s,reload:n,next:u}){const[a,r]=i.useState(!s.user),[l,d]=i.useState(""),[x,o]=i.useState(""),[b,N]=i.useState(""),[g,f]=i.useState(null),[c,p]=i.useState(!1);if(!a&&s.user)return e.jsxs(e.Fragment,{children:[e.jsx("p",{className:"setup-note",children:t.setupAccountHave(s.user)}),e.jsxs("div",{className:"setup-actions",children:[e.jsx("button",{className:"btn primary",onClick:u,children:t.setupKeep}),e.jsx("button",{className:"btn",onClick:()=>r(!0),children:t.setupReplace})]})]});const j=async h=>{if(h.preventDefault(),x!==b)return f(t.setupPasswordsDiffer);p(!0),f(null);try{await w("/api/setup/account",{username:l,password:x}),await n(),u()}catch(y){f(v(y))}finally{p(!1)}};return e.jsxs("form",{className:"setup-form",onSubmit:j,children:[e.jsx("p",{className:"setup-note",children:t.setupAccountNote}),s.user&&e.jsx("p",{className:"setup-warn",children:t.setupReplaceNote}),e.jsxs("label",{className:"field",children:[t.username,e.jsx("input",{value:l,onChange:h=>d(h.target.value),autoComplete:"username",autoCapitalize:"none",spellCheck:!1,required:!0})]}),e.jsxs("label",{className:"field",children:[t.password,e.jsx("input",{type:"password",value:x,onChange:h=>o(h.target.value),autoComplete:"new-password",minLength:F,required:!0}),e.jsx("small",{children:t.setupPasswordRule(F)})]}),e.jsxs("label",{className:"field",children:[t.setupPasswordAgain,e.jsx("input",{type:"password",value:b,onChange:h=>N(h.target.value),autoComplete:"new-password",required:!0})]}),g&&e.jsx("div",{className:"alert",role:"alert",children:g}),e.jsxs("div",{className:"setup-actions",children:[e.jsx("button",{className:"btn primary",type:"submit",disabled:c,children:e.jsx(C,{busy:c,children:t.setupSaveAccount})}),s.user&&e.jsx("button",{className:"btn",type:"button",onClick:()=>r(!1),children:t.cancel})]})]})}function Q({state:s,reload:n,next:u}){const{tailscale:a,cloudflared:r}=s.remote,l=(r==null?void 0:r.tunnels)??[],[d,x]=i.useState(a!=null&&a.running?"tailscale":l.length?"cloudflare":"other"),[o,b]=i.useState(l[0]??""),[N,g]=i.useState(""),[f,c]=i.useState("https://"),[p,j]=i.useState(null),[h,y]=i.useState(null),H=async m=>{m.preventDefault();const E=d==="tailscale"?{kind:d}:d==="cloudflare"?{kind:d,tunnel:o,hostname:N.trim()}:{kind:d,origin:f.trim()};y("save"),j(null);try{await w("/api/setup/address",E),await n(),u()}catch(T){j(v(T))}finally{y(null)}},K=async()=>{y("check"),await n(),y(null)},A=(m,E,T,I,U)=>e.jsxs("label",{className:`setup-choice${d===m?" chosen":""}${I?"":" off"}`,children:[e.jsx("input",{type:"radio",name:"kind",checked:d===m,disabled:!I,onChange:()=>x(m)}),e.jsxs("span",{className:"setup-choice-text",children:[e.jsx("strong",{children:E}),e.jsx("span",{children:T}),d===m&&U]})]});return e.jsxs("form",{className:"setup-form",onSubmit:H,children:[e.jsx("p",{className:"setup-note",children:t.setupAddressNote}),s.origin&&e.jsxs("div",{className:"setup-now",children:[e.jsx("span",{children:t.setupAddressNow(s.origin)}),e.jsx("button",{className:"btn small",type:"button",onClick:u,children:t.setupKeep})]}),e.jsxs("div",{className:"setup-choices",children:[a&&A("tailscale",t.setupTailscale,a.running?t.setupTailscaleUse(a.dnsName):t.setupTailscaleDown,a.running),l.length>0&&A("cloudflare",t.setupCloudflare,t.setupCloudflareNote,!0,e.jsxs("span",{className:"setup-choice-fields",children:[e.jsxs("label",{className:"field",children:[t.setupTunnel,e.jsx("select",{value:o,onChange:m=>b(m.target.value),children:l.map(m=>e.jsx("option",{children:m},m))})]}),e.jsxs("label",{className:"field",children:[t.setupHostname,e.jsx("input",{value:N,onChange:m=>g(m.target.value),placeholder:"workoffdesk.example.com",autoCapitalize:"none",spellCheck:!1,required:!0})]})]})),A("other",t.setupOther,t.setupOtherNote(s.port),!0,e.jsx("span",{className:"setup-choice-fields",children:e.jsx("label",{className:"field",children:e.jsx("input",{value:f,onChange:m=>c(m.target.value),type:"url",autoCapitalize:"none",spellCheck:!1,required:!0,"aria-label":t.setupOther})})}))]}),!(a!=null&&a.running)&&!l.length&&e.jsx("p",{className:"setup-note",children:t.setupNoWayIn}),p&&e.jsx("div",{className:"alert",role:"alert",children:p}),e.jsxs("div",{className:"setup-actions",children:[e.jsx("button",{className:"btn primary",type:"submit",disabled:h!==null,children:e.jsx(C,{busy:h==="save",children:t.setupUseAddress})}),e.jsx("button",{className:"btn",type:"button",onClick:K,disabled:h!==null,children:e.jsx(C,{busy:h==="check",children:t.setupCheckAgain})})]})]})}function V({reload:s,next:n}){const[u,a]=i.useState(!1),[r,l]=i.useState(null),d=async()=>{a(!0),l(null);try{const x=await w("/api/setup/service",{});await s(),x.ok?n():l(x.message)}catch(x){l(v(x))}finally{a(!1)}};return e.jsxs(e.Fragment,{children:[e.jsx("p",{className:"setup-note",children:t.setupServiceNote}),u&&e.jsxs("div",{className:"setup-busy",role:"status",children:[e.jsx("span",{className:"spinner small"}),t.setupInstalling]}),r&&e.jsx("div",{className:"alert setup-pre",role:"alert",children:r}),e.jsx("div",{className:"setup-actions",children:e.jsx("button",{className:"btn primary",onClick:d,disabled:u,children:r?t.tryAgain:t.setupInstall})})]})}function B({state:s}){return e.jsxs(e.Fragment,{children:[s.qr&&e.jsx("div",{className:"setup-qr",dangerouslySetInnerHTML:{__html:s.qr}}),e.jsx("p",{className:"setup-origin",children:s.origin}),s.user&&e.jsx("p",{className:"setup-note centered",children:t.setupPhoneNote(s.user)})]})}function X({state:s,finish:n}){const[u,a]=i.useState(!1),r=s.checks.filter(l=>!l.ok);return e.jsxs(e.Fragment,{children:[e.jsx(B,{state:s}),e.jsx("div",{className:`mac-summary ${r.length?"bad":"ok"}`,children:r.length?t.macProblemCount(r.length):t.macAllGood}),r.length>0&&e.jsx("div",{className:"group",children:r.map(l=>e.jsx(q,{check:l},l.id))}),e.jsx("div",{className:"setup-actions",children:e.jsx("button",{className:"btn primary",disabled:u,onClick:async()=>{a(!0),await n(),a(!1)},children:t.setupFinish})})]})}const Z=(s,n)=>s==="account"?n.user:s==="address"?n.origin:s==="service"?t.macRunning:null,ee=["account","address"];function te(){const[s,n]=i.useState(null),[u,a]=i.useState(null),[r,l]=i.useState(S?null:t.setupNoToken),[d,x]=i.useState(!1),o=i.useCallback(async()=>{try{const c=await D("/api/setup/state");n(c),l(null),a(p=>p??L(c))}catch(c){l(v(c))}},[]);i.useEffect(()=>{document.title=t.setupTitle,S&&o()},[o]);const b=async()=>{try{await w("/api/setup/done",{}),x(!0)}catch(c){l(v(c))}},N=u?k.indexOf(u):-1,g=()=>a(k[Math.min(N+1,k.length-1)]),f=(c,p)=>c==="checks"?e.jsx(Y,{state:p,reload:o,next:g}):c==="account"?e.jsx(J,{state:p,reload:o,next:g}):c==="address"?e.jsx(Q,{state:p,reload:o,next:g}):c==="service"?e.jsx(V,{reload:o,next:g}):e.jsx(X,{state:p,finish:b});return e.jsxs("main",{className:"setup",children:[e.jsxs("header",{className:"brand setup-brand",children:[e.jsx("img",{src:"/icon.svg",alt:""}),e.jsx("h1",{children:t.setupTitle}),e.jsx("p",{children:t.setupSub})]}),d?e.jsxs("div",{className:"setup-done",children:[e.jsx("div",{className:"mac-summary ok",role:"status",children:t.setupFinished}),s&&e.jsx(B,{state:s})]}):e.jsxs(e.Fragment,{children:[r&&e.jsx("div",{className:"alert setup-gap",role:"alert",children:r}),!s&&!r&&e.jsx("div",{className:"mac-loading",children:e.jsx("span",{className:"spinner"})}),s&&u&&e.jsx("ol",{className:"setup-steps",children:k.map((c,p)=>{const j=p<N?"done":p===N?"now":"later",h=j==="done"?Z(c,s):null;return e.jsxs("li",{className:`setup-step ${j}`,"aria-current":j==="now"?"step":void 0,children:[e.jsxs("div",{className:"setup-step-head",children:[e.jsx("span",{className:"setup-num",children:j==="done"?e.jsx(G,{}):p+1}),e.jsx("strong",{children:t.setupSteps[c]}),h&&e.jsx("span",{className:"setup-summary",children:h}),j==="done"&&ee.includes(c)&&e.jsx("button",{className:"setup-change",onClick:()=>a(c),children:t.setupChange})]}),j==="now"&&e.jsx("div",{className:"setup-body",children:f(c,s)})]},c)})})]})]})}export{te as Setup};
|