qr-terminal 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/README.md +159 -0
- package/index.js +63 -0
- package/package.json +47 -0
- package/src/braille.js +104 -0
- package/src/destruct.js +123 -0
- package/src/engine.js +286 -0
- package/src/export.js +76 -0
- package/src/formatters.js +44 -0
- package/src/handshake.js +112 -0
- package/src/live-preview.js +199 -0
- package/src/luma.js +58 -0
- package/src/matrix-rain.js +115 -0
- package/src/particle.js +91 -0
- package/src/prompts.js +449 -0
- package/src/shortlink.js +58 -0
- package/src/ui.js +183 -0
package/src/engine.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ╔══════════════════════════════════════════════════════════════╗
|
|
3
|
+
* ║ HIGH-DENSITY HALF-BLOCK RENDERING ENGINE ║
|
|
4
|
+
* ║ Compresses 2 vertical QR pixels → 1 Unicode character ║
|
|
5
|
+
* ║ 2D per-character color: diagonal · wave · 4-corner · radial║
|
|
6
|
+
* ╚══════════════════════════════════════════════════════════════╝
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import QRCode from 'qrcode';
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
|
|
12
|
+
// Unicode half-block set
|
|
13
|
+
const BLOCK = {
|
|
14
|
+
FULL: '█',
|
|
15
|
+
UPPER: '▀',
|
|
16
|
+
LOWER: '▄',
|
|
17
|
+
EMPTY: ' ',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Gradient presets.
|
|
22
|
+
* Each has a `type` that controls 2D color distribution,
|
|
23
|
+
* and `stops` — an array of [R,G,B] color points.
|
|
24
|
+
*
|
|
25
|
+
* Types:
|
|
26
|
+
* solid — single color
|
|
27
|
+
* diagonal — top-left → bottom-right blend
|
|
28
|
+
* vertical — top → bottom
|
|
29
|
+
* wave — sinusoidal ripple across x+y
|
|
30
|
+
* radial — center → edge bloom
|
|
31
|
+
* 4corner — bilinear blend between 4 corner colors [tl, tr, bl, br]
|
|
32
|
+
*/
|
|
33
|
+
export const GRADIENT_PRESETS = {
|
|
34
|
+
none: { type: 'solid', stops: [[220, 220, 220]] },
|
|
35
|
+
retro: { type: 'diagonal', stops: [[235, 77, 75], [255, 184, 64]] },
|
|
36
|
+
ocean: { type: 'diagonal', stops: [[0, 48, 200], [0, 210, 255]] },
|
|
37
|
+
neon: { type: 'wave', stops: [[57, 255, 20], [0, 200, 255], [255, 0, 200]] },
|
|
38
|
+
sunset: { type: 'diagonal', stops: [[255, 40, 40], [255, 140, 40], [255, 220, 60]] },
|
|
39
|
+
galaxy: { type: '4corner', stops: [[160, 32, 240], [59, 130, 246], [255, 0, 128], [0, 200, 255]] },
|
|
40
|
+
cherry: { type: 'radial', stops: [[255, 255, 200],[255, 80, 130], [200, 0, 80]] },
|
|
41
|
+
matrix: { type: 'vertical', stops: [[0, 60, 0], [0, 200, 50], [160, 255, 160]] },
|
|
42
|
+
fire: { type: 'diagonal', stops: [[160, 0, 0], [255, 80, 0], [255, 210, 0]] },
|
|
43
|
+
vapor: { type: '4corner', stops: [[255, 0, 255], [0, 220, 255], [180, 0, 180], [80, 80, 255]] },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate QR bit-matrix, bypassing qrcode's own renderer.
|
|
48
|
+
*/
|
|
49
|
+
export function createMatrix(text, { errorLevel = 'M' } = {}) {
|
|
50
|
+
if (!text || text.length === 0) throw new Error('Empty input');
|
|
51
|
+
return QRCode.create(text, { errorCorrectionLevel: errorLevel }).modules;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Core renderer with 2D per-character color.
|
|
56
|
+
*
|
|
57
|
+
* For each character position (col, row), we compute tx ∈ [0,1] and ty ∈ [0,1]
|
|
58
|
+
* then apply the gradient type formula to get a unique RGB for that character.
|
|
59
|
+
* This creates diagonal sweeps, waves, and corner blooms across the QR matrix.
|
|
60
|
+
*
|
|
61
|
+
* @returns {string[]} — chalk-colored strings, one per display row
|
|
62
|
+
*/
|
|
63
|
+
export function renderHalfBlock(modules, options = {}) {
|
|
64
|
+
const { gradient = 'none', quietZone = 2 } = options;
|
|
65
|
+
const { size, data } = modules;
|
|
66
|
+
|
|
67
|
+
const colStart = -quietZone;
|
|
68
|
+
const colEnd = size + quietZone;
|
|
69
|
+
const rowStart = -quietZone;
|
|
70
|
+
const rowEnd = size + quietZone;
|
|
71
|
+
const totalCols = colEnd - colStart;
|
|
72
|
+
const totalRows = rowEnd - rowStart;
|
|
73
|
+
const displayRows = Math.ceil(totalRows / 2);
|
|
74
|
+
|
|
75
|
+
const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
|
|
76
|
+
|
|
77
|
+
const px = (r, c) => {
|
|
78
|
+
if (r < 0 || r >= size || c < 0 || c >= size) return 0;
|
|
79
|
+
return data[r * size + c] ? 1 : 0;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const output = [];
|
|
83
|
+
|
|
84
|
+
for (let di = 0; di < displayRows; di++) {
|
|
85
|
+
const row = rowStart + di * 2;
|
|
86
|
+
const ty = displayRows > 1 ? di / (displayRows - 1) : 0;
|
|
87
|
+
|
|
88
|
+
let coloredLine = '';
|
|
89
|
+
|
|
90
|
+
// Track current run for grouping consecutive same-color chars
|
|
91
|
+
let runColor = null;
|
|
92
|
+
let runChars = '';
|
|
93
|
+
|
|
94
|
+
const flushRun = () => {
|
|
95
|
+
if (!runChars) return;
|
|
96
|
+
if (runColor) {
|
|
97
|
+
coloredLine += chalk.rgb(runColor[0], runColor[1], runColor[2])(runChars);
|
|
98
|
+
} else {
|
|
99
|
+
coloredLine += runChars;
|
|
100
|
+
}
|
|
101
|
+
runChars = '';
|
|
102
|
+
runColor = null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
for (let ci = 0; ci < totalCols; ci++) {
|
|
106
|
+
const col = colStart + ci;
|
|
107
|
+
const tx = totalCols > 1 ? ci / (totalCols - 1) : 0;
|
|
108
|
+
|
|
109
|
+
const bitmask = (px(row, col) << 1) | px(row + 1, col);
|
|
110
|
+
let char;
|
|
111
|
+
switch (bitmask) {
|
|
112
|
+
case 0b11: char = BLOCK.FULL; break;
|
|
113
|
+
case 0b10: char = BLOCK.UPPER; break;
|
|
114
|
+
case 0b01: char = BLOCK.LOWER; break;
|
|
115
|
+
default: char = BLOCK.EMPTY;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (char === BLOCK.EMPTY) {
|
|
119
|
+
// Light module: transparent space — flush colored run first
|
|
120
|
+
flushRun();
|
|
121
|
+
coloredLine += char;
|
|
122
|
+
} else {
|
|
123
|
+
// Dark module: compute 2D color for this exact position
|
|
124
|
+
const color = computeColor(preset, tx, ty);
|
|
125
|
+
|
|
126
|
+
// Group with previous char if colors are close enough (Δ < 8 per channel)
|
|
127
|
+
if (runColor && colorsClose(runColor, color, 8)) {
|
|
128
|
+
runChars += char;
|
|
129
|
+
} else {
|
|
130
|
+
flushRun();
|
|
131
|
+
runColor = color;
|
|
132
|
+
runChars = char;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
flushRun();
|
|
138
|
+
output.push(coloredLine);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return output;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Render QR as plain Unicode half-blocks (no ANSI).
|
|
146
|
+
* For clipboard / README / text file export.
|
|
147
|
+
*/
|
|
148
|
+
export function renderASCII(modules, { quietZone = 2 } = {}) {
|
|
149
|
+
const { size, data } = modules;
|
|
150
|
+
const px = (r, c) => (r < 0 || r >= size || c < 0 || c >= size)
|
|
151
|
+
? 0 : (data[r * size + c] ? 1 : 0);
|
|
152
|
+
|
|
153
|
+
const rows = [];
|
|
154
|
+
for (let di = 0; di < Math.ceil((size + quietZone * 2) / 2); di++) {
|
|
155
|
+
const row = -quietZone + di * 2;
|
|
156
|
+
let line = '';
|
|
157
|
+
for (let col = -quietZone; col < size + quietZone; col++) {
|
|
158
|
+
const bitmask = (px(row, col) << 1) | px(row + 1, col);
|
|
159
|
+
switch (bitmask) {
|
|
160
|
+
case 0b11: line += BLOCK.FULL; break;
|
|
161
|
+
case 0b10: line += BLOCK.UPPER; break;
|
|
162
|
+
case 0b01: line += BLOCK.LOWER; break;
|
|
163
|
+
default: line += BLOCK.EMPTY;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
rows.push(line);
|
|
167
|
+
}
|
|
168
|
+
return rows.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function getQrVersion(size) {
|
|
172
|
+
return Math.round((size - 17) / 4);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Color computation ─────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export function computeColor({ type, stops }, tx, ty) {
|
|
178
|
+
switch (type) {
|
|
179
|
+
case 'solid':
|
|
180
|
+
return stops[0];
|
|
181
|
+
|
|
182
|
+
case 'diagonal': {
|
|
183
|
+
const t = clamp((tx + ty) / 2);
|
|
184
|
+
return lerpStops(stops, t);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'vertical': {
|
|
188
|
+
return lerpStops(stops, ty);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case 'wave': {
|
|
192
|
+
// Sinusoidal ripple — creates a shimmering interference pattern
|
|
193
|
+
const raw = Math.sin((tx * 3.5 + ty * 2.5) * Math.PI);
|
|
194
|
+
const t = clamp((raw + 1) / 2);
|
|
195
|
+
return lerpStops(stops, t);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'radial': {
|
|
199
|
+
// Bloom from center outward
|
|
200
|
+
const dx = tx - 0.5;
|
|
201
|
+
const dy = ty - 0.5;
|
|
202
|
+
const t = clamp(Math.sqrt(dx * dx + dy * dy) * Math.SQRT2);
|
|
203
|
+
return lerpStops(stops, t);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case '4corner': {
|
|
207
|
+
// Bilinear blend: [top-left, top-right, bottom-left, bottom-right]
|
|
208
|
+
const [tl, tr, bl, br] = stops;
|
|
209
|
+
const top = lerp3(tl, tr, tx);
|
|
210
|
+
const bottom = lerp3(bl, br, tx);
|
|
211
|
+
return lerp3(top, bottom, ty);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
default:
|
|
215
|
+
return [220, 220, 220];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Multi-stop linear gradient interpolation
|
|
220
|
+
function lerpStops(stops, t) {
|
|
221
|
+
if (stops.length === 1) return stops[0];
|
|
222
|
+
const seg = 1 / (stops.length - 1);
|
|
223
|
+
const i = Math.min(Math.floor(t / seg), stops.length - 2);
|
|
224
|
+
const lt = clamp((t - i * seg) / seg);
|
|
225
|
+
return lerp3(stops[i], stops[i + 1], lt);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Lerp between two [R,G,B] triplets
|
|
229
|
+
function lerp3(a, b, t) {
|
|
230
|
+
return [
|
|
231
|
+
Math.round(a[0] + (b[0] - a[0]) * t),
|
|
232
|
+
Math.round(a[1] + (b[1] - a[1]) * t),
|
|
233
|
+
Math.round(a[2] + (b[2] - a[2]) * t),
|
|
234
|
+
];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clamp(v, lo = 0, hi = 1) {
|
|
238
|
+
return Math.max(lo, Math.min(hi, v));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Check if two colors are close enough to group into the same chalk call
|
|
242
|
+
function colorsClose([r1, g1, b1], [r2, g2, b2], threshold) {
|
|
243
|
+
return Math.abs(r1 - r2) < threshold &&
|
|
244
|
+
Math.abs(g1 - g2) < threshold &&
|
|
245
|
+
Math.abs(b1 - b2) < threshold;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Build particle data for the fly-in animation.
|
|
250
|
+
* Returns an array of { targetRow, targetCol, char, color } objects
|
|
251
|
+
* and the framebuffer dimensions { displayRows, totalCols }.
|
|
252
|
+
*/
|
|
253
|
+
export function buildParticleData(modules, { gradient = 'none', quietZone = 2 } = {}) {
|
|
254
|
+
const { size, data } = modules;
|
|
255
|
+
const preset = GRADIENT_PRESETS[gradient] ?? GRADIENT_PRESETS.none;
|
|
256
|
+
const colStart = -quietZone, colEnd = size + quietZone;
|
|
257
|
+
const rowStart = -quietZone, rowEnd = size + quietZone;
|
|
258
|
+
const totalCols = colEnd - colStart;
|
|
259
|
+
const totalRows = rowEnd - rowStart;
|
|
260
|
+
const displayRows = Math.ceil(totalRows / 2);
|
|
261
|
+
|
|
262
|
+
const px = (r, c) => {
|
|
263
|
+
if (r < 0 || r >= size || c < 0 || c >= size) return 0;
|
|
264
|
+
return data[r * size + c] ? 1 : 0;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const particles = [];
|
|
268
|
+
for (let di = 0; di < displayRows; di++) {
|
|
269
|
+
const row = rowStart + di * 2;
|
|
270
|
+
const ty = displayRows > 1 ? di / (displayRows - 1) : 0;
|
|
271
|
+
for (let ci = 0; ci < totalCols; ci++) {
|
|
272
|
+
const col = colStart + ci;
|
|
273
|
+
const tx = totalCols > 1 ? ci / (totalCols - 1) : 0;
|
|
274
|
+
const bitmask = (px(row, col) << 1) | px(row + 1, col);
|
|
275
|
+
if (bitmask === 0) continue;
|
|
276
|
+
let char;
|
|
277
|
+
switch (bitmask) {
|
|
278
|
+
case 0b11: char = '█'; break;
|
|
279
|
+
case 0b10: char = '▀'; break;
|
|
280
|
+
default: char = '▄';
|
|
281
|
+
}
|
|
282
|
+
particles.push({ targetRow: di, targetCol: ci, char, color: computeColor(preset, tx, ty) });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { particles, displayRows, totalCols };
|
|
286
|
+
}
|
package/src/export.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export module: save QR codes as PNG, SVG, or plain ASCII text.
|
|
3
|
+
* Leverages qrcode's built-in renderers for pixel-perfect file output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import QRCode from 'qrcode';
|
|
7
|
+
import { writeFile } from 'fs/promises';
|
|
8
|
+
import { createMatrix, renderASCII } from './engine.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Export as high-resolution PNG using qrcode's canvas renderer.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} text - Data to encode
|
|
14
|
+
* @param {string} filePath - Output path (e.g. "qr-code.png")
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @param {string} opts.errorLevel - QR error correction level
|
|
17
|
+
* @param {number} opts.width - Image width in pixels (default 600)
|
|
18
|
+
* @param {string} opts.darkColor - Hex color for dark modules
|
|
19
|
+
* @param {string} opts.lightColor - Hex color for light modules
|
|
20
|
+
*/
|
|
21
|
+
export async function exportPNG(text, filePath, {
|
|
22
|
+
errorLevel = 'M',
|
|
23
|
+
width = 600,
|
|
24
|
+
darkColor = '#000000',
|
|
25
|
+
lightColor = '#FFFFFF',
|
|
26
|
+
} = {}) {
|
|
27
|
+
await QRCode.toFile(filePath, text, {
|
|
28
|
+
type: 'png',
|
|
29
|
+
width,
|
|
30
|
+
margin: 4,
|
|
31
|
+
errorCorrectionLevel: errorLevel,
|
|
32
|
+
color: {
|
|
33
|
+
dark: darkColor,
|
|
34
|
+
light: lightColor,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Export as clean, scalable SVG vector.
|
|
41
|
+
* SVG output is ideal for print, presentations, and web embedding.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} text - Data to encode
|
|
44
|
+
* @param {string} filePath - Output path (e.g. "qr-code.svg")
|
|
45
|
+
* @param {object} opts
|
|
46
|
+
*/
|
|
47
|
+
export async function exportSVG(text, filePath, {
|
|
48
|
+
errorLevel = 'M',
|
|
49
|
+
darkColor = '#000000',
|
|
50
|
+
lightColor = '#FFFFFF',
|
|
51
|
+
} = {}) {
|
|
52
|
+
const svg = await QRCode.toString(text, {
|
|
53
|
+
type: 'svg',
|
|
54
|
+
margin: 4,
|
|
55
|
+
errorCorrectionLevel: errorLevel,
|
|
56
|
+
color: {
|
|
57
|
+
dark: darkColor,
|
|
58
|
+
light: lightColor,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
await writeFile(filePath, svg, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Export as plain Unicode half-block text (no ANSI codes).
|
|
66
|
+
* Paste into README files, Markdown, or any plain-text environment.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} text - Data to encode
|
|
69
|
+
* @param {string} filePath - Output path (e.g. "qr-code.txt")
|
|
70
|
+
* @param {object} opts
|
|
71
|
+
*/
|
|
72
|
+
export async function exportTXT(text, filePath, { errorLevel = 'M' } = {}) {
|
|
73
|
+
const modules = createMatrix(text, { errorLevel });
|
|
74
|
+
const ascii = renderASCII(modules);
|
|
75
|
+
await writeFile(filePath, ascii, 'utf8');
|
|
76
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data formatters + UI metadata for prompts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function formatWifi({ ssid, password, security = 'WPA', hidden = false }) {
|
|
6
|
+
const esc = s => String(s).replace(/([\\;,":])/, '\\$1');
|
|
7
|
+
if (security === 'nopass') return `WIFI:T:nopass;S:${esc(ssid)};;`;
|
|
8
|
+
return `WIFI:T:${security};S:${esc(ssid)};P:${esc(password)};H:${hidden};;`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatVCard({ firstName = '', lastName = '', phone = '', email = '', org = '', url = '' }) {
|
|
12
|
+
const lines = [
|
|
13
|
+
'BEGIN:VCARD',
|
|
14
|
+
'VERSION:3.0',
|
|
15
|
+
`N:${lastName};${firstName};;;`,
|
|
16
|
+
`FN:${[firstName, lastName].filter(Boolean).join(' ')}`,
|
|
17
|
+
];
|
|
18
|
+
if (org) lines.push(`ORG:${org}`);
|
|
19
|
+
if (phone) lines.push(`TEL;TYPE=CELL:${phone}`);
|
|
20
|
+
if (email) lines.push(`EMAIL;TYPE=INTERNET:${email}`);
|
|
21
|
+
if (url) lines.push(`URL:${url}`);
|
|
22
|
+
lines.push('END:VCARD');
|
|
23
|
+
return lines.join('\r\n');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const ERROR_LEVELS = {
|
|
27
|
+
L: { label: 'L — Low (7% recovery)', desc: 'Smallest matrix. Best for pristine digital screens.', recovery: '7%' },
|
|
28
|
+
M: { label: 'M — Medium (15% recovery)', desc: 'Balanced. Recommended for URLs and general use.', recovery: '15%' },
|
|
29
|
+
Q: { label: 'Q — Quartile (25% recovery)', desc: 'Good tolerance for printed/folded materials.', recovery: '25%' },
|
|
30
|
+
H: { label: 'H — High (30% recovery)', desc: 'Maximum redundancy — logos can overlay up to 30% area.', recovery: '30%' },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const GRADIENT_OPTIONS = [
|
|
34
|
+
{ name: ' ◆ Classic — Crisp white, terminal-native', value: 'none' },
|
|
35
|
+
{ name: ' ◈ Retro — Diagonal: coral red → warm amber', value: 'retro' },
|
|
36
|
+
{ name: ' ◈ Ocean — Diagonal: deep navy → electric cyan', value: 'ocean' },
|
|
37
|
+
{ name: ' ◈ Neon — Sine-wave: green · cyan · magenta ripple', value: 'neon' },
|
|
38
|
+
{ name: ' ◈ Sunset — Diagonal: hot red → orange → gold', value: 'sunset' },
|
|
39
|
+
{ name: ' ◈ Galaxy — 4-corner bilinear: purple · blue · pink · teal', value: 'galaxy' },
|
|
40
|
+
{ name: ' ◈ Cherry — Radial bloom: soft white center → deep rose', value: 'cherry' },
|
|
41
|
+
{ name: ' ◈ Matrix — Vertical cascade: dark → electric green', value: 'matrix' },
|
|
42
|
+
{ name: ' ◈ Fire — Diagonal: blood red → ember orange → gold', value: 'fire' },
|
|
43
|
+
{ name: ' ◈ Vaporwave — 4-corner: magenta · cyan · purple · blue', value: 'vapor' },
|
|
44
|
+
];
|
package/src/handshake.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-to-Mobile WebSocket Handshake
|
|
3
|
+
*
|
|
4
|
+
* Starts an HTTP + WebSocket server. The QR code encodes the server URL.
|
|
5
|
+
* When scanned, the phone opens a mobile-optimized page and connects.
|
|
6
|
+
* The terminal detects the connection and streams any text the user types
|
|
7
|
+
* on their phone directly into the terminal session.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from 'http';
|
|
11
|
+
import { WebSocketServer } from 'ws';
|
|
12
|
+
import { networkInterfaces } from 'os';
|
|
13
|
+
|
|
14
|
+
// Minimal mobile web UI — served to the phone browser after scan
|
|
15
|
+
const MOBILE_PAGE = `<!DOCTYPE html>
|
|
16
|
+
<html>
|
|
17
|
+
<head>
|
|
18
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
19
|
+
<title>Terminal Link</title>
|
|
20
|
+
<style>
|
|
21
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
22
|
+
body{background:#0d0d0d;color:#00ff41;font-family:monospace;padding:20px;min-height:100vh}
|
|
23
|
+
h1{font-size:14px;letter-spacing:3px;color:#444;margin-bottom:20px}
|
|
24
|
+
#st{font-size:18px;font-weight:bold;padding:14px;border:1px solid;border-radius:4px;text-align:center;margin-bottom:20px}
|
|
25
|
+
.on{border-color:#00ff41;background:#0a1a0a}.off{border-color:#ff0040;color:#ff0040;background:#1a000a}
|
|
26
|
+
textarea{width:100%;height:160px;background:#111;color:#00ff41;border:1px solid #00ff41;padding:12px;font-size:15px;font-family:monospace;outline:none;resize:none;border-radius:4px}
|
|
27
|
+
button{display:block;width:100%;margin-top:12px;padding:18px;background:#00ff41;color:#000;border:none;font-size:18px;font-family:monospace;font-weight:bold;border-radius:4px;cursor:pointer;letter-spacing:2px}
|
|
28
|
+
button:active{background:#00cc33}
|
|
29
|
+
#log{margin-top:14px;font-size:12px;color:#444;min-height:20px}
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<h1>◈ QR TERMINAL LINK</h1>
|
|
34
|
+
<div id="st" class="off">○ CONNECTING...</div>
|
|
35
|
+
<textarea id="inp" placeholder="Type here — press TRANSMIT to send to the terminal"></textarea>
|
|
36
|
+
<button onclick="send()">▶ TRANSMIT TO TERMINAL</button>
|
|
37
|
+
<div id="log"></div>
|
|
38
|
+
<script>
|
|
39
|
+
var ws=new WebSocket('ws://'+location.host);
|
|
40
|
+
var st=document.getElementById('st');
|
|
41
|
+
var log=document.getElementById('log');
|
|
42
|
+
ws.onopen=function(){st.textContent='● DEVICE LINKED';st.className='on'};
|
|
43
|
+
ws.onclose=function(){st.textContent='○ DISCONNECTED';st.className='off'};
|
|
44
|
+
function send(){
|
|
45
|
+
var t=document.getElementById('inp').value.trim();
|
|
46
|
+
if(!t||ws.readyState!==1)return;
|
|
47
|
+
ws.send(t);
|
|
48
|
+
log.textContent='✓ Sent '+t.length+' chars';
|
|
49
|
+
document.getElementById('inp').value='';
|
|
50
|
+
}
|
|
51
|
+
document.getElementById('inp').addEventListener('keydown',function(e){
|
|
52
|
+
if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){e.preventDefault();send()}
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
</body>
|
|
56
|
+
</html>`;
|
|
57
|
+
|
|
58
|
+
function getLocalIP() {
|
|
59
|
+
const nets = networkInterfaces();
|
|
60
|
+
for (const name of Object.keys(nets)) {
|
|
61
|
+
for (const iface of nets[name]) {
|
|
62
|
+
if (iface.family === 'IPv4' && !iface.internal) return iface.address;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return '127.0.0.1';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start the handshake server.
|
|
70
|
+
*
|
|
71
|
+
* @returns {Promise<{url, waitForDevice, onMessage, close}>}
|
|
72
|
+
* url — The URL to encode in the QR code
|
|
73
|
+
* waitForDevice — Promise that resolves when the first phone connects
|
|
74
|
+
* onMessage — Register a callback for incoming phone messages
|
|
75
|
+
* close — Shut down the server
|
|
76
|
+
*/
|
|
77
|
+
export function startHandshakeServer() {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const port = 3847 + Math.floor(Math.random() * 100);
|
|
80
|
+
const ip = getLocalIP();
|
|
81
|
+
const url = `http://${ip}:${port}`;
|
|
82
|
+
|
|
83
|
+
const server = http.createServer((req, res) => {
|
|
84
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
85
|
+
res.end(MOBILE_PAGE);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const wss = new WebSocketServer({ server });
|
|
89
|
+
const messageHandlers = [];
|
|
90
|
+
let deviceConnectResolve = null;
|
|
91
|
+
const deviceConnected = new Promise(r => { deviceConnectResolve = r; });
|
|
92
|
+
|
|
93
|
+
wss.on('connection', (ws) => {
|
|
94
|
+
deviceConnectResolve(); // signal first connection
|
|
95
|
+
ws.on('message', (raw) => {
|
|
96
|
+
const text = raw.toString();
|
|
97
|
+
messageHandlers.forEach(h => h(text));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server.on('error', reject);
|
|
102
|
+
|
|
103
|
+
server.listen(port, () => {
|
|
104
|
+
resolve({
|
|
105
|
+
url,
|
|
106
|
+
waitForDevice: deviceConnected,
|
|
107
|
+
onMessage: (handler) => messageHandlers.push(handler),
|
|
108
|
+
close: () => { wss.close(); server.close(); },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|