import { Recipe, BaseFilter, FrameId, ColorAdjustments } from '../types';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from './defaultRecipes';
import { sanitizeHslBands } from './colorUtils';
// 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',
'old-film', 'old-film-portrait',
];
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 [
'',
``,
` ${scramble(JSON.stringify(shareable), salt)}`,
'',
'',
].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 {
const salt = /salt="([0-9a-fA-F]+)"/.exec(xml)?.[1];
const 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) };
// The mixer is the one adjustment that arrives as a nested object, so it is
// the one that needs its own gate: unknown band ids go, every value is
// clamped to the knob range, and a band left at zero is dropped rather than
// carried around.
adjustments.hslBands = sanitizeHslBands(raw.adjustments.hslBands);
const frameId: FrameId = FRAMES.includes(raw.frameId) ? raw.frameId : 'none';
return { name, baseFilter, adjustments, frameId, useGeotag: !!raw.useGeotag };
}