diff --git a/README.md b/README.md index 914d0e3..6477695 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,26 @@ docker compose up -d --build App runs on `http://localhost:3000`. +### Mobile App (Expo, iOS-first, TestFlight-ready) + +PitchCheck includes a production-oriented Expo mobile client in `mobile/` with the same dark design language as desktop and native runtime controls: + +- **PitchServer** runtime +- **Vast AI** runtime +- Transport mode toggle (`auto`, `next-api`, `direct`) +- Runtime health probing before scoring +- Secure local credential storage (`expo-secure-store`) +- EAS build profiles for development/preview/production and iOS submit scripts + +Run it: + +```bash +npm run mobile:install +npm run mobile:start +``` + +For App Store release flow, see `mobile/README.md` (`build:ios`, `submit:ios`) and configure `mobile/.env` from `.env.example`. + ### One-Line Install ```bash diff --git a/mobile/.env.example b/mobile/.env.example new file mode 100644 index 0000000..336764a --- /dev/null +++ b/mobile/.env.example @@ -0,0 +1,6 @@ +EXPO_PUBLIC_EAS_PROJECT_ID= +EXPO_PUBLIC_IOS_BUNDLE_ID=com.pitchcheck.mobile +EXPO_PUBLIC_IOS_BUILD_NUMBER=1 +EXPO_PUBLIC_ANDROID_PACKAGE=com.pitchcheck.mobile +EXPO_PUBLIC_ANDROID_VERSION_CODE=1 +EXPO_PUBLIC_RELEASE_CHANNEL=development diff --git a/mobile/App.tsx b/mobile/App.tsx new file mode 100644 index 0000000..2f650c9 --- /dev/null +++ b/mobile/App.tsx @@ -0,0 +1,371 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform as NativePlatform, + Pressable, + ScrollView, + StatusBar, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { LinearGradient } from "expo-linear-gradient"; +import * as Clipboard from "expo-clipboard"; +import Constants from "expo-constants"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { isProbablyHttpUrl } from "./src/network"; +import { defaultSettings, loadSettings, probeRuntime, saveSettings, scorePitch } from "./src/runtime"; +import { clearPendingDraft, loadPendingDraft, savePendingDraft } from "./src/draft-queue"; +import { getRuntimeEvents } from "./src/telemetry"; +import { platformValues, RuntimeKind, RuntimeProbe, RuntimeSettings, type PitchScoreReport, type Platform, type TransportMode } from "./src/types"; +import { theme } from "./src/theme"; + +type HistoryItem = { at: string; score: number; verdict: string }; +type PendingScoreRequest = { message: string; persona: string; platform: Platform }; + +const platformLabels: Record = { + email: "Email", + linkedin: "LinkedIn", + "cold-call-script": "Cold Call", + "landing-page": "Landing Page", + "ad-copy": "Ad Copy", + general: "General", +}; + +const transportLabels: Record = { + auto: "Auto", + "next-api": "Next API", + direct: "Direct", +}; + +export default function App() { + const [settings, setSettings] = useState(defaultSettings); + const [message, setMessage] = useState(""); + const [persona, setPersona] = useState(""); + const [platform, setPlatform] = useState("email"); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [probe, setProbe] = useState(null); + const [probing, setProbing] = useState(false); + const [report, setReport] = useState(null); + const [history, setHistory] = useState([]); + const [queuedRequest, setQueuedRequest] = useState(null); + const queuedRequestRef = useRef(null); + const strictTransportRequired = Boolean((Constants.expoConfig?.extra as { strictTransportRequired?: boolean } | undefined)?.strictTransportRequired); + + useEffect(() => { + loadSettings() + .then((loaded) => { + const next = strictTransportRequired ? { ...loaded, strictTransportSecurity: true } : loaded; + setSettings(next); + }) + .catch(() => undefined); + + loadPendingDraft() + .then((draft) => { + if (!draft) return; + setQueuedRequest({ message: draft.message, persona: draft.persona, platform: draft.platform }); + }) + .catch(() => undefined); + }, [strictTransportRequired]); + + + const runtimeLabel = settings.runtime === "pitchserver" ? "PitchServer" : "Vast AI"; + const activeRuntimeUrl = settings.runtime === "pitchserver" ? settings.pitchserverUrl : settings.vastUrl; + const insecureHttpWarning = + NativePlatform.OS === "ios" && + settings.strictTransportSecurity && + activeRuntimeUrl.startsWith("http://") && + !activeRuntimeUrl.includes("127.0.0.1") && + !activeRuntimeUrl.includes("localhost"); + + const scoreColor = useMemo(() => { + const score = report?.persuasion_score ?? 0; + if (score >= 75) return theme.ok; + if (score >= 50) return theme.warn; + return theme.err; + }, [report]); + + const canScore = message.trim().length >= 10 && persona.trim().length >= 5; + + async function patchSettings(patch: Partial) { + const next = { ...settings, ...patch }; + if (strictTransportRequired) next.strictTransportSecurity = true; + setSettings(next); + setProbe(null); + await saveSettings(next); + } + + async function onScore(override?: PendingScoreRequest) { + const request = override ?? { message: message.trim(), persona: persona.trim(), platform }; + + if (request.message.length < 10 || request.persona.length < 5) { + setError("Pitch must be at least 10 chars and persona at least 5 chars."); + return; + } + + if (loading && !override) { + queuedRequestRef.current = request; + setQueuedRequest(request); + void savePendingDraft({ ...request, queuedAt: new Date().toISOString() }); + setError("Current scoring continues. Your latest draft is queued."); + return; + } + + setLoading(true); + setError(null); + try { + const next = await scorePitch(settings, request.message, request.persona, request.platform); + setReport(next); + setHistory((prev) => [{ at: next.scored_at, score: next.persuasion_score, verdict: next.verdict }, ...prev].slice(0, 5)); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Unexpected error."); + } finally { + setLoading(false); + if (!override && queuedRequestRef.current) { + const nextRequest = queuedRequestRef.current; + queuedRequestRef.current = null; + setQueuedRequest(null); + void clearPendingDraft(); + void onScore(nextRequest); + } + } + } + + async function runProbe() { + setProbing(true); + setError(null); + try { + setProbe(await probeRuntime(settings)); + } catch (caught) { + setProbe(null); + setError(caught instanceof Error ? caught.message : "Runtime check failed."); + } finally { + setProbing(false); + } + } + + + async function exportTelemetry() { + const events = getRuntimeEvents(); + await Clipboard.setStringAsync(JSON.stringify(events, null, 2)); + setError(`Telemetry copied (${events.length} events).`); + } + + return ( + + + + + + PITCHCHECK · MOBILE + Neural Persuasion Studio + Premium iOS-first experience with production runtime controls for PitchServer and Vast AI. + + + + + Runtime + {runtimeLabel} + + + + void patchSettings({ runtime: "pitchserver" as RuntimeKind })} /> + void patchSettings({ runtime: "vast" as RuntimeKind })} /> + + + {settings.runtime === "pitchserver" ? ( + void patchSettings({ pitchserverUrl: v })} /> + ) : ( + <> + void patchSettings({ vastUrl: v })} /> + void patchSettings({ vastApiKey: v })} /> + + )} + + {!isProbablyHttpUrl(activeRuntimeUrl) && activeRuntimeUrl.length > 0 && Runtime URL should be a valid http(s) URL.} + {insecureHttpWarning && Strict mode is enabled: use HTTPS for non-local iOS production runtimes.} + {strictTransportRequired && Release profile enforces strict HTTPS policy.} + + + Strict HTTPS policy + void patchSettings({ strictTransportSecurity: !settings.strictTransportSecurity })} + disabled={strictTransportRequired} + > + {settings.strictTransportSecurity ? "ON" : "OFF"} + + + + void patchSettings({ openRouterModel: v })} /> + + + {(Object.keys(transportLabels) as TransportMode[]).map((mode) => ( + void patchSettings({ transportMode: mode })} /> + ))} + + + void runProbe()} disabled={probing}> + {probing ? "Checking runtime…" : "Check Runtime"} + + {probe && {probe.detail} {probe.endpointTried ? `(${probe.endpointTried})` : ""}} + + void exportTelemetry()}> + Export Runtime Logs + + + + + Pitch Analyzer + + + + + {platformValues.map((item) => ( + setPlatform(item)}> + {platformLabels[item]} + + ))} + + + void onScore()} disabled={loading || !canScore}> + + {loading ? : Score My Pitch} + + + {error ? {error} : null} + {queuedRequest ? Queued draft will run after current request. : null} + + + {report && ( + + Result + + {report.persuasion_score} + + {report.verdict} + {report.narrative} + {new Date(report.scored_at).toLocaleString()} + + + + + + + void Clipboard.setStringAsync(JSON.stringify(report, null, 2))}> + Copy JSON + + + )} + + {history.length > 0 && ( + + Recent Scores + {history.map((item, index) => ( + + {item.score} + + {item.verdict} + {new Date(item.at).toLocaleString()} + + + ))} + + )} + + + + ); +} + +function SegmentButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + return ( + + {label} + + ); +} + +function Field({ label, value, placeholder, onChange, multiline, secure, tall }: { label: string; value: string; placeholder: string; onChange: (next: string) => void; multiline?: boolean; secure?: boolean; tall?: boolean }) { + return ( + + {label} + + + ); +} + +function List({ title, items }: { title: string; items: string[] }) { + return ( + + {title} + {items.slice(0, 3).map((item, idx) => ( + {`• ${item}`} + ))} + + ); +} + +const styles = StyleSheet.create({ + safe: { flex: 1, backgroundColor: theme.bgApp }, + root: { flex: 1 }, + content: { padding: 16, paddingBottom: 40, gap: 14 }, + header: { borderWidth: 1, borderColor: theme.line, borderRadius: 18, padding: 16 }, + kicker: { color: theme.fgDim, fontSize: 11, letterSpacing: 1.2, marginBottom: 8 }, + title: { color: theme.fg, fontSize: 27, fontWeight: "700", marginBottom: 6 }, + subtitle: { color: theme.fgMuted, fontSize: 13, lineHeight: 18 }, + card: { backgroundColor: theme.bgPanel, borderRadius: 16, borderWidth: 1, borderColor: theme.line, padding: 14 }, + inlineHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }, + runtimePill: { backgroundColor: theme.bgElevated, borderColor: theme.lineStrong, borderWidth: 1, color: theme.fgMuted, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4, fontSize: 11 }, + sectionTitle: { color: theme.fg, fontWeight: "700", fontSize: 16, marginBottom: 10 }, + label: { color: theme.fgMuted, fontSize: 12, marginBottom: 6 }, + input: { borderColor: theme.lineStrong, borderWidth: 1, borderRadius: 12, backgroundColor: theme.bgElevated, color: theme.fg, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14 }, + multiline: { minHeight: 82, textAlignVertical: "top" }, + tall: { minHeight: 140 }, + segmented: { flexDirection: "row", gap: 8, marginBottom: 10 }, + segmentButton: { flex: 1, borderColor: theme.lineStrong, borderWidth: 1, borderRadius: 11, paddingVertical: 9, alignItems: "center", backgroundColor: theme.bgElevated }, + segmentButtonActive: { backgroundColor: "rgba(106,231,178,0.16)", borderColor: "rgba(106,231,178,0.52)" }, + segmentText: { color: theme.fgMuted, fontSize: 12, fontWeight: "600" }, + segmentTextActive: { color: theme.ok }, + secondaryButton: { borderColor: theme.lineStrong, borderWidth: 1, borderRadius: 10, paddingVertical: 10, alignItems: "center", marginTop: 2 }, + secondaryButtonText: { color: theme.fgMuted, fontWeight: "600" }, + probeText: { fontSize: 12, marginTop: 8, lineHeight: 16 }, + warningText: { color: theme.warn, fontSize: 12, marginTop: 2, marginBottom: 8 }, + inlineToggleRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }, + toggleLabel: { color: theme.fgMuted, fontSize: 12 }, + togglePill: { borderWidth: 1, borderColor: theme.lineStrong, borderRadius: 999, paddingHorizontal: 10, paddingVertical: 4, backgroundColor: theme.bgElevated }, + togglePillActive: { borderColor: "rgba(106,231,178,0.52)", backgroundColor: "rgba(106,231,178,0.16)" }, + togglePillText: { color: theme.fgMuted, fontSize: 11, fontWeight: "700" }, + togglePillTextActive: { color: theme.ok }, + toggleDisabled: { opacity: 0.5 }, + platformRow: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginVertical: 10 }, + chip: { borderColor: theme.lineStrong, borderWidth: 1, borderRadius: 999, paddingHorizontal: 11, paddingVertical: 7, backgroundColor: theme.bgElevated }, + chipActive: { backgroundColor: "rgba(106,231,178,0.16)", borderColor: "rgba(106,231,178,0.52)" }, + chipText: { color: theme.fgMuted, fontSize: 12 }, + chipTextActive: { color: theme.ok }, + scoreButton: { marginTop: 6, borderRadius: 12, overflow: "hidden" }, + gradientBtn: { paddingVertical: 12, alignItems: "center" }, + scoreText: { color: "#031a12", fontSize: 14, fontWeight: "800" }, + error: { color: theme.err, fontSize: 12, marginTop: 8 }, + scoreRow: { flexDirection: "row", gap: 14, alignItems: "center" }, + bigScore: { fontSize: 44, fontWeight: "800", width: 72 }, + verdict: { color: theme.fg, fontSize: 16, fontWeight: "700", marginBottom: 4 }, + narrative: { color: theme.fgMuted, lineHeight: 18, fontSize: 13 }, + timestamp: { color: theme.fgDim, fontSize: 11, marginTop: 6 }, + listTitle: { color: theme.fg, fontWeight: "600", marginBottom: 4 }, + listItem: { color: theme.fgMuted, fontSize: 13, lineHeight: 18 }, + historyRow: { flexDirection: "row", gap: 10, alignItems: "center", paddingVertical: 6, borderBottomWidth: 1, borderBottomColor: theme.line }, + historyScore: { width: 44, textAlign: "center", color: theme.ok, fontSize: 20, fontWeight: "800" }, + historyVerdict: { color: theme.fg, fontSize: 13, fontWeight: "600" }, +}); diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..9b70fb6 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,46 @@ +# PitchCheck Mobile (Expo) + +iOS-first mobile client designed to be shippable through TestFlight and the App Store. + +## What is production-ready here +- Runtime switching for **PitchServer** and **Vast AI** +- Secure local storage for runtime credentials via `expo-secure-store` +- Transport compatibility modes: + - `auto` (`/api/score` then `/score`) + - `next-api` (`/api/score` only) + - `direct` (`/score` only) +- Runtime connectivity check (`/api/health` or `/health`) +- URL validation + request timeout/retry behavior for unstable mobile networks +- Score payload normalization + safe defaults for malformed backend responses +- Request ID + idempotency key headers for runtime observability/safety +- Optional strict HTTPS policy for non-local runtimes +- Production build profiles auto-enforce strict HTTPS policy +- In-app recent-score panel + pending draft queue persistence (SecureStore) +- Runtime telemetry export (JSON) for debugging/support +- EAS build profiles for development, preview, and production +- Dynamic app config (`app.config.ts`) for bundle IDs and build numbers via env vars + +## Setup + +```bash +cp .env.example .env +npm install +npm run start +``` + +## iOS release flow + +```bash +npm run build:ios +npm run submit:ios +``` + +## Runtime contract +Both runtime modes expect a compatible scoring service: +- `POST {baseUrl}/score` **or** `POST {baseUrl}/api/score` +- Body: `{ message, persona, platform, openRouterModel? }` + +Health checks: +- `GET {baseUrl}/health` **or** `GET {baseUrl}/api/health` + +Vast mode can optionally attach `Authorization: Bearer `. diff --git a/mobile/app.config.ts b/mobile/app.config.ts new file mode 100644 index 0000000..4394d5f --- /dev/null +++ b/mobile/app.config.ts @@ -0,0 +1,44 @@ +import type { ExpoConfig } from "expo/config"; + +const buildProfile = process.env.EAS_BUILD_PROFILE ?? process.env.EXPO_PUBLIC_RELEASE_CHANNEL ?? "development"; +const strictTransportRequired = buildProfile === "production"; + +const config: ExpoConfig = { + name: "PitchCheck", + slug: "pitchcheck-mobile", + scheme: "pitchcheck", + version: "1.0.0", + orientation: "portrait", + userInterfaceStyle: "dark", + icon: "../src-tauri/icons/icon.png", + ios: { + supportsTablet: true, + bundleIdentifier: process.env.EXPO_PUBLIC_IOS_BUNDLE_ID ?? "com.pitchcheck.mobile", + buildNumber: process.env.EXPO_PUBLIC_IOS_BUILD_NUMBER ?? "1", + infoPlist: { + ITSAppUsesNonExemptEncryption: false, + NSAppTransportSecurity: { + NSAllowsArbitraryLoads: false, + }, + }, + }, + android: { + package: process.env.EXPO_PUBLIC_ANDROID_PACKAGE ?? "com.pitchcheck.mobile", + versionCode: Number(process.env.EXPO_PUBLIC_ANDROID_VERSION_CODE ?? "1"), + }, + updates: { + fallbackToCacheTimeout: 0, + }, + runtimeVersion: { + policy: "appVersion", + }, + extra: { + eas: { + projectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID, + }, + buildProfile, + strictTransportRequired, + }, +}; + +export default config; diff --git a/mobile/babel.config.js b/mobile/babel.config.js new file mode 100644 index 0000000..0843853 --- /dev/null +++ b/mobile/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ["babel-preset-expo"], + }; +}; diff --git a/mobile/eas.json b/mobile/eas.json new file mode 100644 index 0000000..b76aabe --- /dev/null +++ b/mobile/eas.json @@ -0,0 +1,20 @@ +{ + "cli": { + "version": ">= 13.3.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..a6ae7e6 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,32 @@ +{ + "name": "pitchcheck-mobile", + "version": "0.1.0", + "private": true, + "main": "expo/AppEntry", + "scripts": { + "start": "expo start", + "ios": "expo run:ios", + "android": "expo run:android", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "doctor": "expo-doctor", + "build:ios": "eas build --platform ios --profile production", + "submit:ios": "eas submit --platform ios --profile production" + }, + "dependencies": { + "expo": "~53.0.12", + "expo-clipboard": "~7.0.1", + "expo-linear-gradient": "~14.0.2", + "expo-secure-store": "~14.0.1", + "react": "19.0.0", + "react-native": "0.79.5", + "react-native-safe-area-context": "5.4.0", + "react-native-svg": "15.11.2", + "expo-constants": "~17.0.8" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@types/react": "~19.0.10", + "typescript": "^5.6.3" + } +} diff --git a/mobile/src/draft-queue.ts b/mobile/src/draft-queue.ts new file mode 100644 index 0000000..e5c75d7 --- /dev/null +++ b/mobile/src/draft-queue.ts @@ -0,0 +1,54 @@ +import * as SecureStore from "expo-secure-store"; +import { Platform } from "./types"; + +const KEY = "pitchcheck-mobile-pending-score"; + +export type PendingScoreDraft = { + message: string; + persona: string; + platform: Platform; + queuedAt: string; +}; + +function isPlatform(value: unknown): value is Platform { + return ( + value === "email" || + value === "linkedin" || + value === "cold-call-script" || + value === "landing-page" || + value === "ad-copy" || + value === "general" + ); +} + +export async function loadPendingDraft(): Promise { + const raw = await SecureStore.getItemAsync(KEY); + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.message === "string" && + typeof parsed.persona === "string" && + isPlatform(parsed.platform) + ) { + return { + message: parsed.message, + persona: parsed.persona, + platform: parsed.platform, + queuedAt: typeof parsed.queuedAt === "string" ? parsed.queuedAt : new Date().toISOString(), + }; + } + return null; + } catch { + return null; + } +} + +export async function savePendingDraft(draft: PendingScoreDraft): Promise { + await SecureStore.setItemAsync(KEY, JSON.stringify(draft)); +} + +export async function clearPendingDraft(): Promise { + await SecureStore.deleteItemAsync(KEY); +} diff --git a/mobile/src/network.ts b/mobile/src/network.ts new file mode 100644 index 0000000..7b27730 --- /dev/null +++ b/mobile/src/network.ts @@ -0,0 +1,67 @@ +import { TransportMode } from "./types"; + +export function normalizeBaseUrl(raw: string): string { + return raw.trim().replace(/\/$/, ""); +} + +export function parseHttpUrl(raw: string): URL | null { + try { + const value = normalizeBaseUrl(raw); + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url; + } catch { + return null; + } +} + +export function isProbablyHttpUrl(raw: string): boolean { + return Boolean(parseHttpUrl(raw)); +} + +export function isLocalRuntimeUrl(raw: string): boolean { + const url = parseHttpUrl(raw); + if (!url) return false; + return url.hostname === "localhost" || url.hostname === "127.0.0.1"; +} + +export function scoreEndpoints(baseUrl: string, mode: TransportMode): string[] { + if (mode === "next-api") return [`${baseUrl}/api/score`]; + if (mode === "direct") return [`${baseUrl}/score`]; + return [`${baseUrl}/api/score`, `${baseUrl}/score`]; +} + +export function healthEndpoints(baseUrl: string, mode: TransportMode): string[] { + if (mode === "next-api") return [`${baseUrl}/api/health`]; + if (mode === "direct") return [`${baseUrl}/health`]; + return [`${baseUrl}/api/health`, `${baseUrl}/health`]; +} + +export function createRequestId(): string { + const rand = Math.random().toString(36).slice(2, 10); + return `pc_${Date.now().toString(36)}_${rand}`; +} + +export function createIdempotencyKey(parts: string[]): string { + const normalized = parts.map((part) => part.trim().toLowerCase()).join("|"); + let hash = 0; + for (let i = 0; i < normalized.length; i += 1) { + hash = (hash << 5) - hash + normalized.charCodeAt(i); + hash |= 0; + } + return `pcid_${Math.abs(hash)}`; +} + +export async function fetchWithTimeout( + input: string, + init: RequestInit, + timeoutMs = 18000, +): Promise { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } finally { + clearTimeout(id); + } +} diff --git a/mobile/src/report.ts b/mobile/src/report.ts new file mode 100644 index 0000000..58e2194 --- /dev/null +++ b/mobile/src/report.ts @@ -0,0 +1,42 @@ +import { PitchScoreReport, Platform } from "./types"; + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string").slice(0, 8); +} + +function asScore(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function normalizePitchScoreReport(data: unknown): PitchScoreReport { + if (!data || typeof data !== "object") { + throw new Error("Runtime returned an invalid payload."); + } + + const raw = data as Record; + const score = asScore(raw.persuasion_score); + const verdict = asString(raw.verdict, "No verdict provided."); + const narrative = asString(raw.narrative, "No narrative provided."); + const strengths = asStringArray(raw.strengths); + const risks = asStringArray(raw.risks); + const platformRaw = asString(raw.platform, "general"); + const allowed = new Set(["email", "linkedin", "cold-call-script", "landing-page", "ad-copy", "general"]); + const platform: Platform = allowed.has(platformRaw as Platform) ? (platformRaw as Platform) : "general"; + const scoredAt = asString(raw.scored_at, new Date().toISOString()); + + return { + persuasion_score: score, + verdict, + narrative, + strengths, + risks, + platform, + scored_at: scoredAt, + }; +} diff --git a/mobile/src/runtime.ts b/mobile/src/runtime.ts new file mode 100644 index 0000000..51f43cf --- /dev/null +++ b/mobile/src/runtime.ts @@ -0,0 +1,243 @@ +import * as SecureStore from "expo-secure-store"; +import { PitchScoreReport, RuntimeProbe, RuntimeSettings } from "./types"; +import { normalizePitchScoreReport } from "./report"; +import { + createIdempotencyKey, + createRequestId, + fetchWithTimeout, + healthEndpoints, + isLocalRuntimeUrl, + normalizeBaseUrl, + parseHttpUrl, + scoreEndpoints, +} from "./network"; +import { logRuntimeEvent } from "./telemetry"; + +const KEY = "pitchcheck-mobile-settings"; +const TIMEOUT_MS = 22000; + +export const defaultSettings: RuntimeSettings = { + runtime: "pitchserver", + pitchserverUrl: "http://127.0.0.1:8090", + vastUrl: "", + vastApiKey: "", + openRouterModel: "anthropic/claude-sonnet-4.6", + transportMode: "auto", + strictTransportSecurity: true, +}; + +export async function loadSettings(): Promise { + const raw = await SecureStore.getItemAsync(KEY); + if (!raw) return defaultSettings; + try { + const parsed = JSON.parse(raw) as Partial; + return { ...defaultSettings, ...parsed }; + } catch { + return defaultSettings; + } +} + +export async function saveSettings(settings: RuntimeSettings): Promise { + await SecureStore.setItemAsync(KEY, JSON.stringify(settings)); +} + +function runtimeBaseUrl(settings: RuntimeSettings) { + return settings.runtime === "pitchserver" + ? settings.pitchserverUrl.trim() + : settings.vastUrl.trim(); +} + +function buildAuthHeaders(settings: RuntimeSettings): Record { + const headers: Record = { + "Content-Type": "application/json", + }; + if (settings.runtime === "vast" && settings.vastApiKey.trim()) { + headers.Authorization = `Bearer ${settings.vastApiKey.trim()}`; + } + return headers; +} + +function validateRuntimeUrl(url: string, settings: RuntimeSettings) { + if (!url) return "Runtime URL is required."; + const parsed = parseHttpUrl(url); + if (!parsed) return "Runtime URL must start with http:// or https://"; + + if ( + settings.strictTransportSecurity && + parsed.protocol === "http:" && + !isLocalRuntimeUrl(url) + ) { + return "HTTPS is required for non-local runtimes when strict security is enabled."; + } + + return null; +} + +async function postScore( + url: string, + headers: Record, + payload: Record, + requestId: string, +): Promise { + const response = await fetchWithTimeout( + url, + { + method: "POST", + headers, + body: JSON.stringify(payload), + }, + TIMEOUT_MS, + ); + + const data = (await response.json().catch(() => null)) as Record | null; + + if (!response.ok) { + const reason = data && typeof data.error === "string" ? data.error : `Scoring failed (${response.status}).`; + logRuntimeEvent({ + at: new Date().toISOString(), + level: "error", + event: "score_http_error", + requestId, + details: { status: response.status, url }, + }); + throw new Error(reason); + } + + return normalizePitchScoreReport(data); +} + +async function retryOnce(fn: () => Promise): Promise { + const attempts = [0, 450, 900]; + let lastError: unknown = null; + + for (const wait of attempts) { + if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); + try { + return await fn(); + } catch (error) { + lastError = error; + } + } + + throw lastError; +} + +export async function probeRuntime(settings: RuntimeSettings): Promise { + const baseUrl = normalizeBaseUrl(runtimeBaseUrl(settings)); + const invalidReason = validateRuntimeUrl(baseUrl, settings); + if (invalidReason) { + return { + ok: false, + status: null, + endpointTried: "", + detail: invalidReason, + }; + } + + const headers = buildAuthHeaders(settings); + + for (const endpoint of healthEndpoints(baseUrl, settings.transportMode)) { + try { + const response = await fetchWithTimeout(endpoint, { headers }, 10000); + if (response.ok) { + return { + ok: true, + status: response.status, + endpointTried: endpoint, + detail: "Runtime reachable.", + }; + } + return { + ok: false, + status: response.status, + endpointTried: endpoint, + detail: `Runtime responded with ${response.status}.`, + }; + } catch { + continue; + } + } + + return { + ok: false, + status: null, + endpointTried: `${baseUrl}/health`, + detail: "Could not reach runtime.", + }; +} + +export async function scorePitch( + settings: RuntimeSettings, + message: string, + persona: string, + platform: string, +): Promise { + const baseUrl = normalizeBaseUrl(runtimeBaseUrl(settings)); + const invalidReason = validateRuntimeUrl(baseUrl, settings); + if (invalidReason) { + throw new Error(invalidReason); + } + + const requestId = createRequestId(); + const idempotencyKey = createIdempotencyKey([ + settings.runtime, + baseUrl, + platform, + message, + persona, + settings.openRouterModel, + ]); + + const headers = { + ...buildAuthHeaders(settings), + "X-Request-Id": requestId, + "X-Idempotency-Key": idempotencyKey, + }; + const payload = { + message, + persona, + platform, + openRouterModel: settings.openRouterModel, + }; + + logRuntimeEvent({ + at: new Date().toISOString(), + level: "info", + event: "score_start", + requestId, + details: { runtime: settings.runtime, transportMode: settings.transportMode }, + }); + + let lastError: Error | null = null; + for (const endpoint of scoreEndpoints(baseUrl, settings.transportMode)) { + try { + const result = await retryOnce(() => postScore(endpoint, headers, payload, requestId)); + logRuntimeEvent({ + at: new Date().toISOString(), + level: "info", + event: "score_success", + requestId, + details: { endpoint, score: result.persuasion_score }, + }); + return result; + } catch (caught) { + lastError = caught instanceof Error ? caught : new Error("Scoring failed."); + logRuntimeEvent({ + at: new Date().toISOString(), + level: "warn", + event: "score_endpoint_failed", + requestId, + details: { endpoint, reason: lastError.message }, + }); + } + } + + logRuntimeEvent({ + at: new Date().toISOString(), + level: "error", + event: "score_failed", + requestId, + details: { error: lastError?.message ?? "Scoring failed." }, + }); + throw lastError ?? new Error("Scoring failed."); +} diff --git a/mobile/src/telemetry.ts b/mobile/src/telemetry.ts new file mode 100644 index 0000000..3fcf474 --- /dev/null +++ b/mobile/src/telemetry.ts @@ -0,0 +1,32 @@ +export type RuntimeEvent = { + at: string; + level: "info" | "warn" | "error"; + event: string; + requestId?: string; + details?: Record; +}; + +const events: RuntimeEvent[] = []; +const MAX_EVENTS = 200; + +export function logRuntimeEvent(event: RuntimeEvent): void { + events.push(event); + if (events.length > MAX_EVENTS) events.shift(); + + const line = `[mobile-runtime] ${event.event}`; + if (event.level === "error") { + console.error(line, { requestId: event.requestId, ...event.details }); + } else if (event.level === "warn") { + console.warn(line, { requestId: event.requestId, ...event.details }); + } else { + console.info(line, { requestId: event.requestId, ...event.details }); + } +} + +export function getRuntimeEvents(): RuntimeEvent[] { + return [...events]; +} + +export function clearRuntimeEvents(): void { + events.length = 0; +} diff --git a/mobile/src/theme.ts b/mobile/src/theme.ts new file mode 100644 index 0000000..9759d7e --- /dev/null +++ b/mobile/src/theme.ts @@ -0,0 +1,14 @@ +export const theme = { + bgApp: "#0e0f0d", + bgChrome: "#141513", + bgPanel: "#17191a", + bgElevated: "#1c1e1b", + line: "#23251f", + lineStrong: "#2e3029", + fg: "#e8e8e3", + fgMuted: "#9a9c93", + fgDim: "#6b6d64", + ok: "#6ae7b2", + warn: "#ffd166", + err: "#ff6b6b", +}; diff --git a/mobile/src/types.ts b/mobile/src/types.ts new file mode 100644 index 0000000..75ce611 --- /dev/null +++ b/mobile/src/types.ts @@ -0,0 +1,39 @@ +export const platformValues = [ + "email", + "linkedin", + "cold-call-script", + "landing-page", + "ad-copy", + "general", +] as const; + +export type Platform = (typeof platformValues)[number]; +export type RuntimeKind = "pitchserver" | "vast"; +export type TransportMode = "auto" | "next-api" | "direct"; + +export interface PitchScoreReport { + persuasion_score: number; + verdict: string; + narrative: string; + strengths: string[]; + risks: string[]; + platform: Platform; + scored_at: string; +} + +export interface RuntimeSettings { + runtime: RuntimeKind; + pitchserverUrl: string; + vastUrl: string; + vastApiKey: string; + openRouterModel: string; + transportMode: TransportMode; + strictTransportSecurity: boolean; +} + +export interface RuntimeProbe { + ok: boolean; + status: number | null; + endpointTried: string; + detail: string; +} diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json new file mode 100644 index 0000000..5b7151f --- /dev/null +++ b/mobile/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "allowSyntheticDefaultImports": true, + "types": ["react"] + }, + "include": ["./App.tsx", "./src/**/*"] +} diff --git a/package.json b/package.json index d6c2c59..7c83ff1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,12 @@ "start": "next start", "lint": "eslint", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "mobile:install": "npm install --prefix mobile", + "mobile:start": "npm --prefix mobile run start", + "mobile:typecheck": "npm --prefix mobile run typecheck", + "mobile:build:ios": "npm --prefix mobile run build:ios", + "mobile:submit:ios": "npm --prefix mobile run submit:ios" }, "dependencies": { "@tauri-apps/api": "^2.10.1", diff --git a/tests/mobile/network.test.ts b/tests/mobile/network.test.ts new file mode 100644 index 0000000..4f36a07 --- /dev/null +++ b/tests/mobile/network.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createIdempotencyKey, + createRequestId, + healthEndpoints, + isLocalRuntimeUrl, + isProbablyHttpUrl, + normalizeBaseUrl, + scoreEndpoints, + fetchWithTimeout, +} from "@/mobile/src/network"; + +describe("mobile network helpers", () => { + it("normalizes url trailing slash", () => { + expect(normalizeBaseUrl("https://x.y/z/")).toBe("https://x.y/z"); + }); + + it("validates http urls", () => { + expect(isProbablyHttpUrl("https://demo.example")).toBe(true); + expect(isProbablyHttpUrl("http://127.0.0.1:8090")).toBe(true); + expect(isProbablyHttpUrl("ftp://x")).toBe(false); + }); + + it("detects localhost runtimes", () => { + expect(isLocalRuntimeUrl("http://127.0.0.1:8090")).toBe(true); + expect(isLocalRuntimeUrl("http://localhost:3000")).toBe(true); + expect(isLocalRuntimeUrl("https://cloud.example")).toBe(false); + }); + + it("builds score endpoints by transport", () => { + expect(scoreEndpoints("https://a", "auto")).toEqual(["https://a/api/score", "https://a/score"]); + expect(scoreEndpoints("https://a", "next-api")).toEqual(["https://a/api/score"]); + expect(scoreEndpoints("https://a", "direct")).toEqual(["https://a/score"]); + }); + + it("builds health endpoints by transport", () => { + expect(healthEndpoints("https://a", "auto")).toEqual(["https://a/api/health", "https://a/health"]); + expect(healthEndpoints("https://a", "next-api")).toEqual(["https://a/api/health"]); + expect(healthEndpoints("https://a", "direct")).toEqual(["https://a/health"]); + }); + + it("creates request id and deterministic idempotency keys", () => { + expect(createRequestId()).toMatch(/^pc_/); + const k1 = createIdempotencyKey(["A", "B"]); + const k2 = createIdempotencyKey(["a", "b"]); + expect(k1).toEqual(k2); + }); + + it("passes timeout signal to fetch", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal("fetch", fetchMock); + await fetchWithTimeout("https://a", { method: "GET" }, 50); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, { signal?: AbortSignal }]; + expect(options.signal).toBeDefined(); + vi.unstubAllGlobals(); + }); +}); diff --git a/tests/mobile/report.test.ts b/tests/mobile/report.test.ts new file mode 100644 index 0000000..059c1bd --- /dev/null +++ b/tests/mobile/report.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { normalizePitchScoreReport } from "@/mobile/src/report"; + +describe("normalizePitchScoreReport", () => { + it("normalizes and clamps a valid payload", () => { + const result = normalizePitchScoreReport({ + persuasion_score: 104.4, + verdict: "Strong", + narrative: "Great clarity", + strengths: ["A", "B"], + risks: ["X"], + platform: "email", + scored_at: "2026-04-23T00:00:00.000Z", + }); + + expect(result.persuasion_score).toBe(100); + expect(result.platform).toBe("email"); + expect(result.strengths).toEqual(["A", "B"]); + }); + + it("falls back on malformed payload fields", () => { + const result = normalizePitchScoreReport({ + persuasion_score: "bad", + strengths: ["ok", 123, null], + risks: "oops", + platform: "unknown", + }); + + expect(result.persuasion_score).toBe(0); + expect(result.platform).toBe("general"); + expect(result.strengths).toEqual(["ok"]); + expect(result.risks).toEqual([]); + expect(result.verdict.length).toBeGreaterThan(0); + }); + + it("throws for non-object payload", () => { + expect(() => normalizePitchScoreReport(null)).toThrow(/invalid payload/i); + }); +}); diff --git a/tests/mobile/telemetry.test.ts b/tests/mobile/telemetry.test.ts new file mode 100644 index 0000000..e755111 --- /dev/null +++ b/tests/mobile/telemetry.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { clearRuntimeEvents, getRuntimeEvents, logRuntimeEvent } from "@/mobile/src/telemetry"; + +describe("mobile telemetry", () => { + it("stores and clears runtime events", () => { + clearRuntimeEvents(); + logRuntimeEvent({ + at: new Date().toISOString(), + level: "info", + event: "score_start", + requestId: "pc_test", + details: { runtime: "pitchserver" }, + }); + + const events = getRuntimeEvents(); + expect(events.length).toBe(1); + expect(events[0].event).toBe("score_start"); + + clearRuntimeEvents(); + expect(getRuntimeEvents()).toEqual([]); + }); +});