apple signing status|ensure: bundle id capabilities, a distribution certificate from a key made locally (kept in .storebound/signing, never printed), App Store profiles installed for Xcode; Developer ID 403 explained. apple build / apple upload: xcodebuild archive + export with a generated ExportOptions.plist, next build number, App Store Connect API build upload (or altool), processing wait. Native, Capacitor, React Native, Flutter, Unity projects. play build / play upload: Gradle bundleRelease with the upload key passed through the environment, next versionCode read back from the AAB, testing tracks only. originality: offline Guideline 4.3 check (name, listing text, icon and screenshot perceptual hashes, project structure). New skill build-and-sign; zero new dependencies.
267 lines
16 KiB
TypeScript
267 lines
16 KiB
TypeScript
// Originality check against the owner's other apps (App Store Guideline 4.3 "spam", Google Play repetitive content):
|
|
// listing text, icon, screenshots and project structure of this app vs each other app, offline. Every axis is a
|
|
// 0-1 likeness (1 = the same), the overall score weighs the axes that both apps have, and a pair is flagged as a
|
|
// near-duplicate above the threshold or when one axis alone is nearly identical. Suggestions follow the
|
|
// portfolio-originality skill: deepen one axis until it is clearly different, or merge the idea into the other app.
|
|
import { createHash } from 'node:crypto';
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
import type { ShipConfig } from './config';
|
|
import { parseAppStoreListing, parsePlayListing } from './copy';
|
|
import { hashFile, type ImageHash, imageSimilarity } from './imagehash';
|
|
import { findIcon } from './project-files';
|
|
|
|
export interface OriginalityConfig {
|
|
/** Other apps: folders with a storebound.config.ts, or workspace folders holding several (one level down). */
|
|
against?: string[];
|
|
/** Overall score from which a pair counts as a near-duplicate (0-1). Default 0.7. */
|
|
threshold?: number;
|
|
}
|
|
|
|
export function validateOriginality(o: OriginalityConfig | undefined): string[] {
|
|
if (!o) return [];
|
|
const e: string[] = [];
|
|
if (o.against && (!Array.isArray(o.against) || o.against.some(x => typeof x !== 'string' || !x))) e.push('originality.against: a list of folders');
|
|
if (o.threshold !== undefined && !(o.threshold > 0 && o.threshold < 1)) e.push('originality.threshold: between 0 and 1 (e.g. 0.7)');
|
|
return e;
|
|
}
|
|
|
|
export const DEFAULT_THRESHOLD = 0.7;
|
|
/** One axis alone at or above this likeness flags the pair too. */
|
|
export const AXIS_FLAG = { text: 0.8, name: 0.85, icon: 0.85, screenshots: 0.8, structure: 0.85 } as const;
|
|
const WEIGHTS = { text: 0.3, icon: 0.2, screenshots: 0.3, structure: 0.2 } as const;
|
|
|
|
// ---- text ----
|
|
|
|
const STOP = new Set(('a an and are as at be but by for from has have in is it its of on or our so that the their them this to us was we were will '
|
|
+ 'with you your yours all any can more most new no not now one only just get got out up very than then there these those into over each '
|
|
+ 'app apps game games play free').split(' '));
|
|
|
|
export const words = (s: string): string[] => (s.toLowerCase().normalize('NFKC').match(/[\p{L}\p{N}]+/gu) ?? []).filter(w => w.length > 1 && !STOP.has(w));
|
|
|
|
function cosine(a: string[], b: string[]): number {
|
|
if (!a.length || !b.length) return 0;
|
|
const ta = new Map<string, number>(), tb = new Map<string, number>();
|
|
for (const w of a) ta.set(w, (ta.get(w) ?? 0) + 1);
|
|
for (const w of b) tb.set(w, (tb.get(w) ?? 0) + 1);
|
|
let dot = 0, na = 0, nb = 0;
|
|
for (const [w, n] of ta) { na += n * n; dot += n * (tb.get(w) ?? 0); }
|
|
for (const n of tb.values()) nb += n * n;
|
|
return dot / Math.sqrt(na * nb);
|
|
}
|
|
|
|
const shingles = (w: string[], k = 3): Set<string> => { const s = new Set<string>(); for (let i = 0; i + k <= w.length; i++) s.add(w.slice(i, i + k).join(' ')); return s; };
|
|
|
|
export function jaccard<T>(a: Set<T>, b: Set<T>): number {
|
|
if (!a.size && !b.size) return 0;
|
|
let n = 0;
|
|
for (const x of a) if (b.has(x)) n++;
|
|
return n / (a.size + b.size - n);
|
|
}
|
|
|
|
/** Bigram (character pair) Dice coefficient of two short strings (app names). */
|
|
export function nameSimilarity(a: string, b: string): number {
|
|
const norm = (s: string) => s.toLowerCase().normalize('NFKC').replace(/[^\p{L}\p{N}]+/gu, '');
|
|
const x = norm(a), y = norm(b);
|
|
if (!x || !y) return 0;
|
|
if (x === y) return 1;
|
|
const bi = (s: string) => { const m = new Map<string, number>(); for (let i = 0; i < s.length - 1; i++) { const k = s.slice(i, i + 2); m.set(k, (m.get(k) ?? 0) + 1); } return m; };
|
|
const A = bi(x), B = bi(y);
|
|
let inter = 0;
|
|
for (const [k, n] of A) inter += Math.min(n, B.get(k) ?? 0);
|
|
const total = Math.max(1, x.length - 1 + y.length - 1);
|
|
return (2 * inter) / total;
|
|
}
|
|
|
|
/** Listing text likeness: word frequency cosine (same promises and vocabulary) and 3-word shingle overlap (copied
|
|
* sentences), 0.6 / 0.4. */
|
|
export function textSimilarity(a: string, b: string): { score: number; cosine: number; shingles: number } {
|
|
const wa = words(a), wb = words(b);
|
|
const c = cosine(wa, wb), s = jaccard(shingles(wa), shingles(wb));
|
|
return { score: 0.6 * c + 0.4 * s, cosine: c, shingles: s };
|
|
}
|
|
|
|
// ---- fingerprint ----
|
|
|
|
export interface AppFingerprint {
|
|
name: string;
|
|
dir: string;
|
|
ids: string[];
|
|
/** Listing text of the primary language (name, subtitle, keywords, descriptions). */
|
|
text: string;
|
|
icon: ImageHash | null;
|
|
shots: ImageHash[];
|
|
/** Relative file paths (folder layout) and content hashes of the project's own files. */
|
|
paths: Set<string>;
|
|
contents: Set<string>;
|
|
notes: string[];
|
|
}
|
|
|
|
const SKIP = new Set(['node_modules', '.git', 'build', 'dist', 'Pods', 'DerivedData', '.gradle', '.storebound', '.dart_tool', 'Library', 'Temp', 'Logs',
|
|
'obj', '.idea', '.vscode', 'renders', '.next', '.expo', 'coverage', 'www', 'public']);
|
|
export const MAX_FILES = 20_000;
|
|
const MAX_HASH_BYTES = 4_000_000;
|
|
|
|
/** Folder layout and content hashes of a project, bounded (MAX_FILES). Store renders and build output are left out:
|
|
* screenshots have their own axis, build output is not the developer's work. */
|
|
export function projectStructure(root: string): { paths: Set<string>; contents: Set<string>; truncated: boolean } {
|
|
const paths = new Set<string>(), contents = new Set<string>();
|
|
let count = 0, truncated = false;
|
|
const walk = (d: string): void => {
|
|
let names: string[];
|
|
try { names = readdirSync(d).sort(); } catch { return; }
|
|
for (const n of names) {
|
|
if (count >= MAX_FILES) { truncated = true; return; }
|
|
if (n.startsWith('.') && n !== '.github') continue;
|
|
const p = join(d, n);
|
|
let st;
|
|
try { st = statSync(p); } catch { continue; }
|
|
if (st.isDirectory()) { if (!SKIP.has(n) && !n.endsWith('.xcarchive')) walk(p); continue; }
|
|
count++;
|
|
// names that carry the app's identity (bundle id folders, app names) are normalized away by keeping the extension and depth
|
|
paths.add(relative(root, p).split(/[\\/]/).map((seg, i, all) => (i === all.length - 1 ? seg.toLowerCase() : seg.toLowerCase().replace(/[^a-z]+/g, '-'))).join('/'));
|
|
if (st.size > 0 && st.size <= MAX_HASH_BYTES) { try { contents.add(createHash('md5').update(readFileSync(p)).digest('hex')); } catch { /* unreadable */ } }
|
|
}
|
|
};
|
|
walk(root);
|
|
return { paths, contents, truncated };
|
|
}
|
|
|
|
function primaryText(config: ShipConfig, root: string, notes: string[]): string {
|
|
const parts: string[] = [config.app.name];
|
|
const read = (rel: string): string | null => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } };
|
|
if (config.apple) {
|
|
const md = read(config.apple.listing ?? 'store/appstore.md');
|
|
if (md) try {
|
|
const all = parseAppStoreListing(md);
|
|
const loc = config.apple.primaryLocale ?? (all['en-US'] ? 'en-US' : Object.keys(all)[0]);
|
|
const c = loc ? all[loc] : undefined;
|
|
if (c) parts.push(c.name ?? '', c.subtitle ?? '', (c.keywords ?? '').replace(/,/g, ' '), c.promotionalText ?? '', c.description ?? '');
|
|
} catch (e) { notes.push(`App Store listing not readable: ${(e as Error).message.slice(0, 120)}`); }
|
|
else notes.push('no App Store listing file');
|
|
}
|
|
if (config.play) {
|
|
const md = read(config.play.listing ?? 'store/listing.md');
|
|
if (md) try {
|
|
const all = parsePlayListing(md, config.app.name);
|
|
const c = all.find(x => x.lang === (config.play!.defaultLanguage ?? 'en-US')) ?? all[0];
|
|
if (c) parts.push(c.title, c.short, c.full);
|
|
} catch (e) { notes.push(`Play listing not readable: ${(e as Error).message.slice(0, 120)}`); }
|
|
else notes.push('no Play listing file');
|
|
}
|
|
return parts.filter(Boolean).join('\n');
|
|
}
|
|
|
|
function screenshotFiles(config: ShipConfig, root: string): string[] {
|
|
const dir = join(root, config.renders ?? config.shots?.out ?? 'store/renders');
|
|
if (!existsSync(dir)) return [];
|
|
const langs = readdirSync(dir).filter(n => { try { return statSync(join(dir, n)).isDirectory(); } catch { return false; } });
|
|
const lang = langs.find(l => l === config.languages[0]?.id) ?? langs.find(l => l !== 'feature') ?? null;
|
|
if (!lang) return [];
|
|
const sizes = readdirSync(join(dir, lang)).map(s => join(dir, lang, s)).filter(p => { try { return statSync(p).isDirectory(); } catch { return false; } });
|
|
const imgs = (d: string) => readdirSync(d).filter(f => /\.(png|jpe?g|webp)$/i.test(f) && !/contact|sheet|index/i.test(f)).sort().map(f => join(d, f));
|
|
const best = sizes.map(imgs).sort((a, b) => b.length - a.length)[0] ?? imgs(join(dir, lang));
|
|
return best.slice(0, 10);
|
|
}
|
|
|
|
/** Everything the comparison needs from one app's folder and config. */
|
|
export function fingerprint(config: ShipConfig, root: string): AppFingerprint {
|
|
const notes: string[] = [];
|
|
const iconPath = findIcon(config, root);
|
|
let icon: ImageHash | null = null;
|
|
if (iconPath) { const h = hashFile(iconPath); icon = h.hash; if (!h.hash) notes.push(`icon not hashed: ${h.reason}`); } else notes.push('no icon found');
|
|
const files = screenshotFiles(config, root);
|
|
const shots: ImageHash[] = [];
|
|
for (const f of files) { const h = hashFile(f); if (h.hash) shots.push(h.hash); else notes.push(`${relative(root, f)}: ${h.reason}`); }
|
|
if (!files.length) notes.push('no rendered screenshots (shots render)');
|
|
const st = projectStructure(root);
|
|
if (st.truncated) notes.push(`project structure: first ${MAX_FILES} files only`);
|
|
return { name: config.app.name, dir: root, ids: [config.apple?.bundleId, config.play?.packageName].filter((x): x is string => !!x),
|
|
text: primaryText(config, root, notes), icon, shots, paths: st.paths, contents: st.contents, notes };
|
|
}
|
|
|
|
// ---- comparison ----
|
|
|
|
/** Image likeness on a 0-1 scale where unrelated pictures land near 0 (raw hash similarity of unrelated images is ~0.5). */
|
|
export const imageLikeness = (a: ImageHash, b: ImageHash): number => Math.max(0, (imageSimilarity(a, b) - 0.5) / 0.5);
|
|
|
|
/** Mean of each screenshot's best match in the other set, both directions. */
|
|
export function screenshotSimilarity(a: ImageHash[], b: ImageHash[]): number | null {
|
|
if (!a.length || !b.length) return null;
|
|
const best = (x: ImageHash[], y: ImageHash[]) => x.reduce((s, h) => s + Math.max(...y.map(k => imageLikeness(h, k))), 0) / x.length;
|
|
return (best(a, b) + best(b, a)) / 2;
|
|
}
|
|
|
|
export interface Axis { score: number | null; detail: string }
|
|
export interface PairResult {
|
|
other: { name: string; dir: string; ids: string[] };
|
|
axes: { name: Axis; text: Axis; icon: Axis; screenshots: Axis; structure: Axis };
|
|
overall: number;
|
|
flagged: boolean;
|
|
reasons: string[];
|
|
suggestions: string[];
|
|
}
|
|
|
|
const r2 = (x: number): number => Math.round(x * 100) / 100;
|
|
|
|
export function compare(a: AppFingerprint, b: AppFingerprint, threshold = DEFAULT_THRESHOLD): PairResult {
|
|
const name = nameSimilarity(a.name, b.name);
|
|
const t = a.text && b.text ? textSimilarity(a.text, b.text) : null;
|
|
const icon = a.icon && b.icon ? imageLikeness(a.icon, b.icon) : null;
|
|
const shots = screenshotSimilarity(a.shots, b.shots);
|
|
const layout = a.paths.size && b.paths.size ? jaccard(a.paths, b.paths) : null;
|
|
const content = a.contents.size && b.contents.size ? jaccard(a.contents, b.contents) : null;
|
|
const structure = layout === null || content === null ? null : 0.4 * layout + 0.6 * content;
|
|
const axes: PairResult['axes'] = {
|
|
name: { score: r2(name), detail: `"${a.name}" vs "${b.name}"` },
|
|
text: { score: t ? r2(t.score) : null, detail: t ? `vocabulary ${Math.round(t.cosine * 100)}%, copied phrases ${Math.round(t.shingles * 100)}%` : 'no listing text on one side' },
|
|
icon: { score: icon === null ? null : r2(icon), detail: icon === null ? 'no icon on one side' : `perceptual hash likeness ${Math.round(icon * 100)}%` },
|
|
screenshots: { score: shots === null ? null : r2(shots), detail: shots === null ? 'no screenshots on one side' : `${a.shots.length} vs ${b.shots.length} screenshots, best-match likeness ${Math.round(shots * 100)}%` },
|
|
structure: { score: structure === null ? null : r2(structure), detail: structure === null ? 'no project files on one side' : `same file layout ${Math.round(layout! * 100)}%, identical files ${Math.round(content! * 100)}%` },
|
|
};
|
|
let wsum = 0, sum = 0;
|
|
for (const k of ['text', 'icon', 'screenshots', 'structure'] as const) { const s = axes[k].score; if (s !== null) { wsum += WEIGHTS[k]; sum += WEIGHTS[k] * s; } }
|
|
const overall = wsum ? r2(sum / wsum) : 0;
|
|
const reasons: string[] = [];
|
|
if (overall >= threshold) reasons.push(`overall ${overall} >= ${threshold}`);
|
|
for (const k of ['name', 'text', 'icon', 'screenshots', 'structure'] as const) { const s = axes[k].score; if (s !== null && s >= AXIS_FLAG[k]) reasons.push(`${k} ${s} (${axes[k].detail})`); }
|
|
return { other: { name: b.name, dir: b.dir, ids: b.ids }, axes, overall, flagged: reasons.length > 0, reasons, suggestions: suggest(axes, reasons.length > 0, overall, b.name) };
|
|
}
|
|
|
|
export function suggest(axes: PairResult['axes'], flagged: boolean, overall: number, other: string): string[] {
|
|
const s: string[] = [];
|
|
const hi = (k: keyof typeof axes, x: number) => (axes[k].score ?? 0) >= x;
|
|
if (hi('text', 0.6)) s.push(`Rewrite the listing around what is different from ${other} (another promise and audience), not synonyms: reviewers read both pages side by side`);
|
|
if (hi('name', 0.7)) s.push(`Pick a name that does not read as a variant of "${other}"`);
|
|
if (hi('icon', 0.7)) s.push('Make an icon with its own shape and subject, not a recolour of the other icon');
|
|
if (hi('screenshots', 0.6)) s.push('Show different scenes and messages in the screenshots: the first screenshot should show what this app does that the other cannot');
|
|
if (hi('structure', 0.6)) s.push(`Most project files are shared with ${other}: a template or reskin. Apple reviewers compare binaries; deepen one axis (core mechanic, progression, main task) until a user sees it in the first minute`);
|
|
if (flagged) {
|
|
s.push(`Write the differentiator: "Unlike ${other}, in this app the user <does something they cannot do there>". If it needs "theme", "skin", "colours" or "levels", it is not one`);
|
|
if (overall >= 0.85) s.push(`Consider merging this into ${other} as a mode, world or event instead of a separate app (Guideline 4.3: fewer, richer apps)`);
|
|
}
|
|
return s;
|
|
}
|
|
|
|
export interface OriginalityReport { app: { name: string; dir: string; ids: string[]; notes: string[] }; threshold: number; pairs: PairResult[]; flagged: number; others: { dir: string; notes: string[] }[] }
|
|
|
|
export function report(self: AppFingerprint, others: AppFingerprint[], threshold = DEFAULT_THRESHOLD): OriginalityReport {
|
|
const pairs = others.map(o => compare(self, o, threshold)).sort((x, y) => y.overall - x.overall);
|
|
return { app: { name: self.name, dir: self.dir, ids: self.ids, notes: self.notes }, threshold, pairs, flagged: pairs.filter(p => p.flagged).length,
|
|
others: others.map(o => ({ dir: o.dir, notes: o.notes })) };
|
|
}
|
|
|
|
/** Config files of the apps to compare with: each folder itself when it holds a storebound config, else its
|
|
* sub-folders that do (a workspace). `self` (this app's folder) is left out. */
|
|
export function findApps(folders: string[], self: string, configNames = ['storebound.config.ts', 'storebound.config.js', 'storebound.config.json']): string[] {
|
|
const cfg = (d: string) => configNames.map(n => join(d, n)).find(existsSync) ?? null;
|
|
const out: string[] = [];
|
|
for (const f of folders) {
|
|
const own = cfg(f);
|
|
if (own) { out.push(own); continue; }
|
|
let subs: string[] = [];
|
|
try { subs = readdirSync(f).map(n => join(f, n)).filter(p => { try { return statSync(p).isDirectory(); } catch { return false; } }); } catch { /* missing */ }
|
|
for (const s of subs.sort()) { const c = cfg(s); if (c) out.push(c); }
|
|
}
|
|
return [...new Set(out)].filter(p => !p.startsWith(join(self, 'storebound.config')));
|
|
}
|