v0.5: CLI terminal QR frontend + tests + deploy to /qr-transfer/

This commit is contained in:
Hermes Agent
2026-05-03 19:44:16 +00:00
parent 87bd318adc
commit 73ae264e4d
5 changed files with 469 additions and 2 deletions
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bun
/**
* QR Terminal Display
*
* Displays a sequence of QR codes in the terminal for text/file transfer.
* Reads text from stdin or a file argument, encodes it using the same
* protocol as the webapp, and loops through the QR sequence until
* interrupted.
*
* Usage:
* bun run src/cli/qr-terminal.ts # read from stdin
* bun run src/cli/qr-terminal.ts /path/to/file.txt # read from file
* node --import=tsx src/cli/qr-terminal.ts ... # with tsx
*/
import { readFileSync, existsSync } from 'fs';
import { generateQRMatrix } from '../core/qr/qr_encode';
import { packetize } from '../core/sender/packetizer';
import { scheduleFrames } from '../core/sender/scheduler';
import { QR_VERSION, ECC_LEVEL } from '../core/protocol/constants';
import { parseHeader } from '../core/protocol/packet';
import { clearScreen, hideCursor, showCursor, renderToTerminal } from './terminal_raster';
const FPS_MS = 100;
// ────────────────────────────────────────────────────────────────────────
// Argument parsing
// ────────────────────────────────────────────────────────────────────────
function readInput(): Uint8Array {
const args = process.argv.slice(2);
if (args.length > 0) {
const filePath = args[0]!;
if (!existsSync(filePath)) {
console.error(`Error: file not found: ${filePath}`);
process.exit(1);
}
return new Uint8Array(readFileSync(filePath));
}
// Read from stdin (fd 0)
try {
return new Uint8Array(readFileSync(0));
} catch (err: any) {
console.error(`Error reading stdin: ${err.message ?? String(err)}`);
process.exit(1);
}
}
// ────────────────────────────────────────────────────────────────────────
// Encode pipeline (reuse webapp protocol)
// ────────────────────────────────────────────────────────────────────────
function buildFrames(data: Uint8Array): { packets: Uint8Array[]; genIndices: number[]; meta: { isText: boolean; isCompressed: boolean; totalGenerations: number; dataLength: number } } {
const result = packetize(data, false, true);
const ordered = scheduleFrames(result.packets, result.totalGenerations);
const genIndices = ordered.map((pkt) => parseHeader(pkt).generationIndex);
return {
packets: ordered,
genIndices,
meta: {
isText: result.isText,
isCompressed: result.isCompressed,
totalGenerations: result.totalGenerations,
dataLength: result.dataLength,
},
};
}
// ────────────────────────────────────────────────────────────────────────
// Main loop
// ────────────────────────────────────────────────────────────────────────
function main() {
let data: Uint8Array;
try {
data = readInput();
} catch (err: any) {
console.error(`Error reading input: ${err.message ?? String(err)}`);
process.exit(1);
}
if (data.length === 0) {
console.error('Error: no input data. Provide a file path or pipe text to stdin.');
process.exit(1);
}
const { packets, genIndices, meta } = buildFrames(data);
// Pre-render all QR matrices to terminal strings
const frames: string[][] = [];
for (const pkt of packets) {
const matrix = generateQRMatrix(pkt, QR_VERSION, ECC_LEVEL);
frames.push(renderToTerminal(matrix));
}
const termWidth = process.stdout.columns ?? 80;
const termHeight = process.stdout.rows ?? 24;
const qrWidth = frames[0]?.[0]?.length ?? 0;
const qrHeight = frames[0]?.length ?? 0;
const padLeft = Math.max(0, Math.floor((termWidth - qrWidth) / 2));
const padTop = Math.max(0, Math.floor((termHeight - qrHeight - 4) / 2));
let running = true;
let frameIdx = 0;
function draw() {
if (!running) return;
const lines: string[] = [];
// Top padding
for (let i = 0; i < padTop; i++) {
lines.push('');
}
// QR frame, centered
for (const row of frames[frameIdx]!) {
lines.push(' '.repeat(padLeft) + row);
}
// Status lines
lines.push('');
lines.push(
' '.repeat(padLeft) +
`Frame ${frameIdx + 1}/${frames.length} | Gen ${genIndices[frameIdx]! + 1}/${meta.totalGenerations} | ` +
`${meta.isText ? 'text' : 'binary'} ${meta.isCompressed ? '(compressed)' : ''}`,
);
lines.push(' '.repeat(padLeft) + `Press q or Ctrl-C to quit`);
clearScreen();
process.stdout.write(lines.join('\n'));
frameIdx = (frameIdx + 1) % frames.length;
}
function cleanup() {
running = false;
clearInterval(interval);
clearScreen();
showCursor();
process.stdout.write('QR terminal display stopped.\n');
process.exit(0);
}
// Keyboard handling
if (process.stdin.isTTY) {
hideCursor();
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdin.setEncoding('utf8');
process.stdin.on('data', (key: string) => {
if (key === 'q' || key === 'Q' || key === '\u0003') {
cleanup();
}
});
}
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
// Start loop
draw();
const interval = setInterval(draw, FPS_MS);
}
main();
+65
View File
@@ -0,0 +1,65 @@
/**
* Terminal QR rasterizer.
*
* Renders a QR-code boolean matrix into compact terminal output using
* half-block Unicode characters (U+2580 / U+2584 / U+2588 / space).
* Each terminal row displays two QR rows, giving an approximately
* square aspect ratio in typical monospace terminals.
*/
const BLOCK_FULL = '\u2588';
const BLOCK_UPPER = '\u2580';
const BLOCK_LOWER = '\u2584';
const BLOCK_EMPTY = ' ';
/**
* Render a QR boolean matrix to terminal lines.
* @param matrix 2-D array where true = dark module
* @returns Array of terminal strings (one per screen row)
*/
export function renderToTerminal(matrix: boolean[][]): string[] {
const size = matrix.length;
const lines: string[] = [];
for (let y = 0; y < size; y += 2) {
let line = '';
for (let x = 0; x < size; x++) {
const top = matrix[y][x];
const bottom = y + 1 < size ? matrix[y + 1][x] : false;
if (top && bottom) {
line += BLOCK_FULL;
} else if (top) {
line += BLOCK_UPPER;
} else if (bottom) {
line += BLOCK_LOWER;
} else {
line += BLOCK_EMPTY;
}
}
lines.push(line);
}
return lines;
}
/**
* Clear the terminal screen and move cursor to home position.
*/
export function clearScreen(): void {
process.stdout.write('\x1b[2J\x1b[H');
}
/**
* Hide the terminal cursor.
*/
export function hideCursor(): void {
process.stdout.write('\x1b[?25l');
}
/**
* Show the terminal cursor.
*/
export function showCursor(): void {
process.stdout.write('\x1b[?25h');
}