@pylonsync/create-pylon 0.8.3 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/templates/_root/gitignore +1 -0
- package/templates/backend/mobile/apps/api/README.md +1 -0
- package/templates/backend/mobile/apps/api/app.ts +5 -1
- package/templates/backend/mobile/apps/api/functions/createNote.ts +5 -1
- package/templates/backend/mobile/apps/api/functions/deleteMyData.ts +38 -0
- package/templates/backend/mobile/apps/api/lib/purchases.ts +8 -0
- package/templates/expo/mobile/apps/expo/README.md +26 -2
- package/templates/expo/mobile/apps/expo/STORE.md +24 -4
- package/templates/expo/mobile/apps/expo/app/(auth)/sign-in.tsx +17 -6
- package/templates/expo/mobile/apps/expo/app/(auth)/verify.tsx +3 -1
- package/templates/expo/mobile/apps/expo/app/(tabs)/settings.tsx +6 -1
- package/templates/expo/mobile/apps/expo/app.config.ts +29 -5
- package/templates/expo/mobile/apps/expo/babel.config.js +2 -2
- package/templates/expo/mobile/apps/expo/eas.json +6 -5
- package/templates/expo/mobile/apps/expo/package.json +9 -5
- package/templates/expo/mobile/apps/expo/src/entitlements.ts +4 -9
- package/templates/expo/mobile/apps/expo/src/pylon.ts +7 -2
- package/templates/expo/mobile/apps/expo/src/session.tsx +69 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"create-pylon": "./bin/create-pylon.js"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"test": "node --test"
|
|
13
|
+
"test": "node --test \"test/*.test.mjs\""
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bin",
|
|
@@ -6,6 +6,7 @@ data API, realtime sync, auth, and the RevenueCat webhook.
|
|
|
6
6
|
```
|
|
7
7
|
app.ts User + Note + RcEntitlement (from @pylonsync/revenuecat)
|
|
8
8
|
functions/createNote server-enforced free-tier cap, then insert
|
|
9
|
+
functions/deleteMyData the user's rows, run by DELETE /api/auth/account
|
|
9
10
|
functions/revenuecatWebhook, syncEntitlements, _pylonRcUpsertEntitlement
|
|
10
11
|
lib/purchases.ts the RevenueCat plugin instance + FREE_NOTE_LIMIT
|
|
11
12
|
```
|
|
@@ -79,7 +79,11 @@ const manifest = buildManifest({
|
|
|
79
79
|
// the app's ids on the server: PYLON_APPLE_NATIVE_CLIENT_IDS (the iOS
|
|
80
80
|
// bundle id) and PYLON_GOOGLE_NATIVE_CLIENT_IDS (the iOS + Android OAuth
|
|
81
81
|
// client ids). See .env.example.
|
|
82
|
-
|
|
82
|
+
//
|
|
83
|
+
// `onDeleteAccount`: DELETE /api/auth/account runs this function before
|
|
84
|
+
// it removes the User row. Pylon deletes its own auth rows; the app's
|
|
85
|
+
// rows (Note, RcEntitlement) are deleted in functions/deleteMyData.ts.
|
|
86
|
+
auth: auth({ onDeleteAccount: "deleteMyData" }),
|
|
83
87
|
});
|
|
84
88
|
|
|
85
89
|
// The CLI runs `bun run app.ts` and reads this as the manifest.
|
|
@@ -9,15 +9,19 @@ import { FREE_NOTE_LIMIT, PRO_ENTITLEMENT } from "../lib/purchases";
|
|
|
9
9
|
*
|
|
10
10
|
* Returns `LIMIT_REACHED` when a free account is at the cap; the app opens
|
|
11
11
|
* the paywall on that code.
|
|
12
|
+
*
|
|
13
|
+
* `auth: "guest"`: the app starts every user as a guest, and the default
|
|
14
|
+
* (`"user"`) rejects guest sessions with 401 before the handler runs.
|
|
12
15
|
*/
|
|
13
16
|
export default mutation({
|
|
17
|
+
auth: "guest",
|
|
14
18
|
args: {
|
|
15
19
|
title: v.string(),
|
|
16
20
|
body: v.optional(v.string()),
|
|
17
21
|
},
|
|
18
22
|
async handler(ctx, args: { title: string; body?: string }) {
|
|
19
23
|
const userId = ctx.auth.userId;
|
|
20
|
-
if (!userId) throw ctx.error("UNAUTHENTICATED", "sign in first");
|
|
24
|
+
if (!userId) throw ctx.error("UNAUTHENTICATED", "start a guest session or sign in first");
|
|
21
25
|
const title = args.title.trim();
|
|
22
26
|
if (!title) throw ctx.error("INVALID_ARGS", "title is required");
|
|
23
27
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { ENTITLEMENT_ENTITY } from "../lib/purchases";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Deletes the caller's rows before Pylon deletes the account. Wired
|
|
6
|
+
* through `auth({ onDeleteAccount: "deleteMyData" })` in app.ts; the
|
|
7
|
+
* runtime calls it as the user with `{ userId }` and aborts the deletion
|
|
8
|
+
* if it throws. `internal: true` keeps it off the HTTP surface.
|
|
9
|
+
*
|
|
10
|
+
* Add every entity that stores user data here. Pylon removes the User
|
|
11
|
+
* row, sessions, API keys, linked accounts, and trusted devices; nothing
|
|
12
|
+
* else.
|
|
13
|
+
*/
|
|
14
|
+
export default mutation({
|
|
15
|
+
internal: true,
|
|
16
|
+
auth: "guest",
|
|
17
|
+
args: { userId: v.string() },
|
|
18
|
+
async handler(ctx, args: { userId: string }) {
|
|
19
|
+
if (ctx.auth.userId !== args.userId) {
|
|
20
|
+
throw ctx.error("FORBIDDEN", "a user can only delete their own data");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const notes = await ctx.db.query("Note", { ownerId: args.userId });
|
|
24
|
+
for (const note of notes) {
|
|
25
|
+
await ctx.db.delete("Note", String(note.id));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The entitlement policy denies every client delete. This is server
|
|
29
|
+
// code running as part of account deletion, so the unsafe surface
|
|
30
|
+
// (policy bypass) is the right tool.
|
|
31
|
+
const entitlements = await ctx.db.query(ENTITLEMENT_ENTITY, { userId: args.userId });
|
|
32
|
+
for (const row of entitlements) {
|
|
33
|
+
await ctx.db.unsafe.delete(ENTITLEMENT_ENTITY, String(row.id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { notes: notes.length, entitlements: entitlements.length };
|
|
37
|
+
},
|
|
38
|
+
});
|
|
@@ -16,7 +16,15 @@ export const FREE_NOTE_LIMIT = 10;
|
|
|
16
16
|
// Point the dashboard webhook at https://<your-app>/api/fn/revenuecatWebhook.
|
|
17
17
|
export const purchases = revenuecat({
|
|
18
18
|
entitlements: [PRO_ENTITLEMENT],
|
|
19
|
+
// Guests can buy before they sign in (the plugin default). The server
|
|
20
|
+
// reads RevenueCat by the caller's own id, so a guest cannot claim an
|
|
21
|
+
// entitlement it did not pay for. Set `syncAuth: "user"` to require an
|
|
22
|
+
// account first.
|
|
23
|
+
syncAuth: "guest",
|
|
19
24
|
});
|
|
20
25
|
|
|
26
|
+
/** The synced entitlement entity; `deleteMyData` purges it on account deletion. */
|
|
27
|
+
export const ENTITLEMENT_ENTITY = purchases.manifest.entities[0].name;
|
|
28
|
+
|
|
21
29
|
export const { revenuecatWebhook, syncEntitlements } = purchases.handlers;
|
|
22
30
|
export const { _pylonRcUpsertEntitlement } = purchases.internals;
|
|
@@ -26,8 +26,32 @@ bun run dev # Expo Go: everything except native sign-in and purchases
|
|
|
26
26
|
eas build --profile development --platform ios && bun run dev # dev build: everything
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
The backend must be running (`cd ../api && bun run dev
|
|
30
|
-
(`EXPO_PUBLIC_PYLON_BASE_URL` in `.env`).
|
|
29
|
+
The backend must be running (`cd ../api && bun run dev`, port 4321) or
|
|
30
|
+
deployed (`EXPO_PUBLIC_PYLON_BASE_URL` in `.env`). An Android emulator
|
|
31
|
+
reaches the host machine at `http://10.0.2.2:4321`; `src/pylon.ts` uses
|
|
32
|
+
that when the variable is unset.
|
|
33
|
+
|
|
34
|
+
`bun run check` typechecks. `bun run check:bundle` exports the iOS and
|
|
35
|
+
Android bundles without a device; run it after changing dependencies.
|
|
36
|
+
|
|
37
|
+
Adding an Expo native module (a package with `ios/` or `android/`) needs a
|
|
38
|
+
new development build (`eas build --profile development`). The Metro
|
|
39
|
+
server can keep running.
|
|
40
|
+
|
|
41
|
+
## Replace the demo
|
|
42
|
+
|
|
43
|
+
The Notes screens are placeholders. When you replace them, these files
|
|
44
|
+
also carry Notes-specific copy or logic:
|
|
45
|
+
|
|
46
|
+
- `app/(onboarding)/welcome.tsx`: the three slides.
|
|
47
|
+
- `app/(auth)/sign-in.tsx`: the line under the title.
|
|
48
|
+
- `app/paywall.tsx`: `BENEFITS` and the reason text.
|
|
49
|
+
- `app/(tabs)/settings.tsx`: the delete-account confirmation.
|
|
50
|
+
- `app/(tabs)/index.tsx`: the list, `FREE_LIMIT`, and the sign-in nudge.
|
|
51
|
+
- `app.config.ts`: `name`, `slug`, `scheme`, and the icons in `assets/`.
|
|
52
|
+
- `apps/api/functions/deleteMyData.ts`: delete every entity that stores
|
|
53
|
+
user data, or account deletion leaves rows behind.
|
|
54
|
+
- `apps/api/functions/createNote.ts`: the free-tier cap.
|
|
31
55
|
|
|
32
56
|
## Ship
|
|
33
57
|
|
|
@@ -11,12 +11,15 @@ pylon secrets set PYLON_APPLE_NATIVE_CLIENT_IDS=<bundle id> REVENUECAT_WEBHOOK_A
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Put the deployed URL in `apps/expo/eas.json` (`EXPO_PUBLIC_PYLON_BASE_URL`
|
|
14
|
-
under `preview` and `production`).
|
|
14
|
+
under `preview` and `production`). `app.config.ts` refuses a preview or
|
|
15
|
+
production build until that value, `APP_BUNDLE_ID`, and `EAS_PROJECT_ID`
|
|
16
|
+
are set.
|
|
15
17
|
|
|
16
18
|
## 2. Identifiers
|
|
17
19
|
|
|
18
20
|
- Pick a bundle id (`com.yourco.app`) and set `APP_BUNDLE_ID` in `eas.json`
|
|
19
|
-
env for
|
|
21
|
+
env for `preview` and `production`, or export it before building. The
|
|
22
|
+
`development` profile appends `.dev` to the `com.example` placeholder.
|
|
20
23
|
- `eas init` prints the project id; set `EAS_PROJECT_ID` the same way.
|
|
21
24
|
- Apple: an App ID with the Sign in with Apple capability. EAS creates it on
|
|
22
25
|
the first `eas build` when you let it manage credentials.
|
|
@@ -70,8 +73,25 @@ eas submit --platform ios --latest
|
|
|
70
73
|
eas submit --platform android --latest
|
|
71
74
|
```
|
|
72
75
|
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
For iOS, add the App Store Connect app id to `eas.json` first:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
"submit": { "production": { "ios": { "ascAppId": "1234567890" } } }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Without it `eas submit` asks for the app interactively.
|
|
83
|
+
|
|
84
|
+
## Before the first store build
|
|
85
|
+
|
|
86
|
+
- Products: the `pro` entitlement and a `default` offering exist in
|
|
87
|
+
RevenueCat, and the iOS and Android public keys are in `apps/expo/.env`.
|
|
88
|
+
- Sign-in: `PYLON_APPLE_NATIVE_CLIENT_IDS` (the bundle id) and
|
|
89
|
+
`PYLON_GOOGLE_NATIVE_CLIENT_IDS` are set on the backend.
|
|
90
|
+
- Legal: `EXPO_PUBLIC_PRIVACY_URL`, `EXPO_PUBLIC_TERMS_URL`, and
|
|
91
|
+
`EXPO_PUBLIC_SUPPORT_EMAIL` point at real pages and a monitored inbox.
|
|
92
|
+
- Deletion: `apps/api/functions/deleteMyData.ts` removes every entity that
|
|
93
|
+
stores user data. Test it with a throwaway account.
|
|
94
|
+
- Copy: the "Replace the demo" list in `README.md` is done.
|
|
75
95
|
|
|
76
96
|
## After launch
|
|
77
97
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useState } from "react";
|
|
2
2
|
import { Alert, Platform, Text, View } from "react-native";
|
|
3
3
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
4
|
-
import { nativeSignIn, sendEmailCode } from "@pylonsync/react-native";
|
|
4
|
+
import { db, nativeSignIn, sendEmailCode } from "@pylonsync/react-native";
|
|
5
5
|
import { track } from "@/analytics";
|
|
6
6
|
import { useAppSession } from "@/session";
|
|
7
7
|
import { space, useTheme } from "@/theme";
|
|
@@ -59,12 +59,23 @@ export default function SignIn() {
|
|
|
59
59
|
});
|
|
60
60
|
}, [apple, google]);
|
|
61
61
|
|
|
62
|
-
function done() {
|
|
62
|
+
async function done() {
|
|
63
63
|
track("sign_in_completed");
|
|
64
|
+
// Wait for the server to confirm the new session so the route guard
|
|
65
|
+
// sees `ready` before the navigation, not after it.
|
|
66
|
+
await db.sync.notifySessionChanged();
|
|
64
67
|
if (params.next) router.replace(params.next as never);
|
|
65
68
|
else router.replace("/(tabs)");
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
// "Not now" is reachable from a cold start (no history) as well as from
|
|
72
|
+
// Settings (history). Fall back to the app when there is nothing to go
|
|
73
|
+
// back to.
|
|
74
|
+
function dismiss() {
|
|
75
|
+
if (router.canGoBack()) router.back();
|
|
76
|
+
else router.replace("/(tabs)");
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
async function withApple() {
|
|
69
80
|
if (!apple) return;
|
|
70
81
|
setBusy("apple");
|
|
@@ -81,7 +92,7 @@ export default function SignIn() {
|
|
|
81
92
|
.filter(Boolean)
|
|
82
93
|
.join(" ");
|
|
83
94
|
await nativeSignIn("apple", credential.identityToken, name || undefined);
|
|
84
|
-
done();
|
|
95
|
+
await done();
|
|
85
96
|
} catch (e) {
|
|
86
97
|
if ((e as { code?: string })?.code !== "ERR_REQUEST_CANCELED") {
|
|
87
98
|
Alert.alert("Sign in failed", messageOf(e));
|
|
@@ -101,7 +112,7 @@ export default function SignIn() {
|
|
|
101
112
|
const idToken = result.type === "success" ? result.data.idToken : null;
|
|
102
113
|
if (!idToken) return;
|
|
103
114
|
await nativeSignIn("google", idToken, result.type === "success" ? result.data.user.name : undefined);
|
|
104
|
-
done();
|
|
115
|
+
await done();
|
|
105
116
|
} catch (e) {
|
|
106
117
|
Alert.alert("Sign in failed", messageOf(e));
|
|
107
118
|
} finally {
|
|
@@ -132,7 +143,7 @@ export default function SignIn() {
|
|
|
132
143
|
<Spacer h={space.xxl} />
|
|
133
144
|
<Title>Sign in</Title>
|
|
134
145
|
<Body muted style={{ marginTop: space.sm }}>
|
|
135
|
-
Keep
|
|
146
|
+
Keep everything on every device. Anything you made already comes with you.
|
|
136
147
|
</Body>
|
|
137
148
|
<Spacer h={space.xxl} />
|
|
138
149
|
<View style={{ gap: space.md }}>
|
|
@@ -179,7 +190,7 @@ export default function SignIn() {
|
|
|
179
190
|
onPress={() => void continueAsGuest().then(() => router.replace("/(tabs)"))}
|
|
180
191
|
/>
|
|
181
192
|
) : (
|
|
182
|
-
<Button title="Not now" variant="ghost" onPress={
|
|
193
|
+
<Button title="Not now" variant="ghost" onPress={dismiss} />
|
|
183
194
|
)}
|
|
184
195
|
<Spacer />
|
|
185
196
|
<Text style={{ color: t.muted, fontSize: 12, textAlign: "center", lineHeight: 18 }}>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Alert, TextInput } from "react-native";
|
|
3
3
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
4
|
-
import { sendEmailCode, verifyEmailCode } from "@pylonsync/react-native";
|
|
4
|
+
import { db, sendEmailCode, verifyEmailCode } from "@pylonsync/react-native";
|
|
5
5
|
import { track } from "@/analytics";
|
|
6
6
|
import { space } from "@/theme";
|
|
7
7
|
import { Body, Button, Field, Screen, Spacer, Title } from "@/ui";
|
|
@@ -26,6 +26,8 @@ export default function Verify() {
|
|
|
26
26
|
try {
|
|
27
27
|
await verifyEmailCode(email, value);
|
|
28
28
|
track("sign_in_completed", { method: "email" });
|
|
29
|
+
// See sign-in.tsx: let the route guard observe the session first.
|
|
30
|
+
await db.sync.notifySessionChanged();
|
|
29
31
|
if (next) router.replace(next as never);
|
|
30
32
|
else router.replace("/(tabs)");
|
|
31
33
|
} catch (e) {
|
|
@@ -15,6 +15,11 @@ import { Caption, Row, Screen, Spacer, Title } from "@/ui";
|
|
|
15
15
|
* Account, subscription, legal, and support. App Review requires an
|
|
16
16
|
* in-app account deletion path when the app offers account creation, and
|
|
17
17
|
* both stores require the privacy policy and terms links.
|
|
18
|
+
*
|
|
19
|
+
* Delete account: `deleteAccount()` calls `DELETE /api/auth/account`. The
|
|
20
|
+
* backend runs `deleteMyData` first (see `auth({ onDeleteAccount })` in
|
|
21
|
+
* apps/api/app.ts), so the user's notes and entitlement rows go with the
|
|
22
|
+
* account. Add every new entity that stores user data to that function.
|
|
18
23
|
*/
|
|
19
24
|
export default function Settings() {
|
|
20
25
|
const router = useRouter();
|
|
@@ -40,7 +45,7 @@ export default function Settings() {
|
|
|
40
45
|
function confirmDelete() {
|
|
41
46
|
Alert.alert(
|
|
42
47
|
"Delete account?",
|
|
43
|
-
"This
|
|
48
|
+
"This deletes your account and every note on our servers. A subscription is managed by the store; cancel it there.",
|
|
44
49
|
[
|
|
45
50
|
{ text: "Cancel", style: "cancel" },
|
|
46
51
|
{
|
|
@@ -4,10 +4,11 @@ import type { ConfigContext, ExpoConfig } from "expo/config";
|
|
|
4
4
|
* App config. The values that change per environment come from env vars
|
|
5
5
|
* so one file serves development builds, TestFlight, and the store:
|
|
6
6
|
*
|
|
7
|
-
* APP_BUNDLE_ID
|
|
8
|
-
* APP_SCHEME
|
|
9
|
-
* EAS_PROJECT_ID
|
|
10
|
-
*
|
|
7
|
+
* APP_BUNDLE_ID reverse-DNS id (iOS bundle id + Android package)
|
|
8
|
+
* APP_SCHEME deep-link scheme, e.g. "__APP_NAME_KEBAB__"
|
|
9
|
+
* EAS_PROJECT_ID from `eas init` (printed once, then stable)
|
|
10
|
+
* EXPO_PUBLIC_PYLON_BASE_URL the backend; https:// outside development
|
|
11
|
+
* APP_VARIANT "development" | "preview" | "production" (set by eas.json)
|
|
11
12
|
*
|
|
12
13
|
* A development build gets a ".dev" suffix so it installs alongside the
|
|
13
14
|
* store app on the same phone.
|
|
@@ -17,6 +18,27 @@ const baseBundleId = process.env.APP_BUNDLE_ID ?? "com.example.__APP_NAME_SNAKE_
|
|
|
17
18
|
const bundleId = variant === "development" ? `${baseBundleId}.dev` : baseBundleId;
|
|
18
19
|
const name = variant === "production" ? "__APP_NAME__" : `__APP_NAME__ (${variant})`;
|
|
19
20
|
|
|
21
|
+
// A preview or store build with placeholder values would install and
|
|
22
|
+
// then fail at first launch. Refuse to build instead.
|
|
23
|
+
if (variant !== "development") {
|
|
24
|
+
const problems: string[] = [];
|
|
25
|
+
if (!process.env.APP_BUNDLE_ID) {
|
|
26
|
+
problems.push("APP_BUNDLE_ID is unset; the com.example placeholder cannot ship");
|
|
27
|
+
}
|
|
28
|
+
if (!process.env.EAS_PROJECT_ID) {
|
|
29
|
+
problems.push("EAS_PROJECT_ID is unset; run `eas init` and copy the id");
|
|
30
|
+
}
|
|
31
|
+
if (!/^https:\/\//.test(process.env.EXPO_PUBLIC_PYLON_BASE_URL ?? "")) {
|
|
32
|
+
problems.push("EXPO_PUBLIC_PYLON_BASE_URL must be the deployed https:// backend");
|
|
33
|
+
}
|
|
34
|
+
if (problems.length > 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`The ${variant} build is not configured:\n - ${problems.join("\n - ")}\n` +
|
|
37
|
+
`Set these under build.${variant}.env in eas.json, or export them before building.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
20
42
|
export default ({ config }: ConfigContext): ExpoConfig => ({
|
|
21
43
|
...config,
|
|
22
44
|
name,
|
|
@@ -56,7 +78,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|
|
56
78
|
[
|
|
57
79
|
"expo-build-properties",
|
|
58
80
|
{
|
|
59
|
-
|
|
81
|
+
// The floors for Expo SDK 57 / React Native 0.86. expo-build-properties
|
|
82
|
+
// rejects a lower iOS target; Android below 24 does not build.
|
|
83
|
+
ios: { deploymentTarget: "16.4" },
|
|
60
84
|
// Cleartext lets a dev/preview build reach http://localhost:4321.
|
|
61
85
|
android: { minSdkVersion: 24, usesCleartextTraffic: variant !== "production" },
|
|
62
86
|
},
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module.exports = function (api) {
|
|
2
2
|
api.cache(true);
|
|
3
3
|
return {
|
|
4
|
+
// babel-preset-expo 57 registers the react-native-worklets plugin
|
|
5
|
+
// (Reanimated 4) itself; adding it here again applies it twice.
|
|
4
6
|
presets: ["babel-preset-expo"],
|
|
5
|
-
// Reanimated's plugin must be last.
|
|
6
|
-
plugins: ["react-native-reanimated/plugin"],
|
|
7
7
|
};
|
|
8
8
|
};
|
|
@@ -21,22 +21,23 @@
|
|
|
21
21
|
"distribution": "internal",
|
|
22
22
|
"env": {
|
|
23
23
|
"APP_VARIANT": "preview",
|
|
24
|
-
"
|
|
24
|
+
"APP_BUNDLE_ID": "",
|
|
25
|
+
"EAS_PROJECT_ID": "",
|
|
26
|
+
"EXPO_PUBLIC_PYLON_BASE_URL": ""
|
|
25
27
|
}
|
|
26
28
|
},
|
|
27
29
|
"production": {
|
|
28
30
|
"autoIncrement": true,
|
|
29
31
|
"env": {
|
|
30
32
|
"APP_VARIANT": "production",
|
|
31
|
-
"
|
|
33
|
+
"APP_BUNDLE_ID": "",
|
|
34
|
+
"EAS_PROJECT_ID": "",
|
|
35
|
+
"EXPO_PUBLIC_PYLON_BASE_URL": ""
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
},
|
|
35
39
|
"submit": {
|
|
36
40
|
"production": {
|
|
37
|
-
"ios": {
|
|
38
|
-
"ascAppId": "REPLACE_WITH_APP_STORE_CONNECT_APP_ID"
|
|
39
|
-
},
|
|
40
41
|
"android": {
|
|
41
42
|
"track": "internal"
|
|
42
43
|
}
|
|
@@ -14,11 +14,13 @@
|
|
|
14
14
|
"build:preview": "eas build --profile preview --platform all",
|
|
15
15
|
"build:prod": "eas build --profile production --platform all",
|
|
16
16
|
"submit:ios": "eas submit --platform ios --latest",
|
|
17
|
-
"submit:android": "eas submit --platform android --latest"
|
|
17
|
+
"submit:android": "eas submit --platform android --latest",
|
|
18
|
+
"check:bundle": "expo export --platform ios --platform android --output-dir .expo-export"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@pylonsync/react": "^__PYLON_VERSION__",
|
|
21
22
|
"@pylonsync/react-native": "^__PYLON_VERSION__",
|
|
23
|
+
"@pylonsync/revenuecat": "^__PYLON_VERSION__",
|
|
22
24
|
"@pylonsync/sdk": "^__PYLON_VERSION__",
|
|
23
25
|
"@pylonsync/sync": "^__PYLON_VERSION__",
|
|
24
26
|
"@react-native-async-storage/async-storage": "2.2.0",
|
|
@@ -42,11 +44,13 @@
|
|
|
42
44
|
"react-native-reanimated": "4.5.1",
|
|
43
45
|
"react-native-safe-area-context": "~5.7.0",
|
|
44
46
|
"react-native-screens": "~4.26.0",
|
|
45
|
-
"react-native-web": "~0.21.0"
|
|
47
|
+
"react-native-web": "~0.21.0",
|
|
48
|
+
"react-native-worklets": "0.10.1"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
|
-
"@babel/core": "^7.
|
|
49
|
-
"@types/react": "~19.2.
|
|
50
|
-
"
|
|
51
|
+
"@babel/core": "^7.29.0",
|
|
52
|
+
"@types/react": "~19.2.4",
|
|
53
|
+
"babel-preset-expo": "~57.0.10",
|
|
54
|
+
"typescript": "~6.0.3"
|
|
51
55
|
}
|
|
52
56
|
}
|
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import { db } from "@pylonsync/react-native";
|
|
2
|
-
import { hasEntitlement } from "@pylonsync/revenuecat";
|
|
2
|
+
import { hasEntitlement, type RcEntitlementRow } from "@pylonsync/revenuecat/client";
|
|
3
3
|
|
|
4
4
|
export const PRO = "pro";
|
|
5
5
|
|
|
6
|
-
interface RcEntitlementRow {
|
|
7
|
-
id: string;
|
|
8
|
-
userId: string;
|
|
9
|
-
entitlement: string;
|
|
10
|
-
status: string;
|
|
11
|
-
expiresAt?: string | null;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
6
|
/**
|
|
15
7
|
* Live Pro status from the synced RcEntitlement rows. Updates the moment
|
|
16
8
|
* the server writes the row after a purchase, on every device.
|
|
9
|
+
*
|
|
10
|
+
* `@pylonsync/revenuecat/client` is the browser/React Native entry of the
|
|
11
|
+
* plugin. The package root is server code and must not be imported here.
|
|
17
12
|
*/
|
|
18
13
|
export function usePro(): { pro: boolean; loading: boolean } {
|
|
19
14
|
const { data, loading } = db.useQuery<RcEntitlementRow>("RcEntitlement", {});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { Platform } from "react-native";
|
|
2
2
|
import { init } from "@pylonsync/react-native";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The backend URL. `pylon dev` in apps/api listens on 4321. An Android
|
|
6
|
+
* emulator reaches the host machine at 10.0.2.2. Outside development
|
|
7
|
+
* app.config.ts refuses to build without an https:// value.
|
|
8
|
+
*/
|
|
9
|
+
const configured = process.env.EXPO_PUBLIC_PYLON_BASE_URL?.trim();
|
|
5
10
|
export const PYLON_BASE_URL =
|
|
6
|
-
|
|
11
|
+
configured ||
|
|
7
12
|
(Platform.OS === "android" ? "http://10.0.2.2:4321" : "http://localhost:4321");
|
|
8
13
|
|
|
9
14
|
export const APP_NAME = "__APP_NAME_SNAKE__";
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
db,
|
|
4
|
+
getReactStorage,
|
|
5
|
+
guestSession,
|
|
6
|
+
signOut as pylonSignOut,
|
|
7
|
+
storageKey,
|
|
8
|
+
useSession,
|
|
9
|
+
} from "@pylonsync/react-native";
|
|
3
10
|
import { ensurePylon } from "./pylon";
|
|
4
11
|
import { getOnboardingDone, setOnboardingDone as persistOnboardingDone } from "./flags";
|
|
5
|
-
import { configure as configurePurchases } from "./purchases";
|
|
12
|
+
import { configure as configurePurchases, syncEntitlements } from "./purchases";
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* What the router needs to know:
|
|
@@ -10,7 +17,7 @@ import { configure as configurePurchases } from "./purchases";
|
|
|
10
17
|
* booting the engine and the on-device flags are still loading
|
|
11
18
|
* onboarding first launch: show the welcome slides
|
|
12
19
|
* ready a session exists (guest or account) and the tabs can render
|
|
13
|
-
* signedOut
|
|
20
|
+
* signedOut no session; offer sign-in or a fresh guest
|
|
14
21
|
*/
|
|
15
22
|
export type SessionState = "booting" | "onboarding" | "ready" | "signedOut";
|
|
16
23
|
|
|
@@ -26,37 +33,73 @@ interface SessionValue {
|
|
|
26
33
|
|
|
27
34
|
const Ctx = createContext<SessionValue | null>(null);
|
|
28
35
|
|
|
36
|
+
async function notBooted(): Promise<void> {
|
|
37
|
+
throw new Error("Pylon is still booting");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BOOTING: SessionValue = {
|
|
41
|
+
state: "booting",
|
|
42
|
+
userId: null,
|
|
43
|
+
isGuest: false,
|
|
44
|
+
completeOnboarding: notBooted,
|
|
45
|
+
continueAsGuest: notBooted,
|
|
46
|
+
signOut: notBooted,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Boots Pylon, then mounts the session reader. The order matters: before
|
|
51
|
+
* `init()` has run, `db.sync` would create a default engine with the
|
|
52
|
+
* wrong base URL and no AsyncStorage, and the reader would stay bound to
|
|
53
|
+
* it. Children render during boot, so the native splash stays up (see
|
|
54
|
+
* app/_layout.tsx) instead of a blank frame.
|
|
55
|
+
*/
|
|
29
56
|
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|
30
|
-
const [
|
|
31
|
-
const [onboardingDone, setOnboardingDoneState] = useState(false);
|
|
32
|
-
const [signedOut, setSignedOut] = useState(false);
|
|
33
|
-
const session = useSession(db.sync);
|
|
34
|
-
const userId = session.userId;
|
|
57
|
+
const [boot, setBoot] = useState<{ onboardingDone: boolean } | null>(null);
|
|
35
58
|
|
|
36
59
|
useEffect(() => {
|
|
37
60
|
let alive = true;
|
|
38
61
|
void (async () => {
|
|
39
62
|
await ensurePylon();
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
setOnboardingDoneState(done);
|
|
43
|
-
setBooted(true);
|
|
63
|
+
const onboardingDone = await getOnboardingDone();
|
|
64
|
+
if (alive) setBoot({ onboardingDone });
|
|
44
65
|
})();
|
|
45
66
|
return () => {
|
|
46
67
|
alive = false;
|
|
47
68
|
};
|
|
48
69
|
}, []);
|
|
49
70
|
|
|
71
|
+
if (!boot) return <Ctx.Provider value={BOOTING}>{children}</Ctx.Provider>;
|
|
72
|
+
return <BootedSession initialOnboardingDone={boot.onboardingDone}>{children}</BootedSession>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A token in AsyncStorage: the last session (guest or account) to restore. */
|
|
76
|
+
function hasStoredToken(): boolean {
|
|
77
|
+
return Boolean(getReactStorage().get(storageKey("token")));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function BootedSession({
|
|
81
|
+
initialOnboardingDone,
|
|
82
|
+
children,
|
|
83
|
+
}: {
|
|
84
|
+
initialOnboardingDone: boolean;
|
|
85
|
+
children: React.ReactNode;
|
|
86
|
+
}) {
|
|
87
|
+
const [onboardingDone, setOnboardingDoneState] = useState(initialOnboardingDone);
|
|
88
|
+
const session = useSession(db.sync);
|
|
89
|
+
const userId = session.userId;
|
|
90
|
+
|
|
50
91
|
// Identify the purchase SDK with the Pylon user id so RevenueCat's
|
|
51
|
-
// app_user_id is our id and webhook events map to our rows.
|
|
92
|
+
// app_user_id is our id and webhook events map to our rows. After a
|
|
93
|
+
// guest signs in, RevenueCat aliases the ids; re-reading the
|
|
94
|
+
// entitlements moves the guest's purchase onto the account.
|
|
52
95
|
useEffect(() => {
|
|
53
|
-
if (userId)
|
|
96
|
+
if (!userId) return;
|
|
97
|
+
void configurePurchases(userId).then((ok) => (ok ? syncEntitlements() : undefined));
|
|
54
98
|
}, [userId]);
|
|
55
99
|
|
|
56
100
|
const continueAsGuest = useCallback(async () => {
|
|
57
101
|
await guestSession();
|
|
58
102
|
await db.sync.notifySessionChanged();
|
|
59
|
-
setSignedOut(false);
|
|
60
103
|
}, []);
|
|
61
104
|
|
|
62
105
|
const completeOnboarding = useCallback(async () => {
|
|
@@ -69,16 +112,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
|
|
69
112
|
|
|
70
113
|
const signOut = useCallback(async () => {
|
|
71
114
|
await pylonSignOut();
|
|
72
|
-
|
|
115
|
+
// Wait for /api/auth/me so `state` is signedOut before the caller
|
|
116
|
+
// navigates, not one render later.
|
|
117
|
+
await db.sync.notifySessionChanged();
|
|
73
118
|
}, []);
|
|
74
119
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
120
|
+
// Until /api/auth/me answers, `session.userId` is null because the
|
|
121
|
+
// answer has not arrived, not because the user is signed out. A stored
|
|
122
|
+
// token means the previous session is still the one to show: the
|
|
123
|
+
// replica renders from AsyncStorage and the server confirms or rejects
|
|
124
|
+
// the token on the first pull. An expired token resolves to no user and
|
|
125
|
+
// the state moves to signedOut then.
|
|
126
|
+
const hasSession = session.resolved ? userId != null : hasStoredToken();
|
|
127
|
+
const state: SessionState = !onboardingDone ? "onboarding" : hasSession ? "ready" : "signedOut";
|
|
82
128
|
|
|
83
129
|
const value = useMemo<SessionValue>(
|
|
84
130
|
() => ({
|