qrloop
Version:
Envelop big blob of data into frames that can be displayed in series of QR Codes
206 lines (184 loc) • 5.81 kB
text/typescript
import md5 from "md5";
import { Buffer } from "buffer";
import { xor } from "./Buffer";
import { MAX_NONCE, FOUNTAIN_V1 } from "./constants";
type Frame = {
framesCount: number;
index: number;
data: Buffer;
};
type Fountain = {
frameIndexes: number[];
data: Buffer;
};
export type State =
| {
frames: Frame[];
fountainsQueue: Fountain[]; // fountains not yet addressed
exploredFountains: string[]; // cache of fountains that was addressed in order to not add them again in queue
}
| undefined
| null;
const initialState = {
frames: [],
fountainsQueue: [],
exploredFountains: [],
};
function resolveFountains(state: State): State {
if (!state) return state;
const fountainsQueue = state.fountainsQueue.slice(0);
const frames = state.frames.slice(0);
if (fountainsQueue.length === 0 || frames.length === 0) return state;
const { framesCount } = frames[0];
const framesByIndex: { [_: number]: Frame } = {};
for (let i = 0; i < frames.length; ++i) {
const frame = frames[i];
framesByIndex[frame.index] = frame;
}
let i = 0;
while (i < fountainsQueue.length) {
const fountain = fountainsQueue[i];
const existingFramesData = [];
const missing = [];
for (let j = 0; j < fountain.frameIndexes.length; ++j) {
const index = fountain.frameIndexes[j];
const f = framesByIndex[index];
if (f) {
existingFramesData.push(f.data);
} else {
missing.push(index);
}
}
if (
existingFramesData.length > 0 &&
fountain.data.length !==
Math.min(...existingFramesData.map((f) => f.length))
) {
// drop the fountain that no longer match the frames data length
fountainsQueue.splice(i, 1);
} else if (missing.length === 0) {
// fountain useless, simply eat it and continue on same index
// TODO we could assert the data is equal to xor to do a checksum. not sure to do if does not match
fountainsQueue.splice(i, 1);
} else if (missing.length === 1) {
// found a frame to recover. rebuild it
const [index] = missing;
const recoveredData = xor(existingFramesData.concat([fountain.data]));
const head = Buffer.alloc(5);
head.writeUInt8(0, 0);
head.writeUInt16BE(framesCount, 1);
head.writeUInt16BE(index, 3);
const frame = {
index,
framesCount,
data: recoveredData,
};
frames.push(frame);
framesByIndex[index] = frame;
fountainsQueue.splice(i, 1);
// we start over to see if there is no impacted fountains
i = 0;
} else {
i++;
}
}
return {
...state,
frames,
fountainsQueue,
};
}
/**
* reduce frames data array to add on more chunk to it.
* As a user of this function, consider the frames to be a black box and use the available functions to extract things.
*/
export function parseFramesReducer(_state: State, chunkStr: string): State {
const state = _state || initialState;
const chunk = Buffer.from(chunkStr, "base64");
const head = chunk.slice(0, 5);
const version = head.readUInt8(0);
if (version === FOUNTAIN_V1) {
if (state.exploredFountains.includes(chunkStr)) return state; // no need to address again
const exploredFountains = state.exploredFountains.concat(chunkStr);
const k = chunk.readUInt16BE(1);
const frameIndexes = [];
for (let i = 0; i < k; ++i) {
frameIndexes.push(chunk.readUInt16BE(3 + 2 * i));
}
const data = chunk.slice(3 + 2 * k);
const frames = state.frames;
const fountain = {
frameIndexes,
data,
};
const fountainsQueue = state.fountainsQueue.concat(fountain);
return resolveFountains({
frames,
fountainsQueue,
exploredFountains,
});
}
if (version >= MAX_NONCE) {
throw new Error("version " + version + " not supported");
}
const framesCount = head.readUInt16BE(1);
const index = head.readUInt16BE(3);
const data = chunk.slice(5);
if (framesCount <= 0) {
throw new Error("invalid framesCount");
}
if (index < 0 || index >= framesCount) {
throw new Error("invalid index");
}
return resolveFountains({
...state,
frames: state.frames
// override frame by index and also make sure all frames have same framesCount. this allows to not be stucked and recover any scenario.
.filter((c) => c.index !== index && c.framesCount === framesCount)
.concat({ framesCount, index, data }),
});
}
/**
* retrieve the total number of frames
*/
export const totalNumberOfFrames = (s: State): number | undefined | null =>
s && s.frames.length > 0 ? s.frames[0].framesCount : null;
/**
* get the currently captured number of frames
*/
export const currentNumberOfFrames = (s: State): number =>
s ? s.frames.length : 0;
/**
* get a progress value from 0 to 1
*/
export const progressOfFrames = (s: State): number => {
const total = totalNumberOfFrames(s);
if (!total) return 0;
return currentNumberOfFrames(s) / total;
};
/**
* check if the frames have all been retrieved
*/
export const areFramesComplete = (s: State): boolean =>
totalNumberOfFrames(s) === currentNumberOfFrames(s);
/**
* return final result of the frames. assuming you have checked `areFramesComplete`
*/
export function framesToData(s?: State): Buffer {
if (!s) {
throw new Error("invalid date: frames is undefined");
}
const all = Buffer.concat(
s.frames
.slice(0)
.sort((a, b) => a.index - b.index)
.map((frame) => frame.data)
);
const length = all.readUInt32BE(0);
const expectedMD5 = all.slice(4, 20).toString("hex");
const data = all.slice(20).slice(0, length);
if (md5(data) !== expectedMD5) {
throw new Error("invalid data: md5 doesn't match");
}
return data;
}