Keep LITE exports clean of PRO looks, and gate CREATE, FAVORITED and + SAVE behind PRO
LITE previews every look and feature but export/render is blocked while a PRO look is in use, and every LITE export carries the RECIPESCAM mark. Keys are generated offline (FNV-1a checksum over a fixed secret, 16 chars from a no-ambiguity alphabet) and activate one machine or a thousand: nothing is bound to a device, so revoking a leaked key needs Play Billing plus server validation. CREATE, FAVORITED, the star chip and the TopBar + SAVE now answer with a PRO prompt instead of a silent no-op; CREATE still drafts and applies the look so LITE can try it, it just cannot persist. The SETTINGS card also lifts above the keyboard now: RN Modal is its own dialog window, so the IME never resizes or pans it and the UNLOCK row was sitting under the keys.
This commit is contained in:
@@ -24,6 +24,7 @@ import { resolvePhotoGPS } from './src/utils/photoGps';
|
||||
import { processAndExportPhoto } from './src/utils/exportEngine';
|
||||
import { processAndExportPhotoNative } from './src/utils/nativeExport';
|
||||
import { PARAM_DEFS } from './src/utils/paramDefs';
|
||||
import { LITE_MARK, activatePro, loadPro, proLookInUse } from './src/utils/entitlement';
|
||||
|
||||
// @ts-ignore
|
||||
import './global.css';
|
||||
@@ -80,6 +81,12 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
// Lite (free) vs PRO (unlocked key). Read once at startup; the unlock key
|
||||
// flips it in place, so every later export drops the mark and the look gate.
|
||||
const [pro, setPro] = useState(false);
|
||||
useEffect(() => {
|
||||
loadPro().then(setPro);
|
||||
}, []);
|
||||
// FAVORITED: ids of the starred recipes. Metadata only — never saved into the
|
||||
// session look, so RESET and applying a recipe leave it untouched.
|
||||
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
|
||||
@@ -758,6 +765,10 @@ export default function App() {
|
||||
};
|
||||
|
||||
const handleSaveRecipe = async (name: string) => {
|
||||
if (!pro) {
|
||||
promptProFeature('Saving a recipe');
|
||||
return;
|
||||
}
|
||||
if (!selectedRecipe) return;
|
||||
const newRecipe = await saveCustomRecipe({
|
||||
name,
|
||||
@@ -832,6 +843,14 @@ export default function App() {
|
||||
// PRESETS → CREATE quick-input: build a brand-new recipe from the modal's
|
||||
// typed fields, persist it, and apply it (the user sees the look immediately).
|
||||
const handleCreateRecipe = async (recipe: Omit<Recipe, 'id' | 'isCustom'>) => {
|
||||
// Lite composes the recipe and applies it as a throwaway draft so the look
|
||||
// can be tried; only STORING it is PRO. 'draft' is no free id, so the
|
||||
// shutter gate already treats the look as the PRO preset it is.
|
||||
if (!pro) {
|
||||
applyRecipe({ ...recipe, id: 'draft', isCustom: false });
|
||||
promptProFeature('Saving a custom recipe');
|
||||
return;
|
||||
}
|
||||
const newRecipe = await saveCustomRecipe(recipe);
|
||||
const list = await getAllRecipes();
|
||||
setRecipes(list);
|
||||
@@ -852,6 +871,10 @@ export default function App() {
|
||||
// the background. The write is the only failure worth logging — the look is
|
||||
// untouched either way.
|
||||
const handleToggleFavorite = (id: string) => {
|
||||
if (!pro) {
|
||||
promptProFeature('Favoriting recipes');
|
||||
return;
|
||||
}
|
||||
const on = !favoriteIds.includes(id);
|
||||
setFavoriteIds((ids) => (on ? [...ids, id] : ids.filter((x) => x !== id)));
|
||||
setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e));
|
||||
@@ -941,6 +964,54 @@ export default function App() {
|
||||
// Thumbnail/preview target for the camera-mode right button: newest photo.
|
||||
const lastPhotoUri = photoHistory.length > 0 ? photoHistory[photoHistory.length - 1].uri : null;
|
||||
|
||||
// The PRO feature the current look is using, or null while it is Lite-clean.
|
||||
// Only the EXPORT consults it: the preview shows every look for free, so the
|
||||
// answer is computed where the file is about to be written, not on the chips.
|
||||
const proLook = proLookInUse({
|
||||
recipeId: selectedRecipe?.id,
|
||||
frame: selectedFrame,
|
||||
customWm: customWmApplied.enabled,
|
||||
});
|
||||
// The native Kotlin exporter has no text-watermark draw, so a Lite export
|
||||
// (which always carries the mark) must take the Skia engine.
|
||||
const nativeExport = NATIVE_EXPORT && pro;
|
||||
// Shutter/export with a PRO look: nothing is rendered — name the feature and
|
||||
// point at the unlock.
|
||||
const promptPro = (feature: string) => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
Alert.alert(
|
||||
'PRO LOOK',
|
||||
`${feature} can be previewed and edited for free, but exporting it needs PRO. Your own key unlocks it in SETTINGS.`,
|
||||
[
|
||||
{ text: 'LATER', style: 'cancel' },
|
||||
{ text: 'SETTINGS', onPress: () => setSettingsVisible(true) },
|
||||
]
|
||||
);
|
||||
};
|
||||
// Saving, starring, spawning: the action IS the feature, so there is no
|
||||
// look to preview first — a Lite tap names it and points at the unlock,
|
||||
// and nothing else happens.
|
||||
const promptProFeature = (feature: string) => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
Alert.alert(
|
||||
'PRO FEATURE',
|
||||
`${feature} needs PRO. Your own key unlocks it in SETTINGS.`,
|
||||
[
|
||||
{ text: 'LATER', style: 'cancel' },
|
||||
{ text: 'SETTINGS', onPress: () => setSettingsVisible(true) },
|
||||
]
|
||||
);
|
||||
};
|
||||
// Settings unlock: a valid key persists PRO and returns true.
|
||||
const handleUnlock = async (key: string): Promise<boolean> => {
|
||||
const ok = await activatePro(key);
|
||||
if (ok) {
|
||||
setPro(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
}
|
||||
return ok;
|
||||
};
|
||||
|
||||
// Watermark handed to the export engines (Skia + native): the ACCEPTED mark
|
||||
// (not the live draft), null while disabled or empty, else trimmed text +
|
||||
// position. Trimming here keeps the stamped text identical to what the
|
||||
@@ -953,7 +1024,11 @@ export default function App() {
|
||||
size?: number;
|
||||
font?: string | null;
|
||||
} | null =>
|
||||
customWmApplied.enabled && customWmApplied.text.trim().length > 0
|
||||
// Lite: the user's own mark can never reach the file (that look is gated at
|
||||
// the shutter); every Lite export carries the RECIPESCAM mark instead.
|
||||
!pro
|
||||
? LITE_MARK
|
||||
: customWmApplied.enabled && customWmApplied.text.trim().length > 0
|
||||
? {
|
||||
text: customWmApplied.text.trim(),
|
||||
x: customWmApplied.x,
|
||||
@@ -967,6 +1042,13 @@ export default function App() {
|
||||
const handleCapture = async () => {
|
||||
if (isProcessing) return;
|
||||
|
||||
// A PRO look can be composed but not rendered: stop before the shutter, so
|
||||
// no capture, no queue entry and no cache file is produced.
|
||||
if (proLook) {
|
||||
promptPro(proLook);
|
||||
return;
|
||||
}
|
||||
|
||||
// Camera mode (Phase 4, option A): real capture through vision-camera.
|
||||
if (mode === 'camera') {
|
||||
if (!cameraPermissionGranted) {
|
||||
@@ -1051,7 +1133,7 @@ export default function App() {
|
||||
// opening: save the same crop the preview showed (null = 1x).
|
||||
frameWindowZoom: frameZoom,
|
||||
};
|
||||
return NATIVE_EXPORT
|
||||
return nativeExport
|
||||
? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options)
|
||||
: processAndExportPhoto(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options);
|
||||
};
|
||||
@@ -1118,7 +1200,7 @@ export default function App() {
|
||||
? { cropRect: ratioCrop }
|
||||
: { cropWH: CROP_W_H[cropRatio] };
|
||||
|
||||
const result = NATIVE_EXPORT && !cropOptions.cropRect
|
||||
const result = nativeExport && !cropOptions.cropRect
|
||||
? await processAndExportPhotoNative(
|
||||
libraryImageUri,
|
||||
{
|
||||
@@ -1300,6 +1382,10 @@ export default function App() {
|
||||
// parameter row — otherwise a stale slider row (and its image-drag target)
|
||||
// would survive into the next panel.
|
||||
const handleToggleTab = (tab: TabId) => {
|
||||
if (tab === 'favorites' && !pro) {
|
||||
promptProFeature('The FAVORITED tab');
|
||||
return;
|
||||
}
|
||||
setOpenTab((prev) => (prev === tab ? null : tab));
|
||||
setOpenParamKey(null);
|
||||
};
|
||||
@@ -1498,6 +1584,8 @@ export default function App() {
|
||||
onGpsEnabled={handleToggleGps}
|
||||
startupMode={startupMode}
|
||||
onStartupMode={handleStartupMode}
|
||||
pro={pro}
|
||||
onUnlock={handleUnlock}
|
||||
/>
|
||||
|
||||
{/* Session photo viewer (swipe across captured/exported photos) */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, Modal, TouchableOpacity } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, Keyboard, Modal, ScrollView, TouchableOpacity, TextInput } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { X, Volume2, Info, ChevronRight, MapPin, Settings as SettingsIcon } from 'lucide-react-native';
|
||||
import { AspectRatio, MeterMode, ShutterSound, StartupMode } from '../types';
|
||||
@@ -35,6 +35,11 @@ interface SettingsModalProps {
|
||||
onGpsEnabled: (enabled: boolean) => void;
|
||||
startupMode: StartupMode;
|
||||
onStartupMode: (mode: StartupMode) => void;
|
||||
// Lite vs PRO. The unlock key is verified in App (the entitlement module owns
|
||||
// the maths) and comes back false for a bad key, which is all this sheet needs
|
||||
// to know — let alone store.
|
||||
pro: boolean;
|
||||
onUnlock: (key: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function ChipRow({ chips }: { chips: Chip[] }) {
|
||||
@@ -94,20 +99,40 @@ export default function SettingsModal({
|
||||
onGpsEnabled,
|
||||
startupMode,
|
||||
onStartupMode,
|
||||
pro,
|
||||
onUnlock,
|
||||
}: SettingsModalProps) {
|
||||
const [showCredits, setShowCredits] = useState(false);
|
||||
const [keyDraft, setKeyDraft] = useState('');
|
||||
const [keyError, setKeyError] = useState(false);
|
||||
// The RN Modal is its own dialog window, so the IME does not resize it and
|
||||
// the card ends up under the keyboard. Track the keyboard height and lift the
|
||||
// card by that much instead.
|
||||
const [kb, setKb] = useState(0);
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener('keyboardDidShow', (e) => setKb(e.endCoordinates.height));
|
||||
const hide = Keyboard.addListener('keyboardDidHide', () => setKb(0));
|
||||
return () => {
|
||||
show.remove();
|
||||
hide.remove();
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-6">
|
||||
<View className="flex-1 bg-black/70 items-center justify-center px-6" style={{ paddingBottom: kb }}>
|
||||
<TouchableOpacity
|
||||
className="absolute inset-0"
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
accessibilityLabel="Close settings"
|
||||
/>
|
||||
<View
|
||||
className="w-full max-w-md rounded-2xl border border-zinc-800/70 p-5 pb-6 space-y-5"
|
||||
style={{ backgroundColor: 'rgba(8,8,10,0.97)' }}
|
||||
<ScrollView
|
||||
className="w-full max-w-md rounded-2xl border border-zinc-800/70"
|
||||
style={{ maxHeight: '100%', flexGrow: 0 }}
|
||||
contentContainerClassName="p-5 pb-6 space-y-5"
|
||||
contentContainerStyle={{ backgroundColor: 'rgba(8,8,10,0.97)' }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className="flex-row items-center justify-between px-1">
|
||||
<View className="flex-row items-center">
|
||||
@@ -213,6 +238,54 @@ export default function SettingsModal({
|
||||
<CameraOnlyNote cameraMode={cameraMode} />
|
||||
</View>
|
||||
|
||||
{/* Lite vs PRO. Every look is free to preview and edit; the key buys
|
||||
the export: no mark, and PRO presets/frames/watermarks allowed. */}
|
||||
<View>
|
||||
<SectionLabel>PRO</SectionLabel>
|
||||
<ChipRow
|
||||
chips={[
|
||||
{ key: 'plan', label: pro ? 'PRO ACTIVE' : 'LITE', active: pro, disabled: true, onPress: () => {} },
|
||||
]}
|
||||
/>
|
||||
{!pro && (
|
||||
<View className="flex-row items-center mt-2">
|
||||
<TextInput
|
||||
value={keyDraft}
|
||||
onChangeText={(t) => {
|
||||
setKeyDraft(t);
|
||||
setKeyError(false);
|
||||
}}
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
placeholderTextColor="#52525b"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect={false}
|
||||
className={`flex-1 rounded-md border bg-black/40 px-3 py-2 font-mono text-xs font-bold ${
|
||||
keyError ? 'border-red-500/70 text-red-400' : 'border-zinc-700 text-zinc-200'
|
||||
}`}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={async () => {
|
||||
haptic();
|
||||
const ok = await onUnlock(keyDraft);
|
||||
setKeyError(!ok);
|
||||
if (ok) setKeyDraft('');
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
className="ml-2 rounded-md border border-amber-500/70 bg-amber-500/15 px-3 py-2"
|
||||
>
|
||||
<Text className="font-mono text-[11px] font-bold text-amber-500">UNLOCK</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<Text className="text-zinc-600 font-mono text-[10px] mt-1.5 ml-1">
|
||||
{keyError
|
||||
? 'That key is not valid. Check all 16 characters.'
|
||||
: pro
|
||||
? 'Every look exports clean: no mark, no limits. Thanks!'
|
||||
: 'LITE is free forever. Any preset, frame and watermark can be previewed and edited, but a PRO look cannot be exported and every export carries the RECIPESCAM mark. Enter your key to lift both.'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* About & open-source credits */}
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
@@ -235,7 +308,7 @@ export default function SettingsModal({
|
||||
App info + third-party open-source licenses.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
<CreditsModal visible={showCredits} onClose={() => setShowCredits(false)} />
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { FrameId } from '../types';
|
||||
|
||||
// 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.
|
||||
|
||||
const PRO_KEY = '@camrecipe_pro:pro';
|
||||
|
||||
// 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'];
|
||||
|
||||
// Presets: the eight film SIMS are free (a base-filter matrix is the camera's
|
||||
// own look); a saved RECIPE — bundled or user-created — is a dialled-in look
|
||||
// and belongs to PRO. Ids are `sim-*` for sims, anything else for recipes.
|
||||
export const isFreeRecipe = (id: string | null | undefined): boolean =>
|
||||
!id || id.startsWith('sim-');
|
||||
|
||||
// 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',
|
||||
x: 0.55,
|
||||
y: 0.94,
|
||||
color: '#ffffff',
|
||||
size: 0.8,
|
||||
};
|
||||
|
||||
export interface Look {
|
||||
recipeId?: string | null;
|
||||
frame: FrameId;
|
||||
customWm: boolean;
|
||||
}
|
||||
|
||||
// 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';
|
||||
if (PRO_FRAMES.includes(look.frame)) return 'A PRO frame';
|
||||
if (look.customWm) return 'The custom watermark';
|
||||
return null;
|
||||
}
|
||||
|
||||
export const loadPro = async (): Promise<boolean> =>
|
||||
(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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Mint PRO unlock keys for RecipesCam. Dev-side only — never bundled.
|
||||
// node tools/keygen.mjs 5 mint 5 keys
|
||||
// node tools/keygen.mjs --check mint 3 keys and verify them (parity guard)
|
||||
// The key is 12 payload chars + 4 checksum chars, so any 12-char payload works.
|
||||
// Keep KEY_ALPHABET / KEY_SECRET / checksum identical to
|
||||
// src/utils/entitlement.ts — the app verifies with the same maths.
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
const KEY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const KEY_SECRET = 'recipes-cam-key-2026';
|
||||
|
||||
const checksum = (payload) => {
|
||||
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;
|
||||
};
|
||||
|
||||
const keyIsValid = (raw) => {
|
||||
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;
|
||||
};
|
||||
|
||||
const mint = () => {
|
||||
let payload = '';
|
||||
for (let i = 0; i < 12; i++) payload += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)];
|
||||
const bits = checksum(payload);
|
||||
let check = '';
|
||||
for (let i = 0; i < 4; i++) check += KEY_ALPHABET[(bits >>> (i * 5)) & 31];
|
||||
const s = payload + check;
|
||||
return s.replace(/(.{4})/g, '$1-').slice(0, -1);
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--check') {
|
||||
const keys = [mint(), mint(), mint()];
|
||||
for (const k of keys) {
|
||||
if (!keyIsValid(k)) throw new Error(`keygen/verify mismatch on ${k}`);
|
||||
if (keyIsValid(k.slice(0, -1) + (k.endsWith('A') ? 'B' : 'A'))) {
|
||||
throw new Error(`tampered key accepted: ${k}`);
|
||||
}
|
||||
}
|
||||
console.log('ok', keys.join(' '));
|
||||
} else {
|
||||
const n = Number(args[0]) || 1;
|
||||
for (let i = 0; i < n; i++) console.log(mint());
|
||||
}
|
||||
Reference in New Issue
Block a user