pw-repl 0.10.0 → 0.11.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/AGENTS.md +2 -1
- package/README.md +19 -14
- package/bin/pw-repl.js +12 -1
- package/lib/background.js +14 -4
- package/lib/cdp.js +6 -0
- package/lib/cli-names.js +1 -1
- package/lib/client.js +3 -2
- package/lib/commands.js +3 -0
- package/lib/cursor.js +21 -47
- package/lib/elements.js +45 -8
- package/lib/help.js +85 -19
- package/lib/inspect.js +35 -4
- package/lib/modes.js +6 -1
- package/lib/output.js +1 -1
- package/lib/overlay.js +79 -0
- package/lib/record.js +13 -2
- package/lib/runner.js +16 -9
- package/lib/send.js +27 -4
- package/lib/server.js +12 -4
- package/lib/skill.js +1 -1
- package/lib/start.js +4 -1
- package/lib/syntax.js +30 -1
- package/lib/tabs.js +24 -8
- package/lib/toast.js +166 -0
- package/lib/watch.js +210 -29
- package/package.json +1 -1
- package/skill/SKILL.md +58 -44
package/lib/server.js
CHANGED
|
@@ -31,7 +31,7 @@ function handle(req, res) {
|
|
|
31
31
|
// cross-origin POST, and cannot send a JSON content type without a
|
|
32
32
|
// preflight this server never answers.
|
|
33
33
|
if (req.headers.origin) return send(res, 403, { status: 'error', output: 'Requests from web pages are refused' });
|
|
34
|
-
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: state.serving ? 'ok' : 'starting', step: state.serving ? undefined : state.startingStep, browser: state.cdpUrl, launched: state.launched });
|
|
34
|
+
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: state.serving ? 'ok' : 'starting', step: state.serving ? undefined : state.startingStep, browser: state.cdpUrl, launched: state.launched, pid: process.pid });
|
|
35
35
|
if (req.method !== 'POST' || req.url !== '/run') return send(res, 404, { status: 'error', output: 'Use POST /run or GET /health' });
|
|
36
36
|
if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return send(res, 415, { status: 'error', output: 'Content-Type must be application/json' });
|
|
37
37
|
let body = '';
|
|
@@ -43,16 +43,20 @@ function handle(req, res) {
|
|
|
43
43
|
req.on('end', async () => {
|
|
44
44
|
let command;
|
|
45
45
|
let client;
|
|
46
|
-
|
|
46
|
+
let vars;
|
|
47
|
+
try { ({ command, client, vars } = JSON.parse(body)); } catch {}
|
|
47
48
|
if (typeof command !== 'string' || !command.trim() || /[\r\n]/.test(command)) {
|
|
48
49
|
return send(res, 400, { status: 'error', output: 'Body must be {"command": "<one line>"}, with "client": "<name>" if it has one' });
|
|
49
50
|
}
|
|
50
51
|
if (client !== undefined && client !== null && !CLIENT_NAME.test(client)) {
|
|
51
52
|
return send(res, 400, { status: 'error', output: 'A client name is 1-40 letters, digits, dots, dashes and underscores' });
|
|
52
53
|
}
|
|
54
|
+
if (vars !== undefined && (!vars || typeof vars !== 'object' || Object.values(vars).some(v => typeof v !== 'string' || /[\r\n]/.test(v)))) {
|
|
55
|
+
return send(res, 400, { status: 'error', output: '"vars" must be {"<NAME>": "<one line>", ...}' });
|
|
56
|
+
}
|
|
53
57
|
// Bound before the browser is reached, so where and send find it while it starts.
|
|
54
58
|
if (!state.serving) return send(res, 503, { status: 'error', starting: true, output: `The REPL is starting (${state.startingStep}); retry shortly` });
|
|
55
|
-
const result = await runner.submit(command.trim(), client || null);
|
|
59
|
+
const result = await runner.submit(command.trim(), client || null, vars || null);
|
|
56
60
|
if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
|
|
57
61
|
// unconfirmed: the command may or may not have done what it was sent to do.
|
|
58
62
|
send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
|
|
@@ -69,7 +73,11 @@ async function clearStaleSocket(socketPath) {
|
|
|
69
73
|
probe.on('connect', () => { probe.destroy(); resolve(true); });
|
|
70
74
|
probe.on('error', () => resolve(false));
|
|
71
75
|
});
|
|
72
|
-
if (live)
|
|
76
|
+
if (live) {
|
|
77
|
+
const why = await require('./background').alreadyServing(parseEndpoint(socketPath));
|
|
78
|
+
const message = why || `another REPL is already serving on ${socketPath}`;
|
|
79
|
+
throw new Error(message[0].toUpperCase() + message.slice(1));
|
|
80
|
+
}
|
|
73
81
|
fs.unlinkSync(socketPath);
|
|
74
82
|
}
|
|
75
83
|
|
package/lib/skill.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const FILE = path.join(__dirname, '..', 'skill', 'SKILL.md');
|
|
8
8
|
const STAMP = '<!-- pw-repl skill stamp -->';
|
|
9
9
|
|
|
10
|
-
// Only the skill's own text counts: not the stamp line, nor the Custom rules
|
|
10
|
+
// Only the skill's own text counts: not the stamp line, nor the Custom rules the user adds to a
|
|
11
11
|
// saved copy. Line endings are made the same first, so a Windows checkout gets the same hash.
|
|
12
12
|
function hashOf(text) {
|
|
13
13
|
const own = text.replace(/\r\n?/g, '\n').split('\n## Custom rules\n')[0]
|
package/lib/start.js
CHANGED
|
@@ -29,6 +29,9 @@ Or let pw-repl start a private one for this REPL: pw-repl run --launch (or serve
|
|
|
29
29
|
|
|
30
30
|
async function start(options) {
|
|
31
31
|
const START_URL = options.startUrl || process.env.PW_START_URL || null;
|
|
32
|
+
// First, so a terminal or a log says which pw-repl ran; where names the skill to compare a saved one with.
|
|
33
|
+
const skill = require('./skill').current();
|
|
34
|
+
out.log(`pw-repl ${skill.version} (skill ${skill.hash})`);
|
|
32
35
|
if (options.pidFile) require('./background').markStopInLog();
|
|
33
36
|
const serving = options.serve ? await require('./server').serve(options.endpoint) : null;
|
|
34
37
|
if (options.launch) {
|
|
@@ -45,7 +48,7 @@ async function start(options) {
|
|
|
45
48
|
state.launched = !!options.launch;
|
|
46
49
|
out.log(`Connecting to ${CDP_URL}...`);
|
|
47
50
|
state.startingStep = 'connecting to the browser';
|
|
48
|
-
try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
|
|
51
|
+
try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT, noDefaults: true }); }
|
|
49
52
|
catch (error) { throw new Error(connectHelp(error)); }
|
|
50
53
|
if (state.stopping) {
|
|
51
54
|
try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
|
package/lib/syntax.js
CHANGED
|
@@ -54,4 +54,33 @@ function keyName(key) {
|
|
|
54
54
|
return key.split(/\+(?!$)/).map(part => (part.length > 1 ? KEYS.get(part.toLowerCase()) || part : part)).join('+');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// {{ PW_NAME }} in a command send sends: a variable from the sender's environment. The REPL fills it in
|
|
58
|
+
// as it runs the line, so its pane and log show the line as written. Only PW_ names: {{ name }} is common
|
|
59
|
+
// in a page's own text (a template's), which send must neither fail on nor fill with one of the
|
|
60
|
+
// sender's own secrets (GITHUB_TOKEN). In double quotes the value is
|
|
61
|
+
// escaped as they read it (a JS string's too); anywhere else it goes in as it is. Single quotes escape
|
|
62
|
+
// nothing, so a value with one cannot go in them: refused, naming the variable, since a page or eval error
|
|
63
|
+
// that quoted the broken line would show the value.
|
|
64
|
+
const VARIABLE = /\{\{\s*(PW_\w+)\s*\}\}/g;
|
|
65
|
+
|
|
66
|
+
function variablesIn(line) {
|
|
67
|
+
return [...new Set([...line.matchAll(VARIABLE)].map(m => m[1]))];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// values: { NAME: value }. Throws on a variable it has no value for.
|
|
71
|
+
function expandVariables(line, values) {
|
|
72
|
+
const value = name => {
|
|
73
|
+
if (typeof values[name] !== 'string') throw new Error(`{{ ${name} }} has no value: send --file fills it in from ${name} in its environment`);
|
|
74
|
+
return values[name];
|
|
75
|
+
};
|
|
76
|
+
return line.replace(new RegExp(`"(?:[^"\\\\]|\\\\.)*"|'[^']*'|${VARIABLE.source}`, 'g'), (whole, name) => {
|
|
77
|
+
if (name) return value(name);
|
|
78
|
+
if (whole[0] === '"') return whole.replace(VARIABLE, (_, inner) => value(inner).replace(/[\\"]/g, '\\$&'));
|
|
79
|
+
return whole.replace(VARIABLE, (_, inner) => {
|
|
80
|
+
if (value(inner).includes("'")) throw new Error(`{{ ${inner} }} holds a ', which single quotes cannot hold: put it in double quotes`);
|
|
81
|
+
return value(inner);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
|
package/lib/tabs.js
CHANGED
|
@@ -42,8 +42,9 @@ function markStartTab(p) {
|
|
|
42
42
|
|
|
43
43
|
// A tab of the REPL's own: closing it can go back to the tab before. It opens
|
|
44
44
|
// in the background, so it does not take the front of the window from the
|
|
45
|
-
//
|
|
45
|
+
// user using the browser; Playwright's newPage would bring it to the front.
|
|
46
46
|
const OPEN_TIMEOUT = 10000;
|
|
47
|
+
const FRONT_READ_TIMEOUT = 500;
|
|
47
48
|
|
|
48
49
|
// True while tab new opens a tab: watch on --next-tab waits for one someone
|
|
49
50
|
// else opens, not one a client of the REPL opens for itself.
|
|
@@ -74,7 +75,7 @@ async function openOwnTab() {
|
|
|
74
75
|
if (!opened) opened = await ctx.newPage();
|
|
75
76
|
tabState(opened).opened = true;
|
|
76
77
|
tabState(opened).owner = state.client;
|
|
77
|
-
keepDrawing(opened);
|
|
78
|
+
await keepDrawing(opened);
|
|
78
79
|
return opened;
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -104,11 +105,24 @@ async function pageForTarget(ctx, targetId) {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
// Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
|
|
108
|
+
// Whether a tab is the one in front in its window, as the tab itself says: 'in front', 'in front,
|
|
109
|
+
// focused' (its window has the keyboard, and not in the address bar), or null. Not for a tab a client
|
|
110
|
+
// uses, which is told it is in front so that it draws (lib/cdp.js); nor for one that does not answer
|
|
111
|
+
// at once (a dialog open, a frozen tab). Each window has one in front, an incognito one too.
|
|
112
|
+
async function inFront(p) {
|
|
113
|
+
if (tabState(p).focusEmulated) return null;
|
|
114
|
+
const read = p.evaluate(() => [document.visibilityState, document.hasFocus()]).catch(() => null);
|
|
115
|
+
const seen = await Promise.race([read, new Promise(resolve => setTimeout(resolve, FRONT_READ_TIMEOUT, null))]);
|
|
116
|
+
if (!seen || seen[0] !== 'visible') return null;
|
|
117
|
+
return seen[1] ? 'in front, focused' : 'in front';
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
async function listTabs() {
|
|
108
121
|
const all = state.browser.contexts().flatMap(c => c.pages());
|
|
109
122
|
if (state.page && !all.includes(state.page)) state.page = null;
|
|
110
123
|
state.tabListing = all.slice();
|
|
111
124
|
if (!all.length) { out.log('No tabs. Use tab new.'); return; }
|
|
125
|
+
const fronts = await Promise.all(all.map(inFront));
|
|
112
126
|
for (const [i, p] of all.entries()) {
|
|
113
127
|
let title;
|
|
114
128
|
try { title = await p.title(); } catch { title = '[title unavailable]'; }
|
|
@@ -116,7 +130,7 @@ async function listTabs() {
|
|
|
116
130
|
// URL first on its own line: it is what distinguishes otherwise
|
|
117
131
|
// identically titled tabs, and titles wrap less badly when indented.
|
|
118
132
|
if (i) out.log('');
|
|
119
|
-
const notes = ['start' in tabState(p) && 'the start URL', tabState(p).owner && `opened by ${tabState(p).owner}`].filter(Boolean);
|
|
133
|
+
const notes = [fronts[i], 'start' in tabState(p) && 'the start URL', tabState(p).owner && `opened by ${tabState(p).owner}`].filter(Boolean);
|
|
120
134
|
out.log(`${marker} [${i}] ${p.url()}${notes.length ? ` (${notes.join(', ')})` : ''}`);
|
|
121
135
|
out.log(` ${JSON.stringify(title)}`);
|
|
122
136
|
}
|
|
@@ -142,11 +156,13 @@ const commands = {
|
|
|
142
156
|
// Matching on the URL, not an index, so a stale listing cannot point at someone else's tab.
|
|
143
157
|
// Remembers where the REPL was, so closing a tab can go back there, but
|
|
144
158
|
// only to a tab this REPL opened: the previous one may be someone else's.
|
|
145
|
-
|
|
159
|
+
// Its focus is on before anything acts in it: a click in a tab that is not yet told it is focused
|
|
160
|
+
// can leave it drawing a frame a second (lib/cdp.js).
|
|
161
|
+
const select = async p => {
|
|
146
162
|
if (p !== state.page) state.previousPage = state.page;
|
|
147
163
|
state.page = p;
|
|
148
164
|
ensureDialogHandler(p);
|
|
149
|
-
keepDrawing(p);
|
|
165
|
+
await keepDrawing(p);
|
|
150
166
|
};
|
|
151
167
|
const byUrl = part => {
|
|
152
168
|
const matches = all.filter(p => p.url().includes(part));
|
|
@@ -157,7 +173,7 @@ const commands = {
|
|
|
157
173
|
if (/^\d+$/.test(subcommand)) {
|
|
158
174
|
if (parts.length) throw new Error(usage);
|
|
159
175
|
const target = state.tabListing[Number(subcommand)];
|
|
160
|
-
if (target && all.includes(target)) { select(target); return commands.info(); }
|
|
176
|
+
if (target && all.includes(target)) { await select(target); return commands.info(); }
|
|
161
177
|
// Not read as part of a URL instead: that could land in someone else's tab without a word.
|
|
162
178
|
if (target) throw new Error(`Tab [${subcommand}] has closed since the latest listing. Run tab to list them again.`);
|
|
163
179
|
// A number that is not a tab in the listing may be part of a URL (a port).
|
|
@@ -169,7 +185,7 @@ const commands = {
|
|
|
169
185
|
}
|
|
170
186
|
}
|
|
171
187
|
if (subcommand === 'new') {
|
|
172
|
-
select(await openTab());
|
|
188
|
+
await select(await openTab());
|
|
173
189
|
const url = parts.join(' ');
|
|
174
190
|
if (url) {
|
|
175
191
|
try { await commands.goto(url); }
|
|
@@ -196,7 +212,7 @@ const commands = {
|
|
|
196
212
|
if (recording) out.log(await endedByTabClose(recording));
|
|
197
213
|
return listTabs();
|
|
198
214
|
}
|
|
199
|
-
select(byUrl(trimmed));
|
|
215
|
+
await select(byUrl(trimmed));
|
|
200
216
|
return commands.info();
|
|
201
217
|
},
|
|
202
218
|
|
package/lib/toast.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// A line of text over the page, to narrate a video or a screenshot: it stays until toast off, or for
|
|
2
|
+
// --duration. One per tab; another replaces it.
|
|
3
|
+
const { state, onShutdown } = require('./state');
|
|
4
|
+
const out = require('./output');
|
|
5
|
+
const { tabState } = require('./tabstate');
|
|
6
|
+
const { unquote } = require('./syntax');
|
|
7
|
+
const { takeOptions } = require('./cli-names');
|
|
8
|
+
const { inOverlay, keepAcrossPages, forget } = require('./overlay');
|
|
9
|
+
const { recordingOf, beforeRecordingEnds } = require('./record');
|
|
10
|
+
|
|
11
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
12
|
+
|
|
13
|
+
// While the tab records, a toast stays up at least this long (its read time) before another, toast off
|
|
14
|
+
// or record off may take it down: about the pace captions are read at.
|
|
15
|
+
const READ_MS_PER_WORD = 300;
|
|
16
|
+
const READ_MS_MIN = 1500;
|
|
17
|
+
const LONG_READ_MS = 15000;
|
|
18
|
+
const DEFAULTS = { position: 'bottom', size: 24, align: 'center', color: '#fff', background: 'rgba(20, 20, 20, .85)', opacity: 1 };
|
|
19
|
+
|
|
20
|
+
// Runs in the page, as the overlay's toast layer (lib/overlay.js). A new toast waits for the old one to
|
|
21
|
+
// fade out, then fades in; both are waited on, but not past their time in a tab that draws no frames.
|
|
22
|
+
function pageToast(overlay, { text, style, action, fade }) {
|
|
23
|
+
const { set } = overlay;
|
|
24
|
+
const old = overlay.toast;
|
|
25
|
+
const fadeTo = (el, from, to) => Promise.race([
|
|
26
|
+
el.animate([{ opacity: from }, { opacity: to }], { duration: 200, fill: 'forwards' }).finished,
|
|
27
|
+
new Promise(resolve => setTimeout(resolve, 500)),
|
|
28
|
+
]).catch(() => {});
|
|
29
|
+
if (action === 'remove') {
|
|
30
|
+
if (!old) return;
|
|
31
|
+
delete overlay.toast;
|
|
32
|
+
return (fade ? fadeTo(old, getComputedStyle(old).opacity, 0) : Promise.resolve()).then(() => {
|
|
33
|
+
old.remove();
|
|
34
|
+
if (!overlay.toast) overlay.drop('toast');
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const box = document.createElement('div');
|
|
38
|
+
overlay.toast = box;
|
|
39
|
+
box.textContent = text;
|
|
40
|
+
const edge = { top: { top: '6%' }, center: { top: '50%' }, bottom: { bottom: '6%' } }[style.position];
|
|
41
|
+
set(box, {
|
|
42
|
+
position: 'absolute', left: '50%', ...edge, transform: `translate(-50%, ${style.position === 'center' ? '-50%' : '0'})`,
|
|
43
|
+
'max-width': '80%', width: 'max-content', 'box-sizing': 'border-box', padding: `${Math.round(style.size * 0.5)}px ${Math.round(style.size * 0.9)}px`,
|
|
44
|
+
'border-radius': `${Math.round(style.size * 0.45)}px`, background: style.background, color: style.color, opacity: String(style.opacity),
|
|
45
|
+
font: `500 ${style.size}px/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif`, 'text-align': style.align,
|
|
46
|
+
'white-space': 'pre-wrap', 'overflow-wrap': 'anywhere', 'box-shadow': '0 4px 18px rgba(0, 0, 0, .3)',
|
|
47
|
+
});
|
|
48
|
+
const out = old && fade ? fadeTo(old, getComputedStyle(old).opacity, 0) : Promise.resolve();
|
|
49
|
+
return out.then(() => {
|
|
50
|
+
old?.remove();
|
|
51
|
+
if (overlay.toast !== box) return;
|
|
52
|
+
overlay.layer('toast').append(box);
|
|
53
|
+
if (fade) return fadeTo(box, 0, style.opacity);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function toastOf(p) {
|
|
58
|
+
return tabState(p).toast || null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Waits out what is left of the toast's read time, while its tab records; says so, when it waited.
|
|
62
|
+
async function waitToRead(p) {
|
|
63
|
+
const toast = toastOf(p);
|
|
64
|
+
if (!toast || !toast.readMs || !recordingOf(p)) return;
|
|
65
|
+
const left = toast.shownAt + toast.readMs - Date.now();
|
|
66
|
+
if (left <= 0) return;
|
|
67
|
+
await sleep(left);
|
|
68
|
+
out.log(`Waited ${(left / 1000).toFixed(1)}s for the toast's read time (the tab is recording)`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function toastOff(p, fade = true) {
|
|
72
|
+
const toast = toastOf(p);
|
|
73
|
+
if (!toast) return false;
|
|
74
|
+
clearTimeout(toast.timer);
|
|
75
|
+
delete tabState(p).toast;
|
|
76
|
+
forget(p, 'toast');
|
|
77
|
+
await inOverlay(p, pageToast, { action: 'remove', fade }, false).catch(() => {});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeRecordingEnds(p => (p && !p.isClosed() ? waitToRead(p) : null));
|
|
82
|
+
|
|
83
|
+
// 3s, 1.5s, 800ms, or a number of milliseconds.
|
|
84
|
+
function parseDuration(value) {
|
|
85
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value || '');
|
|
86
|
+
if (!match) return null;
|
|
87
|
+
return Math.round(Number(match[1]) * (match[2] === 's' ? 1000 : 1));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onShutdown(async () => {
|
|
91
|
+
if (!state.browser || state.connectionLost) return;
|
|
92
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
|
|
93
|
+
await Promise.all(pages.map(p => toastOff(p, false).catch(() => {})));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const commands = {
|
|
97
|
+
async toast(args) {
|
|
98
|
+
const p = state.page;
|
|
99
|
+
const line = (args || '').trim();
|
|
100
|
+
const toast = toastOf(p);
|
|
101
|
+
if (!line) {
|
|
102
|
+
out.log(toast
|
|
103
|
+
? `A toast is up in the selected tab${toast.owner ? ` (by ${toast.owner})` : ''}: ${JSON.stringify(toast.text)}${toast.until ? `, for ${Math.max(0, Math.round((toast.until - Date.now()) / 100) / 10)}s more` : ''}; toast off hides it`
|
|
104
|
+
: 'No toast is up in the selected tab; toast <text> shows one');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (line === 'off') {
|
|
108
|
+
await waitToRead(p);
|
|
109
|
+
out.log(await toastOff(p) ? 'The toast is gone' : 'No toast was up in the selected tab');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const usage = 'Usage: toast <text> [--duration=<time>] [--read-time=<time>] [--position=top|center|bottom] [--size=<px>] [--align=left|center|right] [--color=<css>] [--background=<css>] [--opacity=<0-1>] | toast off';
|
|
113
|
+
const { found, rest } = takeOptions(line, { duration: 'value', 'read-time': 'value', position: 'value', size: 'value', align: 'value', color: 'value', background: 'value', opacity: 'value' });
|
|
114
|
+
const text = unquote(rest.trim());
|
|
115
|
+
if (!text) throw new Error(usage);
|
|
116
|
+
const style = { ...DEFAULTS };
|
|
117
|
+
let duration = null;
|
|
118
|
+
let readTime = null;
|
|
119
|
+
for (const { name, value } of found) {
|
|
120
|
+
if (value === undefined || value === '') throw new Error(`--${name} needs a value: ${usage}`);
|
|
121
|
+
if (name === 'duration') {
|
|
122
|
+
duration = parseDuration(value);
|
|
123
|
+
if (!duration) throw new Error(`--duration is a time, e.g. --duration=3s or --duration=1500 (ms): ${usage}`);
|
|
124
|
+
} else if (name === 'read-time') {
|
|
125
|
+
readTime = parseDuration(value);
|
|
126
|
+
if (readTime === null) throw new Error(`--read-time is a time, e.g. --read-time=4s, or 0 for none: ${usage}`);
|
|
127
|
+
} else if (name === 'position') {
|
|
128
|
+
if (!['top', 'center', 'bottom'].includes(value)) throw new Error(`--position is top, center or bottom: ${usage}`);
|
|
129
|
+
style.position = value;
|
|
130
|
+
} else if (name === 'align') {
|
|
131
|
+
if (!['left', 'center', 'right'].includes(value)) throw new Error(`--align is left, center or right: ${usage}`);
|
|
132
|
+
style.align = value;
|
|
133
|
+
} else if (name === 'size') {
|
|
134
|
+
const size = Number(String(value).replace(/px$/, ''));
|
|
135
|
+
if (!(size >= 8 && size <= 200)) throw new Error(`--size is the text's size in pixels, 8 to 200: ${usage}`);
|
|
136
|
+
style.size = size;
|
|
137
|
+
} else if (name === 'opacity') {
|
|
138
|
+
const opacity = Number(value);
|
|
139
|
+
if (!(opacity > 0 && opacity <= 1)) throw new Error(`--opacity is above 0, up to 1: ${usage}`);
|
|
140
|
+
style.opacity = opacity;
|
|
141
|
+
} else {
|
|
142
|
+
style[name] = value;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (duration && readTime !== null) throw new Error(`--duration and --read-time do not go together: a toast with a --duration ends by itself, and nothing waits for it`);
|
|
146
|
+
// Replaced, not stacked: the old one fades out, then the new one fades in.
|
|
147
|
+
if (toast) {
|
|
148
|
+
await waitToRead(p);
|
|
149
|
+
clearTimeout(toast.timer);
|
|
150
|
+
}
|
|
151
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
152
|
+
const readMs = duration ? 0 : readTime ?? Math.max(READ_MS_MIN, words * READ_MS_PER_WORD);
|
|
153
|
+
const shown = tabState(p).toast = { owner: state.client, text, style, until: duration ? Date.now() + duration : null, readMs, shownAt: Date.now() };
|
|
154
|
+
await inOverlay(p, pageToast, { text, style, action: 'draw', fade: true });
|
|
155
|
+
shown.shownAt = Date.now();
|
|
156
|
+
keepAcrossPages(p, 'toast', async () => { if (toastOf(p) === shown) await inOverlay(p, pageToast, { text, style, action: 'draw', fade: false }); });
|
|
157
|
+
if (duration) shown.timer = setTimeout(() => { if (toastOf(p) === shown && !p.isClosed()) toastOff(p).catch(() => {}); }, duration);
|
|
158
|
+
if (duration) { out.log(`Showing the toast for ${duration / 1000}s`); return; }
|
|
159
|
+
const why = readTime !== null ? '--read-time' : `${words} word${words === 1 ? '' : 's'}; ${READ_MS_PER_WORD}ms a word, at least ${READ_MS_MIN / 1000}s`;
|
|
160
|
+
// Past send's default 20s, the command that waits would come back unconfirmed while it still waits.
|
|
161
|
+
const long = readMs > LONG_READ_MS ? ` While recording, that one can wait up to ${readMs / 1000}s: send it with -t ${Math.ceil(readMs / 1000) + 10}.` : '';
|
|
162
|
+
out.log(`Showing the toast until toast off. ${readMs ? `Read time: ${readMs / 1000}s (${why}), which a recording waits out before the next toast, toast off or record off.${long}` : 'No read time (--read-time=0).'}`);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
module.exports = { commands, toastOf, toastOff };
|