pendingyou 0.8.0 → 0.10.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 +72 -0
- package/README.md +89 -23
- package/dist/api.js +33 -0
- package/dist/args.js +78 -14
- package/dist/claude.js +268 -23
- package/dist/confirm.js +231 -0
- package/dist/credentials.js +39 -1
- package/dist/files.js +3 -3
- package/dist/hold.js +35 -17
- package/dist/index.js +1 -0
- package/dist/install.js +4 -2
- package/dist/io.js +3 -1
- package/dist/main.js +195 -35
- package/dist/mod.js +87 -0
- package/dist/oauth.js +27 -6
- package/dist/pickup.js +16 -18
- package/dist/qr.js +365 -0
- package/dist/remote.js +306 -0
- package/dist/state.js +5 -32
- package/dist/stopcheck.js +88 -8
- package/dist/version.js +1 -1
- package/dist/watch.js +3 -5
- package/mod/.claude-plugin/plugin.json +12 -0
- package/mod/README.md +7 -0
- package/mod/hooks/hooks.json +7 -0
- package/mod/hooks/register.js +314 -0
- package/mod/hooks/wake.js +599 -0
- package/mod/tests/wake.test.ts +198 -0
- package/package.json +11 -4
package/dist/mod.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The wake mod (0.10.0, guide 2.24): the Pending You plugin's mod, which this package carries in mod/ for Claude Code
|
|
2
|
+
// set up with `npx pendingyou init` and no plugin. Claude Code 2.1.287 and later load it from CLAUDE_CODE_PLUGIN_DIRS
|
|
3
|
+
// (`env` in ~/.claude/settings.json). Every session then learns the cards it posts, checks them through its own
|
|
4
|
+
// Pending You connection, and is woken when the person answers, with no hold running; a hold an agent still starts is
|
|
5
|
+
// answered by the mod. `init` adds the copy inside the hooks' private copy (install.ts), keeping every other entry and
|
|
6
|
+
// replacing an older version's; `uninstall` takes it out; `status` says whether it's there. With the Pending You plugin
|
|
7
|
+
// installed too, the two copies agree that one acts.
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
/** The first Claude Code that loads mods. */
|
|
10
|
+
export const MOD_CLAUDE = '2.1.287';
|
|
11
|
+
/** Where Claude Code finds plugin folders to load as --plugin-dir does. */
|
|
12
|
+
export const PLUGIN_DIRS = 'CLAUDE_CODE_PLUGIN_DIRS';
|
|
13
|
+
/** The Pending You plugins, which carry the same mod: production's, and staging's for our own testing. */
|
|
14
|
+
export const PLUGINS_WITH_MOD = ['pending-you@pendingyou', 'pending-you-staging@pendingyou-staging'];
|
|
15
|
+
/** The mod in a copy of this package (its folder in node_modules). */
|
|
16
|
+
export const modDir = (packageRoot) => join(packageRoot, 'mod');
|
|
17
|
+
/** Claude Code's version from `claude --version` ("2.1.289 (Claude Code)"); null when it can't be read. */
|
|
18
|
+
export function claudeVersion(output) {
|
|
19
|
+
return /^\s*(\d+\.\d+\.\d+)\b/.exec(output)?.[1] ?? null;
|
|
20
|
+
}
|
|
21
|
+
/** Whether that Claude Code loads mods (2.1.287 or later). */
|
|
22
|
+
export function loadsMods(version) {
|
|
23
|
+
const have = version.split('.').map(Number);
|
|
24
|
+
const want = MOD_CLAUDE.split('.').map(Number);
|
|
25
|
+
for (let index = 0; index < want.length; index++) {
|
|
26
|
+
const a = have[index] ?? 0;
|
|
27
|
+
const b = want[index] ?? 0;
|
|
28
|
+
if (a !== b)
|
|
29
|
+
return a > b;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/** Whether a CLAUDE_CODE_PLUGIN_DIRS entry is a copy of this mod, from any version of this package. */
|
|
34
|
+
export const isOurModDir = (entry) => /(^|[\\/])node_modules[\\/]pendingyou[\\/]mod[\\/]?$/.test(entry.trim());
|
|
35
|
+
/** What Claude Code splits CLAUDE_CODE_PLUGIN_DIRS on. */
|
|
36
|
+
export const separatorFor = (platform) => (platform === 'win32' ? ';' : ':');
|
|
37
|
+
const objectAt = (parent, key) => {
|
|
38
|
+
const value = parent[key];
|
|
39
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
40
|
+
? value
|
|
41
|
+
: null;
|
|
42
|
+
};
|
|
43
|
+
/** The entries of CLAUDE_CODE_PLUGIN_DIRS in settings' `env`; null when it's there but isn't text. */
|
|
44
|
+
export function pluginDirs(settings, separator) {
|
|
45
|
+
const env = objectAt(settings, 'env');
|
|
46
|
+
if (settings.env !== undefined && env === null)
|
|
47
|
+
return null;
|
|
48
|
+
const value = env?.[PLUGIN_DIRS];
|
|
49
|
+
if (value === undefined)
|
|
50
|
+
return [];
|
|
51
|
+
if (typeof value !== 'string')
|
|
52
|
+
return null;
|
|
53
|
+
return value.split(separator).filter((entry) => entry.trim() !== '');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Settings with the mod at `dir` in CLAUDE_CODE_PLUGIN_DIRS, or with none of ours when `dir` is null: every other entry
|
|
57
|
+
* kept as it was, in its place, and any copy of ours (an older version's) replaced. `changed` is false when there was
|
|
58
|
+
* nothing to do; `invalid` says why the settings were left alone.
|
|
59
|
+
*/
|
|
60
|
+
export function withModDir(settings, dir, separator) {
|
|
61
|
+
const entries = pluginDirs(settings, separator);
|
|
62
|
+
if (entries === null)
|
|
63
|
+
return {
|
|
64
|
+
invalid: `Claude Code’s settings have an “env” or ${PLUGIN_DIRS} that I can’t read, so I left them alone.`,
|
|
65
|
+
};
|
|
66
|
+
if (dir?.includes(separator))
|
|
67
|
+
return { invalid: `The wake mod’s folder has a “${separator}” in its path: ${dir}` };
|
|
68
|
+
const wanted = [...entries.filter((entry) => !isOurModDir(entry)), ...(dir ? [dir] : [])];
|
|
69
|
+
if (wanted.join(separator) === entries.join(separator))
|
|
70
|
+
return { settings, changed: false };
|
|
71
|
+
const next = structuredClone(settings);
|
|
72
|
+
const env = objectAt(next, 'env') ?? {};
|
|
73
|
+
if (wanted.length)
|
|
74
|
+
env[PLUGIN_DIRS] = wanted.join(separator);
|
|
75
|
+
else
|
|
76
|
+
delete env[PLUGIN_DIRS];
|
|
77
|
+
if (Object.keys(env).length)
|
|
78
|
+
next.env = env;
|
|
79
|
+
else
|
|
80
|
+
delete next.env;
|
|
81
|
+
return { settings: next, changed: true };
|
|
82
|
+
}
|
|
83
|
+
/** Whether settings enable a Pending You plugin, which carries the mod itself. */
|
|
84
|
+
export function pluginEnabled(settings) {
|
|
85
|
+
const enabled = objectAt(settings, 'enabledPlugins');
|
|
86
|
+
return PLUGINS_WITH_MOD.some((id) => enabled?.[id] === true);
|
|
87
|
+
}
|
package/dist/oauth.js
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
// consent page with PKCE (S256), and hears the code back on a one-time loopback address (127.0.0.1, any free port).
|
|
4
4
|
// The consent page recognises the command line by that name and the loopback address, and gives it a grant that only
|
|
5
5
|
// hears answers to your Claude Code assistants' questions: it isn't an assistant, and can't ask or answer anything.
|
|
6
|
+
// On a computer with no browser it signs in with a code instead (RFC 8628, signInWithDevice), and there `init` asks
|
|
7
|
+
// for more: a Claude Code connection of this computer's own, whose token Claude Code's MCP server uses too (remote.ts).
|
|
6
8
|
//
|
|
7
9
|
// Access tokens last 15 minutes and refresh on their own; refresh tokens rotate on every use. Nothing here prints or
|
|
8
10
|
// logs a token, a code or a verifier.
|
|
9
11
|
import { createHash, randomBytes } from 'node:crypto';
|
|
10
12
|
import { createServer } from 'node:http';
|
|
13
|
+
import { qrLines } from "./qr.js";
|
|
11
14
|
import { VERSION } from "./version.js";
|
|
12
15
|
/** The name the consent page knows the command line by (apps/pendingyou/worker/oauth/authorize.ts). */
|
|
13
16
|
export const CLIENT_NAME = 'Pending You CLI';
|
|
@@ -237,18 +240,31 @@ export async function revoke(io, origin, clientId, refreshToken) {
|
|
|
237
240
|
}
|
|
238
241
|
}
|
|
239
242
|
/**
|
|
240
|
-
* Device sign-in (RFC 8628) for a computer with no browser: shows a short code
|
|
241
|
-
* the person presses Allow on their phone (or
|
|
243
|
+
* Device sign-in (RFC 8628) for a computer with no browser: shows a short code, where to enter it and, at a terminal, a
|
|
244
|
+
* QR code that opens that page with the code filled in; then polls until the person presses Allow on their phone (or
|
|
245
|
+
* Cancel, or the code runs out).
|
|
242
246
|
*/
|
|
243
247
|
export async function signInWithDevice(io, options) {
|
|
244
248
|
const metadata = await discover(io, options.origin);
|
|
245
|
-
const
|
|
246
|
-
const started = await io.fetch(`${options.origin}/oauth/device`, {
|
|
249
|
+
const begin = (client) => io.fetch(`${options.origin}/oauth/device`, {
|
|
247
250
|
method: 'POST',
|
|
248
251
|
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
249
|
-
body: new URLSearchParams({
|
|
252
|
+
body: new URLSearchParams({
|
|
253
|
+
client_id: client,
|
|
254
|
+
scope: SCOPE,
|
|
255
|
+
...(options.machine ? { machine: options.machine } : {}),
|
|
256
|
+
...(options.connection ? { assistant: 'claude-code' } : {}),
|
|
257
|
+
}).toString(),
|
|
250
258
|
signal: AbortSignal.timeout(15_000),
|
|
251
259
|
});
|
|
260
|
+
let clientId = options.clientId || (await register(io, metadata));
|
|
261
|
+
let started = await begin(clientId);
|
|
262
|
+
if (started.status === 401 && options.clientId) {
|
|
263
|
+
// Pending You no longer knows that client (a registration lapses after 90 days unused): register again.
|
|
264
|
+
await started.body?.cancel();
|
|
265
|
+
clientId = await register(io, metadata);
|
|
266
|
+
started = await begin(clientId);
|
|
267
|
+
}
|
|
252
268
|
const start = (await started.json().catch(() => ({})));
|
|
253
269
|
if (!started.ok || typeof start.device_code !== 'string' || typeof start.user_code !== 'string')
|
|
254
270
|
throw new OAuthError(start.error ?? `http_${started.status}`, start.error === 'slow_down'
|
|
@@ -257,8 +273,13 @@ export async function signInWithDevice(io, options) {
|
|
|
257
273
|
const page = start.verification_uri ?? `${options.origin}/device`;
|
|
258
274
|
if (new URL(page).origin !== options.origin)
|
|
259
275
|
throw new OAuthError('discovery', 'Pending You sent the sign-in page somewhere else.');
|
|
276
|
+
const direct = start.verification_uri_complete ?? `${page}?code=${start.user_code}`;
|
|
277
|
+
const qr = io.interactive && new URL(direct).origin === options.origin ? qrLines(direct, io.env) : null;
|
|
260
278
|
io.err(`On your phone or any browser, open ${page} and enter this code:\n\n ${start.user_code}\n\n` +
|
|
261
|
-
|
|
279
|
+
(qr
|
|
280
|
+
? `Or scan this with your phone’s camera, which opens it with the code filled in:\n\n${qr.join('\n')}\n\nOr go straight to ${direct}\n`
|
|
281
|
+
: `Or go straight to ${direct}\n`) +
|
|
282
|
+
'Waiting for you to press Allow…\n');
|
|
262
283
|
let interval = Math.max(1, start.interval ?? 5) * 1000;
|
|
263
284
|
const deadline = io.now() + Math.max(60, start.expires_in ?? 600) * 1000;
|
|
264
285
|
for (;;) {
|
package/dist/pickup.js
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// Pending You: …"). While signed in, it also adds one short line (REMINDER) to every message, so an assistant
|
|
7
7
|
// deep in a long conversation still puts what it needs from the person on a card, and that line names this folder's
|
|
8
8
|
// cards still waiting on the person (openLine), so one they answer in chat gets cancelled with answeredHere. That's
|
|
9
|
-
// one small request per message (the open cards
|
|
10
|
-
//
|
|
9
|
+
// one small request per message (the open cards and this folder's answers together), time-boxed short; on any
|
|
10
|
+
// error, or from a server too old to list open cards, the line is the plain reminder. Until 0.10.0 it looked for
|
|
11
|
+
// answers only while a hold had left an entry behind, so an answer to a card no hold ever waited on stayed unheard
|
|
12
|
+
// until the next session (2026-10-04).
|
|
11
13
|
//
|
|
12
14
|
// Both also add one line when the session's pendingyou MCP server is another Pending You than the hooks' origin
|
|
13
15
|
// (environment.ts): its cards would go where these hooks, and maybe its person, don't look.
|
|
@@ -15,13 +17,14 @@
|
|
|
15
17
|
// Whatever they print, Claude Code adds to the conversation. A hook must never get in the way: each is time-boxed,
|
|
16
18
|
// has a hard deadline (main.ts) after which it prints only its fallback, never refreshes the sign-in itself (api.ts),
|
|
17
19
|
// prints nothing when there's nothing new, never repeats an answer it handed over, and always exits 0.
|
|
18
|
-
import { getJson, SignInNeeded } from "./api.js";
|
|
20
|
+
import { getJson, SESSION_APP, SignInNeeded } from "./api.js";
|
|
19
21
|
import { readCredential } from "./credentials.js";
|
|
20
22
|
import { environmentWarning } from "./environment.js";
|
|
21
|
-
import { clip, describe
|
|
22
|
-
import {
|
|
23
|
+
import { clip, describe } from "./format.js";
|
|
24
|
+
import { holdCommand } from "./hold.js";
|
|
25
|
+
import { markHanded, momentOf, wasHanded } from "./state.js";
|
|
23
26
|
/** Each request's own limit, inside the hook's 3-second deadline (main.ts). */
|
|
24
|
-
const TIME_BOX_MS = { pickup: 2500, handoff: 2000
|
|
27
|
+
const TIME_BOX_MS = { pickup: 2500, handoff: 2000 };
|
|
25
28
|
/** What every message carries while signed in (about 18 tokens). */
|
|
26
29
|
export const REMINDER = 'Pending You: anything you need from your person also goes on a card.';
|
|
27
30
|
/** The most open cards the line names, and how much of each title. */
|
|
@@ -87,26 +90,25 @@ export async function pickup(io, options, hook = { fallback: '' }) {
|
|
|
87
90
|
warning = await environmentWarning(io, cwd, origin);
|
|
88
91
|
if (warning)
|
|
89
92
|
hook.fallback = `${warning}\n`;
|
|
90
|
-
// The hand-off
|
|
91
|
-
let answers = true;
|
|
93
|
+
// The hand-off asks for this folder's answers and its open cards in one request, whether or not a hold ran.
|
|
92
94
|
if (mode === 'handoff') {
|
|
93
95
|
if ((await readCredential(io, origin)) === null)
|
|
94
96
|
return 0;
|
|
95
97
|
line = REMINDER;
|
|
96
98
|
hook.fallback = `${[REMINDER, warning].filter(Boolean).join('\n')}\n`;
|
|
97
|
-
answers = (await waitingOn(io, origin)).length > 0;
|
|
98
99
|
}
|
|
99
100
|
const query = new URLSearchParams([
|
|
100
101
|
...folderForms(cwd, io.home).map((form) => ['cwd', form]),
|
|
101
|
-
['limit',
|
|
102
|
+
['limit', '5'],
|
|
102
103
|
...(mode === 'handoff' ? [['open', String(OPEN_SHOWN)]] : []),
|
|
104
|
+
['source', SESSION_APP],
|
|
103
105
|
]);
|
|
104
|
-
const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`,
|
|
106
|
+
const { status, body } = await getJson(io, origin, `/mcp/cli/answers?${query}`, TIME_BOX_MS[mode], io.signal, { hook: true });
|
|
105
107
|
if (status !== 200)
|
|
106
108
|
return 0;
|
|
107
109
|
if (mode === 'handoff')
|
|
108
110
|
line = openLine(body.open);
|
|
109
|
-
if (!
|
|
111
|
+
if (!Array.isArray(body.requests))
|
|
110
112
|
return 0;
|
|
111
113
|
const fresh = [];
|
|
112
114
|
for (const heard of body.requests)
|
|
@@ -114,7 +116,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
|
|
|
114
116
|
fresh.push(heard);
|
|
115
117
|
if (fresh.length === 0)
|
|
116
118
|
return 0;
|
|
117
|
-
const again = (id) => `run
|
|
119
|
+
const again = (id) => `run \`${holdCommand(id, origin)}\` in the background`;
|
|
118
120
|
// One write, the line included: at the deadline a hook has printed all of this or none of it. Printed before it's
|
|
119
121
|
// marked as handed over, so a hook cut off in between repeats an answer rather than losing it.
|
|
120
122
|
io.out(`${[
|
|
@@ -129,11 +131,7 @@ export async function pickup(io, options, hook = { fallback: '' }) {
|
|
|
129
131
|
].join('\n')}\n`);
|
|
130
132
|
line = null;
|
|
131
133
|
warning = null;
|
|
132
|
-
await markHanded(io, origin, fresh.map((heard) => ({
|
|
133
|
-
requestId: heard.requestId,
|
|
134
|
-
moment: momentOf(heard),
|
|
135
|
-
closed: isClosed(heard),
|
|
136
|
-
})));
|
|
134
|
+
await markHanded(io, origin, fresh.map((heard) => ({ requestId: heard.requestId, moment: momentOf(heard) })));
|
|
137
135
|
return 0;
|
|
138
136
|
}
|
|
139
137
|
catch (error) {
|
package/dist/qr.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// A QR code for the device sign-in's link (0.10.0), so the person can scan it from the terminal with their phone
|
|
2
|
+
// instead of typing the address: on a computer reached over SSH, that phone is where they approve the code. The
|
|
3
|
+
// command line has no dependencies, so this is a small encoder of its own: byte mode, versions 1–9 (up to 180 bytes at
|
|
4
|
+
// level M), every error correction level, and the standard's mask choice (lowest penalty). It follows Project Nayuki's
|
|
5
|
+
// QR Code generator, as uqr does (the app's phone code, onboarding.tsx); apps/pendingyou/test/cli-qr.test.ts checks it
|
|
6
|
+
// against uqr module for module.
|
|
7
|
+
const LEVELS = {
|
|
8
|
+
L: { index: 0, format: 1 },
|
|
9
|
+
M: { index: 1, format: 0 },
|
|
10
|
+
Q: { index: 2, format: 3 },
|
|
11
|
+
H: { index: 3, format: 2 },
|
|
12
|
+
};
|
|
13
|
+
/** Byte mode's character count is 8 bits up to version 9; this encoder stops there. */
|
|
14
|
+
const MAX_VERSION = 9;
|
|
15
|
+
/** Error correction codewords per block, by level, then version (index 0 unused). */
|
|
16
|
+
const ECC_PER_BLOCK = [
|
|
17
|
+
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30],
|
|
18
|
+
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22],
|
|
19
|
+
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20],
|
|
20
|
+
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24],
|
|
21
|
+
];
|
|
22
|
+
/** Error correction blocks, by level, then version (index 0 unused). */
|
|
23
|
+
const BLOCKS = [
|
|
24
|
+
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2],
|
|
25
|
+
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5],
|
|
26
|
+
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8],
|
|
27
|
+
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8],
|
|
28
|
+
];
|
|
29
|
+
const bit = (value, index) => ((value >>> index) & 1) !== 0;
|
|
30
|
+
/** Modules left for data and error correction once the function patterns are drawn. */
|
|
31
|
+
function rawModules(version) {
|
|
32
|
+
let result = (16 * version + 128) * version + 64;
|
|
33
|
+
if (version >= 2) {
|
|
34
|
+
const align = Math.floor(version / 7) + 2;
|
|
35
|
+
result -= (25 * align - 10) * align - 55;
|
|
36
|
+
if (version >= 7)
|
|
37
|
+
result -= 36;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
const dataCodewords = (version, level) => Math.floor(rawModules(version) / 8) -
|
|
42
|
+
(ECC_PER_BLOCK[level]?.[version] ?? 0) * (BLOCKS[level]?.[version] ?? 0);
|
|
43
|
+
/** GF(256) multiplication, modulo x^8 + x^4 + x^3 + x^2 + 1. */
|
|
44
|
+
function multiply(x, y) {
|
|
45
|
+
let z = 0;
|
|
46
|
+
for (let i = 7; i >= 0; i--) {
|
|
47
|
+
z = (z << 1) ^ ((z >>> 7) * 0x11d);
|
|
48
|
+
z ^= ((y >>> i) & 1) * x;
|
|
49
|
+
}
|
|
50
|
+
return z;
|
|
51
|
+
}
|
|
52
|
+
function divisor(degree) {
|
|
53
|
+
const result = new Array(degree).fill(0);
|
|
54
|
+
result[degree - 1] = 1;
|
|
55
|
+
let root = 1;
|
|
56
|
+
for (let i = 0; i < degree; i++) {
|
|
57
|
+
for (let j = 0; j < result.length; j++) {
|
|
58
|
+
result[j] = multiply(result[j], root);
|
|
59
|
+
if (j + 1 < result.length)
|
|
60
|
+
result[j] = result[j] ^ result[j + 1];
|
|
61
|
+
}
|
|
62
|
+
root = multiply(root, 2);
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function remainder(data, by) {
|
|
67
|
+
const result = by.map(() => 0);
|
|
68
|
+
for (const byte of data) {
|
|
69
|
+
const factor = byte ^ result.shift();
|
|
70
|
+
result.push(0);
|
|
71
|
+
by.forEach((coefficient, index) => {
|
|
72
|
+
result[index] = result[index] ^ multiply(coefficient, factor);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/** The data codewords with each block's error correction, interleaved as the standard places them. */
|
|
78
|
+
function interleave(data, version, level) {
|
|
79
|
+
const blocks = BLOCKS[level]?.[version];
|
|
80
|
+
const eccLength = ECC_PER_BLOCK[level]?.[version];
|
|
81
|
+
const raw = Math.floor(rawModules(version) / 8);
|
|
82
|
+
const short = blocks - (raw % blocks);
|
|
83
|
+
const shortLength = Math.floor(raw / blocks);
|
|
84
|
+
const by = divisor(eccLength);
|
|
85
|
+
const all = [];
|
|
86
|
+
for (let index = 0, at = 0; index < blocks; index++) {
|
|
87
|
+
const block = data.slice(at, at + shortLength - eccLength + (index < short ? 0 : 1));
|
|
88
|
+
at += block.length;
|
|
89
|
+
const ecc = remainder(block, by);
|
|
90
|
+
if (index < short)
|
|
91
|
+
block.push(0);
|
|
92
|
+
all.push([...block, ...ecc]);
|
|
93
|
+
}
|
|
94
|
+
const result = [];
|
|
95
|
+
for (let i = 0; i < (all[0]?.length ?? 0); i++)
|
|
96
|
+
all.forEach((block, j) => {
|
|
97
|
+
if (i !== shortLength - eccLength || j >= short)
|
|
98
|
+
result.push(block[i]);
|
|
99
|
+
});
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
class Grid {
|
|
103
|
+
version;
|
|
104
|
+
/** The level's format bits (L 1, M 0, Q 3, H 2). */
|
|
105
|
+
format;
|
|
106
|
+
size;
|
|
107
|
+
modules;
|
|
108
|
+
/** Function modules (finders, timing, alignment, format and version), which data and masks never touch. */
|
|
109
|
+
reserved;
|
|
110
|
+
constructor(version, format) {
|
|
111
|
+
this.version = version;
|
|
112
|
+
this.format = format;
|
|
113
|
+
this.size = version * 4 + 17;
|
|
114
|
+
this.modules = Array.from({ length: this.size }, () => new Array(this.size).fill(false));
|
|
115
|
+
this.reserved = Array.from({ length: this.size }, () => new Array(this.size).fill(false));
|
|
116
|
+
}
|
|
117
|
+
set(x, y, dark) {
|
|
118
|
+
;
|
|
119
|
+
this.modules[y][x] = dark;
|
|
120
|
+
this.reserved[y][x] = true;
|
|
121
|
+
}
|
|
122
|
+
functionPatterns() {
|
|
123
|
+
for (let i = 0; i < this.size; i++) {
|
|
124
|
+
this.set(6, i, i % 2 === 0);
|
|
125
|
+
this.set(i, 6, i % 2 === 0);
|
|
126
|
+
}
|
|
127
|
+
for (const [x, y] of [
|
|
128
|
+
[3, 3],
|
|
129
|
+
[this.size - 4, 3],
|
|
130
|
+
[3, this.size - 4],
|
|
131
|
+
])
|
|
132
|
+
for (let dy = -4; dy <= 4; dy++)
|
|
133
|
+
for (let dx = -4; dx <= 4; dx++) {
|
|
134
|
+
const distance = Math.max(Math.abs(dx), Math.abs(dy));
|
|
135
|
+
const [xx, yy] = [x + dx, y + dy];
|
|
136
|
+
if (xx >= 0 && xx < this.size && yy >= 0 && yy < this.size)
|
|
137
|
+
this.set(xx, yy, distance !== 2 && distance !== 4);
|
|
138
|
+
}
|
|
139
|
+
// Alignment patterns everywhere on the grid of positions but the three finder corners.
|
|
140
|
+
const positions = this.alignments();
|
|
141
|
+
const last = positions.length - 1;
|
|
142
|
+
for (const [i, a] of positions.entries())
|
|
143
|
+
for (const [j, b] of positions.entries()) {
|
|
144
|
+
if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0))
|
|
145
|
+
continue;
|
|
146
|
+
for (let dy = -2; dy <= 2; dy++)
|
|
147
|
+
for (let dx = -2; dx <= 2; dx++)
|
|
148
|
+
this.set(a + dx, b + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
149
|
+
}
|
|
150
|
+
this.formatBits(0);
|
|
151
|
+
if (this.version >= 7) {
|
|
152
|
+
let rest = this.version;
|
|
153
|
+
for (let i = 0; i < 12; i++)
|
|
154
|
+
rest = (rest << 1) ^ ((rest >>> 11) * 0x1f25);
|
|
155
|
+
const bits = (this.version << 12) | rest;
|
|
156
|
+
for (let i = 0; i < 18; i++) {
|
|
157
|
+
const a = this.size - 11 + (i % 3);
|
|
158
|
+
const b = Math.floor(i / 3);
|
|
159
|
+
this.set(a, b, bit(bits, i));
|
|
160
|
+
this.set(b, a, bit(bits, i));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
alignments() {
|
|
165
|
+
if (this.version === 1)
|
|
166
|
+
return [];
|
|
167
|
+
const count = Math.floor(this.version / 7) + 2;
|
|
168
|
+
const step = Math.ceil((this.version * 4 + 4) / (count * 2 - 2)) * 2;
|
|
169
|
+
const result = [6];
|
|
170
|
+
for (let at = this.size - 7; result.length < count; at -= step)
|
|
171
|
+
result.splice(1, 0, at);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
formatBits(mask) {
|
|
175
|
+
const data = (this.format << 3) | mask;
|
|
176
|
+
let rest = data;
|
|
177
|
+
for (let i = 0; i < 10; i++)
|
|
178
|
+
rest = (rest << 1) ^ ((rest >>> 9) * 0x537);
|
|
179
|
+
const bits = ((data << 10) | rest) ^ 0x5412;
|
|
180
|
+
for (let i = 0; i <= 5; i++)
|
|
181
|
+
this.set(8, i, bit(bits, i));
|
|
182
|
+
this.set(8, 7, bit(bits, 6));
|
|
183
|
+
this.set(8, 8, bit(bits, 7));
|
|
184
|
+
this.set(7, 8, bit(bits, 8));
|
|
185
|
+
for (let i = 9; i < 15; i++)
|
|
186
|
+
this.set(14 - i, 8, bit(bits, i));
|
|
187
|
+
for (let i = 0; i < 8; i++)
|
|
188
|
+
this.set(this.size - 1 - i, 8, bit(bits, i));
|
|
189
|
+
for (let i = 8; i < 15; i++)
|
|
190
|
+
this.set(8, this.size - 15 + i, bit(bits, i));
|
|
191
|
+
this.set(8, this.size - 8, true);
|
|
192
|
+
}
|
|
193
|
+
codewords(data) {
|
|
194
|
+
let i = 0;
|
|
195
|
+
for (let right = this.size - 1; right >= 1; right -= 2) {
|
|
196
|
+
if (right === 6)
|
|
197
|
+
right = 5;
|
|
198
|
+
for (let vertical = 0; vertical < this.size; vertical++)
|
|
199
|
+
for (let j = 0; j < 2; j++) {
|
|
200
|
+
const x = right - j;
|
|
201
|
+
const upward = ((right + 1) & 2) === 0;
|
|
202
|
+
const y = upward ? this.size - 1 - vertical : vertical;
|
|
203
|
+
if (!this.reserved[y]?.[x] && i < data.length * 8) {
|
|
204
|
+
;
|
|
205
|
+
this.modules[y][x] = bit(data[i >>> 3], 7 - (i & 7));
|
|
206
|
+
i++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** XORs the data modules with a mask pattern; applying it twice undoes it. */
|
|
212
|
+
mask(mask) {
|
|
213
|
+
for (let y = 0; y < this.size; y++)
|
|
214
|
+
for (let x = 0; x < this.size; x++) {
|
|
215
|
+
const flip = [
|
|
216
|
+
(x + y) % 2 === 0,
|
|
217
|
+
y % 2 === 0,
|
|
218
|
+
x % 3 === 0,
|
|
219
|
+
(x + y) % 3 === 0,
|
|
220
|
+
(Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
221
|
+
((x * y) % 2) + ((x * y) % 3) === 0,
|
|
222
|
+
(((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
223
|
+
(((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
224
|
+
][mask];
|
|
225
|
+
if (flip && !this.reserved[y]?.[x])
|
|
226
|
+
this.modules[y][x] = !this.modules[y]?.[x];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** The standard's penalty for the modules as they are: runs, boxes, finder look-alikes and imbalance. */
|
|
230
|
+
penalty() {
|
|
231
|
+
let result = 0;
|
|
232
|
+
const at = (x, y) => this.modules[y]?.[x];
|
|
233
|
+
for (const across of [true, false]) {
|
|
234
|
+
for (let line = 0; line < this.size; line++) {
|
|
235
|
+
let color = false;
|
|
236
|
+
let run = 0;
|
|
237
|
+
const history = [0, 0, 0, 0, 0, 0, 0];
|
|
238
|
+
for (let along = 0; along < this.size; along++) {
|
|
239
|
+
const dark = across ? at(along, line) : at(line, along);
|
|
240
|
+
if (dark === color) {
|
|
241
|
+
run++;
|
|
242
|
+
if (run === 5)
|
|
243
|
+
result += 3;
|
|
244
|
+
else if (run > 5)
|
|
245
|
+
result++;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
this.addHistory(run, history);
|
|
249
|
+
if (!color)
|
|
250
|
+
result += this.finderLike(history) * 40;
|
|
251
|
+
color = dark;
|
|
252
|
+
run = 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (color) {
|
|
256
|
+
this.addHistory(run, history);
|
|
257
|
+
run = 0;
|
|
258
|
+
}
|
|
259
|
+
this.addHistory(run + this.size, history);
|
|
260
|
+
result += this.finderLike(history) * 40;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (let y = 0; y < this.size - 1; y++)
|
|
264
|
+
for (let x = 0; x < this.size - 1; x++) {
|
|
265
|
+
const color = at(x, y);
|
|
266
|
+
if (color === at(x + 1, y) && color === at(x, y + 1) && color === at(x + 1, y + 1))
|
|
267
|
+
result += 3;
|
|
268
|
+
}
|
|
269
|
+
const dark = this.modules.reduce((sum, row) => sum + row.filter(Boolean).length, 0);
|
|
270
|
+
const total = this.size * this.size;
|
|
271
|
+
result += (Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1) * 10;
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
addHistory(run, history) {
|
|
275
|
+
history.pop();
|
|
276
|
+
history.unshift(history[0] === 0 ? run + this.size : run);
|
|
277
|
+
}
|
|
278
|
+
finderLike(history) {
|
|
279
|
+
const [a = 0, n = 0, c = 0, d = 0, e = 0, f = 0, g = 0] = history;
|
|
280
|
+
const core = n > 0 && c === n && d === n * 3 && e === n && f === n;
|
|
281
|
+
return (core && a >= n * 4 && g >= n ? 1 : 0) + (core && g >= n * 4 && a >= n ? 1 : 0);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* `text` as a QR code in byte mode, the smallest version that holds it at level `ecc` (M unless asked), with the mask
|
|
286
|
+
* the standard's penalty prefers (or `mask`). Null when it's longer than version 9 holds.
|
|
287
|
+
*/
|
|
288
|
+
export function qrCode(text, options = {}) {
|
|
289
|
+
const bytes = [...new TextEncoder().encode(text)];
|
|
290
|
+
const { index: level, format } = LEVELS[options.ecc ?? 'M'];
|
|
291
|
+
let version = 1;
|
|
292
|
+
// Mode (4 bits), the count (8 bits), then the bytes.
|
|
293
|
+
const used = 4 + 8 + bytes.length * 8;
|
|
294
|
+
while (version <= MAX_VERSION && used > dataCodewords(version, level) * 8)
|
|
295
|
+
version++;
|
|
296
|
+
if (version > MAX_VERSION)
|
|
297
|
+
return null;
|
|
298
|
+
const capacity = dataCodewords(version, level) * 8;
|
|
299
|
+
const bits = [];
|
|
300
|
+
const append = (value, length) => {
|
|
301
|
+
for (let i = length - 1; i >= 0; i--)
|
|
302
|
+
bits.push((value >>> i) & 1);
|
|
303
|
+
};
|
|
304
|
+
append(0b0100, 4);
|
|
305
|
+
append(bytes.length, 8);
|
|
306
|
+
for (const byte of bytes)
|
|
307
|
+
append(byte, 8);
|
|
308
|
+
append(0, Math.min(4, capacity - bits.length));
|
|
309
|
+
append(0, (8 - (bits.length % 8)) % 8);
|
|
310
|
+
for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11)
|
|
311
|
+
append(pad, 8);
|
|
312
|
+
const data = new Array(bits.length / 8).fill(0);
|
|
313
|
+
bits.forEach((value, i) => {
|
|
314
|
+
data[i >>> 3] = data[i >>> 3] | (value << (7 - (i & 7)));
|
|
315
|
+
});
|
|
316
|
+
const grid = new Grid(version, format);
|
|
317
|
+
grid.functionPatterns();
|
|
318
|
+
grid.codewords(interleave(data, version, level));
|
|
319
|
+
let mask = options.mask ?? -1;
|
|
320
|
+
if (mask < 0) {
|
|
321
|
+
let lowest = Number.POSITIVE_INFINITY;
|
|
322
|
+
for (let candidate = 0; candidate < 8; candidate++) {
|
|
323
|
+
grid.mask(candidate);
|
|
324
|
+
grid.formatBits(candidate);
|
|
325
|
+
const score = grid.penalty();
|
|
326
|
+
if (score < lowest) {
|
|
327
|
+
lowest = score;
|
|
328
|
+
mask = candidate;
|
|
329
|
+
}
|
|
330
|
+
grid.mask(candidate);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
grid.mask(mask);
|
|
334
|
+
grid.formatBits(mask);
|
|
335
|
+
return { version, mask, modules: grid.modules.map((row) => [...row]) };
|
|
336
|
+
}
|
|
337
|
+
/** Dark on light whatever the terminal's colours: black text on a white background, then back to normal. */
|
|
338
|
+
const ON = '\u001b[30;47m';
|
|
339
|
+
const OFF = '\u001b[0m';
|
|
340
|
+
/** Light modules around the code, so a camera finds its edges. */
|
|
341
|
+
const QUIET = 2;
|
|
342
|
+
/**
|
|
343
|
+
* The code as terminal lines: two rows of modules a line, drawn with half blocks, dark on light, with a quiet zone.
|
|
344
|
+
* Null when the text is too long for a code, or the terminal can't be trusted with colours (NO_COLOR, TERM=dumb).
|
|
345
|
+
*/
|
|
346
|
+
export function qrLines(text, env = {}) {
|
|
347
|
+
if (env.NO_COLOR || env.TERM === 'dumb')
|
|
348
|
+
return null;
|
|
349
|
+
const code = qrCode(text);
|
|
350
|
+
if (!code)
|
|
351
|
+
return null;
|
|
352
|
+
const size = code.modules.length + QUIET * 2;
|
|
353
|
+
const dark = (x, y) => code.modules[y - QUIET]?.[x - QUIET] === true;
|
|
354
|
+
const lines = [];
|
|
355
|
+
for (let y = 0; y < size; y += 2) {
|
|
356
|
+
let line = '';
|
|
357
|
+
for (let x = 0; x < size; x++) {
|
|
358
|
+
const top = dark(x, y);
|
|
359
|
+
const bottom = y + 1 < size && dark(x, y + 1);
|
|
360
|
+
line += top ? (bottom ? '█' : '▀') : bottom ? '▄' : ' ';
|
|
361
|
+
}
|
|
362
|
+
lines.push(`${ON}${line}${OFF}`);
|
|
363
|
+
}
|
|
364
|
+
return lines;
|
|
365
|
+
}
|