Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions mobile/.env.example
Original file line number Diff line number Diff line change
@@ -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
371 changes: 371 additions & 0 deletions mobile/App.tsx

Large diffs are not rendered by default.

46 changes: 46 additions & 0 deletions mobile/README.md
Original file line number Diff line number Diff line change
@@ -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 <key>`.
44 changes: 44 additions & 0 deletions mobile/app.config.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 6 additions & 0 deletions mobile/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = function(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};
20 changes: 20 additions & 0 deletions mobile/eas.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"cli": {
"version": ">= 13.3.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
32 changes: 32 additions & 0 deletions mobile/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
54 changes: 54 additions & 0 deletions mobile/src/draft-queue.ts
Original file line number Diff line number Diff line change
@@ -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<PendingScoreDraft | null> {
const raw = await SecureStore.getItemAsync(KEY);
if (!raw) return null;

try {
const parsed = JSON.parse(raw) as Partial<PendingScoreDraft>;
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<void> {
await SecureStore.setItemAsync(KEY, JSON.stringify(draft));
}

export async function clearPendingDraft(): Promise<void> {
await SecureStore.deleteItemAsync(KEY);
}
67 changes: 67 additions & 0 deletions mobile/src/network.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(input, { ...init, signal: controller.signal });
} finally {
clearTimeout(id);
}
}
42 changes: 42 additions & 0 deletions mobile/src/report.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<Platform>(["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,
};
}
Loading
Loading