Drop the in-app unlock key: only the PRO apk opens PRO
The set is two apks and the unlock key was a second, weaker lock on the same door. A verifier that runs offline has to carry its secret inside the lite apk, so anyone who unpacked it could mint a key. Nothing in the lite build can flip the gate now. - entitlement.ts keeps the gate (which look is PRO, what an export carries in lite) and exports IS_PRO straight from the build flag. - SETTINGS loses the key field; the plan chip still says LITE / PRO ACTIVE and the helper text says what lite cannot export. - The upsell alerts just name the build that has the feature. - tools/keygen.mjs goes with the verifier it fed.
This commit is contained in:
+17
-56
@@ -1,15 +1,21 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { FrameId } from '../types';
|
||||
import { IS_PRO_BUILD } from '../provariant';
|
||||
|
||||
// Lite (free, forever) vs PRO (one unlock key). The gate is on the FILE, not on
|
||||
// the preview: a Lite user may pick any preset, frame or watermark, compose
|
||||
// with it and compare before/after — only the render/export of that look is
|
||||
// held back, and every Lite export carries the mark below.
|
||||
// LITE (free) vs PRO (the paid build). The two are separate apks built from this
|
||||
// one tree: the tier is fixed at build time by src/provariant.ts (rewritten by
|
||||
// tools/set-variant.mjs, `npm run apk:lite|apk:pro`) and nothing in a LITE apk
|
||||
// can change it afterwards — no key, no server, no stored flag.
|
||||
|
||||
const PRO_KEY = '@camrecipe_pro:pro';
|
||||
// Constant for the life of the build, so every `!IS_PRO` branch below is dead
|
||||
// code the bundler is free to drop.
|
||||
export const IS_PRO = IS_PRO_BUILD;
|
||||
|
||||
// Frames on the Lite side: the plain export only. The three printed looks are
|
||||
// The gate is on the FILE, not on the preview: a LITE user may pick any preset,
|
||||
// frame or watermark, compose with it and compare before/after — only the
|
||||
// render/export of that look is held back, and every LITE export carries the
|
||||
// mark below.
|
||||
|
||||
// Frames on the LITE side: the plain export only. The three printed looks are
|
||||
// PRO (the artwork/card geometry is the paid half of the FRAME tab).
|
||||
export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe'];
|
||||
|
||||
@@ -19,11 +25,11 @@ export const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe'];
|
||||
export const isFreeRecipe = (id: string | null | undefined): boolean =>
|
||||
!id || id.startsWith('sim-');
|
||||
|
||||
// Lite may store its own recipes, up to this many: creating and editing them is
|
||||
// free, deleting one is PRO — so a Lite list only ever grows to this size.
|
||||
// LITE may store its own recipes, up to this many: creating and editing them is
|
||||
// free, deleting one is PRO — so a LITE list only ever grows to this size.
|
||||
export const LITE_RECIPE_LIMIT = 3;
|
||||
|
||||
// Burned into every Lite export, bottom-right of the photo/frame area. Fractions
|
||||
// Burned into every LITE export, bottom-right of the photo/frame area. Fractions
|
||||
// of the stamp canvas, same space the custom mark uses.
|
||||
export const LITE_MARK = {
|
||||
text: 'RECIPESCAM',
|
||||
@@ -39,7 +45,7 @@ export interface Look {
|
||||
customWm: boolean;
|
||||
}
|
||||
|
||||
// The first PRO feature a look is using, or null when it is Lite-clean. One
|
||||
// The first PRO feature a look is using, or null when it is LITE-clean. One
|
||||
// string, so the caller can name it back to the user in the alert.
|
||||
export function proLookInUse(look: Look): string | null {
|
||||
if (!isFreeRecipe(look.recipeId)) return 'A PRO preset';
|
||||
@@ -47,48 +53,3 @@ export function proLookInUse(look: Look): string | null {
|
||||
if (look.customWm) return 'The custom watermark';
|
||||
return null;
|
||||
}
|
||||
|
||||
// The Pro apk is the product: it is unlocked by being the paid build, and its
|
||||
// SETTINGS sheet never shows a key field. The Lite apk unlocks the same features
|
||||
// with a keygen key, which is what a buyer of the cheap tier gets instead.
|
||||
export const loadPro = async (): Promise<boolean> =>
|
||||
IS_PRO_BUILD || (await AsyncStorage.getItem(PRO_KEY)) === '1';
|
||||
|
||||
// ---- unlock key -------------------------------------------------------------
|
||||
// Offline product key: 12 payload chars + 4 checksum chars, any punctuation
|
||||
// ignored (`XXXX-XXXX-XXXX-XXXX`). No server, no clock — whoever buys gets a key
|
||||
// from tools/keygen.mjs. A reverse engineer can mint their own: that is the
|
||||
// ceiling of a client-only check. Move to Play Billing + server validation when
|
||||
// the app ships on Play (ponytail).
|
||||
// Keep KEY_ALPHABET / KEY_SECRET / checksum byte-identical to tools/keygen.mjs.
|
||||
const KEY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const KEY_SECRET = 'recipes-cam-key-2026';
|
||||
|
||||
const checksum = (payload: string): number => {
|
||||
let h = 0x811c9dc5; // FNV-1a 32
|
||||
const src = KEY_SECRET + payload;
|
||||
for (let i = 0; i < src.length; i++) {
|
||||
h ^= src.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h & 0xfffff; // low 20 bits -> 4 chars of 5 bits
|
||||
};
|
||||
|
||||
export function keyIsValid(raw: string): boolean {
|
||||
const s = (raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
if (s.length !== 16) return false;
|
||||
for (let i = 0; i < 16; i++) if (KEY_ALPHABET.indexOf(s[i]) < 0) return false;
|
||||
const bits = checksum(s.slice(0, 12));
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (KEY_ALPHABET[(bits >>> (i * 5)) & 31] !== s[12 + i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns false for a wrong key; on success PRO is persisted and every later
|
||||
// export stops being marked.
|
||||
export async function activatePro(raw: string): Promise<boolean> {
|
||||
if (!keyIsValid(raw)) return false;
|
||||
await AsyncStorage.setItem(PRO_KEY, '1');
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user