100 lines
3.9 KiB
TypeScript
100 lines
3.9 KiB
TypeScript
import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
|
|
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
|
|
|
|
// Shared-recipe file: a tiny XML envelope around a scrambled hex payload of the
|
|
// recipe JSON, so a shared recipe imports back into the app on another phone.
|
|
//
|
|
// ponytail: obfuscation, not cryptography — the key ships inside the app and
|
|
// the keystream is a plain xorshift. It stops hand-editing and casual peeking,
|
|
// which is all a shared look needs; swap in expo-crypto/AES at the same two
|
|
// functions if the format ever has to resist a determined attacker.
|
|
const KEY = 'RecipesCam::recipe-share::v1';
|
|
const ALGORITHM = 'xor16-v1';
|
|
|
|
const BASE_FILTERS: BaseFilter[] = [...FILM_SIMS.map((s) => s.baseFilter), 'none'];
|
|
const FRAMES: FrameId[] = ['none', 'polaroid', 'classic-white', 'cinematic', 'wallframe', 'wallframe-landscape'];
|
|
|
|
const hash = (s: string): number => {
|
|
let h = 0x811c9dc5;
|
|
for (let i = 0; i < s.length; i++) {
|
|
h ^= s.charCodeAt(i);
|
|
h = Math.imul(h, 0x01000193);
|
|
}
|
|
return h >>> 0;
|
|
};
|
|
|
|
// xorshift32: one 16-bit word of keystream per call.
|
|
const stream = (seed: number) => {
|
|
let s = seed >>> 0 || 0x9e3779b9;
|
|
return () => {
|
|
s ^= s << 13;
|
|
s >>>= 0;
|
|
s ^= s >>> 17;
|
|
s ^= s << 5;
|
|
s >>>= 0;
|
|
return s & 0xffff;
|
|
};
|
|
};
|
|
|
|
// 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names
|
|
// with Vietnamese diacritics included) survives the round trip.
|
|
const scramble = (text: string, salt: number): string => {
|
|
const k = stream(hash(`${KEY}|${salt}`));
|
|
let out = '';
|
|
for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0');
|
|
return out;
|
|
};
|
|
|
|
const unscramble = (hex: string, salt: number): string => {
|
|
if (hex.length % 4 !== 0) throw new Error('Recipe file is damaged.');
|
|
const k = stream(hash(`${KEY}|${salt}`));
|
|
let out = '';
|
|
for (let i = 0; i < hex.length; i += 4) out += String.fromCharCode(parseInt(hex.slice(i, i + 4), 16) ^ k());
|
|
return out;
|
|
};
|
|
|
|
// The shareable half of a recipe: never the local id, and only known fields, so
|
|
// a file from a newer version cannot smuggle anything into storage.
|
|
export function exportRecipeXml(recipe: Recipe): string {
|
|
const shareable = {
|
|
name: recipe.name,
|
|
baseFilter: recipe.baseFilter,
|
|
adjustments: recipe.adjustments,
|
|
frameId: recipe.frameId,
|
|
useGeotag: recipe.useGeotag,
|
|
};
|
|
const salt = Math.floor(Math.random() * 0xffffffff) >>> 0;
|
|
return [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
`<recipescam-recipe version="1" algorithm="${ALGORITHM}" salt="${salt.toString(16)}">`,
|
|
` <payload>${scramble(JSON.stringify(shareable), salt)}</payload>`,
|
|
'</recipescam-recipe>',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
// Throws with a user-readable reason. Fields are validated and clamped to what
|
|
// this build understands: a recipe from a newer app imports as best it can
|
|
// instead of poisoning the store.
|
|
export function importRecipeXml(xml: string): Omit<Recipe, 'id' | 'isCustom'> {
|
|
const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1];
|
|
const payload = /<payload>([0-9a-fA-F\s]+)<\/payload>/.exec(xml)?.[1]?.replace(/\s+/g, '');
|
|
if (!salt || !payload) throw new Error('Not a RecipesCam recipe file.');
|
|
|
|
let raw: any;
|
|
try {
|
|
raw = JSON.parse(unscramble(payload, parseInt(salt, 16)));
|
|
} catch {
|
|
raw = null;
|
|
}
|
|
if (!raw || typeof raw !== 'object' || typeof raw.name !== 'string' || !raw.adjustments || typeof raw.adjustments !== 'object') {
|
|
throw new Error('Recipe file is damaged or from another app.');
|
|
}
|
|
|
|
const name = raw.name.trim().slice(0, 40) || 'Imported recipe';
|
|
const baseFilter: BaseFilter = BASE_FILTERS.includes(raw.baseFilter) ? raw.baseFilter : 'none';
|
|
const adjustments: ColorAdjustments = { ...DEFAULT_ADJUSTMENTS, ...(raw.adjustments as Partial<ColorAdjustments>) };
|
|
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
|
|
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
|
|
}
|