diff --git a/.fingerprintignore b/.fingerprintignore new file mode 100644 index 00000000..a75cb390 --- /dev/null +++ b/.fingerprintignore @@ -0,0 +1,72 @@ +# Ignore files that don't affect native compatibility + +# Development and build artifacts +node_modules/**/* +.expo/**/* +.git/**/* +*.log +.DS_Store + +# Documentation and non-native files +README.md +CONTRIBUTING.md +*.md +docs/**/* + +# Test files +**/*.test.ts +**/*.test.tsx +**/__tests__/**/* +jest.config.ts +jest.setup.ts + +# Linting and formatting +.eslintrc.* +.prettierrc.* +eslint.config.mjs + +# Environment and config files that don't affect native +.env* +.nvmrc +yarn.lock +package-lock.json + +# Scripts that don't affect native build +scripts/**/* +!scripts/check-runtime-compatibility.js + +# GitHub workflows (except our PR preview) +.github/workflows/**/* +!.github/workflows/pr-preview.yml + +# Patches and temporary files +patches/**/* +*.patch +*.tmp + +# IDE and editor files +.vscode/**/* +.idea/**/* +*.swp +*.swo + +# React Native Metro cache +.metro-health-check* + +# Expo development +.expo-shared/**/* + +# TypeScript build artifacts +*.tsbuildinfo + +# Reassure performance tests +reassure-tests.sh + +# Keep important native-affecting files by explicitly not ignoring them: +# - app.config.ts (affects native config) +# - eas.json (affects builds) +# - package.json (affects dependencies) +# - plugins/ (affects native code) +# - ios/ and android/ directories +# - babel.config.js (affects transforms) +# - metro.config.js (affects bundling) \ No newline at end of file diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 00000000..d372bd04 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,259 @@ +name: PR Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + check-compatibility: + name: Check Update Compatibility + runs-on: ubuntu-latest + outputs: + can_create_update: ${{ steps.check-runtime.outputs.can_create_update }} + current_fingerprint: ${{ steps.check-runtime.outputs.current_fingerprint }} + preview_fingerprint: ${{ steps.check-runtime.outputs.preview_fingerprint }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: "yarn" + cache-dependency-path: yarn.lock + env: + SKIP_YARN_COREPACK_CHECK: "1" + + - run: corepack enable + + - name: Install dependencies + run: yarn install + + # Need this here because the "Setup EAS" setup will execute npx expo config and will need the "build" folder of the plugin to be there + - name: Build iOS notification extension plugin + run: yarn plugins:build:notification-service-extension + + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + packager: yarn + + - name: Check fingerprint compatibility + id: check-runtime + run: node scripts/check-runtime-compatibility.js + + create-update: + name: Create EAS Update + runs-on: ubuntu-latest + needs: check-compatibility + if: needs.check-compatibility.outputs.can_create_update == 'true' + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: "yarn" + cache-dependency-path: yarn.lock + env: + SKIP_YARN_COREPACK_CHECK: "1" + + - run: corepack enable + + - name: Install dependencies + run: yarn install + + # Need this here because the "Setup EAS" setup will execute npx expo config and will need the "build" folder of the plugin to be there + - name: Build iOS notification extension plugin + run: yarn plugins:build:notification-service-extension + + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + packager: yarn + eas-cache: true + patch-watchers: true + + - name: Create PR preview update + uses: expo/expo-github-action/preview@v8 + with: + command: eas update --branch=pr-${{ github.event.number }} --message="PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}" + env: + EXPO_ENV: preview + + - name: Comment on PR - EAS Update Ready + uses: actions/github-script@v7 + with: + script: | + const currentFingerprint = '${{ needs.check-compatibility.outputs.current_fingerprint }}'; + const previewFingerprint = '${{ needs.check-compatibility.outputs.preview_fingerprint }}'; + + const body = `## šŸ“± PR Preview Ready (EAS Update) + + āœ… **Compatible with current preview builds** - No native changes detected + + ### šŸ”„ How to Test + 1. **Open the Convos Preview app** (must be on latest preview build) + 2. **Long press anywhere** to open debug menu + 3. **Tap "Updates Menu"** → **"Switch to PR Branch (Smart)"** + 4. **Enter PR number:** \`${{ github.event.number }}\` + 5. **Tap "Check & Switch"** - it will verify compatibility first + + ### šŸ“Š Technical Details + - **Update Type:** EAS Update (JavaScript-only changes) + - **Runtime Version:** \`${currentFingerprint}\` + - **Compatible with builds:** \`${previewFingerprint}\` + - **Branch:** \`pr-${{ github.event.number }}\` + - **Deep Link:** \`convos-preview://expo-development-client/?url=https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2?channel-name=pr-${{ github.event.number }}\` + + ### āš ļø Important Notes + - Only works with **preview builds** that have runtime version \`${previewFingerprint}\` + - The debug menu will **automatically check compatibility** before switching + - If you're on an older build, you'll see a warning message + + --- + *This update was created automatically because no native changes were detected.*`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + create-build: + name: Create EAS Build for Native Changes + runs-on: ubuntu-latest + needs: check-compatibility + if: needs.check-compatibility.outputs.can_create_update == 'false' + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: "yarn" + cache-dependency-path: yarn.lock + + - name: Setup EAS + uses: expo/expo-github-action@v8 + with: + expo-version: latest + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + + - name: Install dependencies + run: yarn install --frozen-lockfile + + # Need this here because the "Setup EAS" setup will execute npx expo config and will need the "build" folder of the plugin to be there + - name: Build iOS notification extension plugin + run: yarn plugins:build:notification-service-extension + + - name: Comment on PR - Build Required + uses: actions/github-script@v7 + with: + script: | + const currentFingerprint = '${{ needs.check-compatibility.outputs.current_fingerprint }}'; + const previewFingerprint = '${{ needs.check-compatibility.outputs.preview_fingerprint }}'; + + const body = `## šŸ”Ø PR Preview Requires New Build + + āš ļø **Native changes detected** - EAS Update not compatible + + ### šŸ—ļø What's Happening + This PR contains native changes (new dependencies, config changes, etc.) that require a new build. + + ### šŸ“Š Technical Details + - **Current PR fingerprint:** \`${currentFingerprint}\` + - **Latest preview build:** \`${previewFingerprint}\` + - **Compatibility:** āŒ **Incompatible** (different runtime versions) + + ### šŸš€ Next Steps + 1. **Wait for new build** - A new preview build will be created automatically + 2. **Check TestFlight** - New build will appear in TestFlight when ready + 3. **Update your app** - Install the new build before testing this PR + + ### āš ļø Important for Testers + - **Don't try to switch to this PR** in the debug menu with old builds + - **It will crash** because of runtime version mismatch + - **Wait for the new build** notification + + --- + *This PR requires a new build because it contains native changes.*`; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + - name: Create EAS Build + run: | + eas build --platform ios --profile preview --non-interactive --message "PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}" + + - name: Comment on PR - Build Complete + if: success() + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `āœ… **Preview Build Complete!** + + The new preview build for this PR is ready. + + **To test:** + 1. Open TestFlight on your device + 2. Update to the latest "Convos Preview" build + 3. The build includes the changes from this PR + + **Build completed at:** ${new Date().toLocaleString()} + + Note: It may take a few minutes for the build to appear in TestFlight.` + }) + + - name: Comment on PR - Build Failed + if: failure() + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `āŒ **Preview Build Failed** + + The preview build for this PR failed to create. + + **To investigate:** + 1. Check the [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + 2. Look for build errors in the EAS dashboard + + **Common causes:** + - Build configuration issues + - Native dependency conflicts + - Code signing problems + + **Build failed at:** ${new Date().toLocaleString()}` + }) diff --git a/App.tsx b/App.tsx index f53a9c61..3d4bddaf 100644 --- a/App.tsx +++ b/App.tsx @@ -96,3 +96,5 @@ const Handlers = memo(function Handlers() { return null }) + +const test = "" diff --git a/app.config.ts b/app.config.ts index bca6d53e..5a7c4149 100644 --- a/app.config.ts +++ b/app.config.ts @@ -26,6 +26,9 @@ type EnvironmentConfig = { backgroundColor: string } } + updates: { + disableAntiBrickingMeasures: boolean + } } export type IExpoAppConfigExtra = { @@ -71,6 +74,9 @@ const settings: Record = { webDomain: "preview.convos.org", appName: "Convos Dev", icon: "./assets/icon-light.png", + updates: { + disableAntiBrickingMeasures: true, + }, }, preview: { scheme: "convos-preview", @@ -100,6 +106,9 @@ const settings: Record = { webDomain: "preview.convos.org", appName: "Convos Preview", icon: "./assets/icon-light.png", + updates: { + disableAntiBrickingMeasures: true, + }, }, production: { scheme: "convos", @@ -129,6 +138,10 @@ const settings: Record = { webDomain: "convos.org", appName: "Convos", icon: "./assets/icon-light.png", + updates: { + // NEVER in production app for now https://docs.expo.dev/eas-update/override/ + disableAntiBrickingMeasures: false, + }, }, } @@ -147,7 +160,7 @@ export default () => { version: version, assetBundlePatterns: ["**/*"], runtimeVersion: { - policy: "nativeVersion", + policy: "fingerprint", }, updates: { url: "https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2", diff --git a/components/debug-menu.tsx b/components/debug-menu.tsx index e9bb03c6..ee70f07d 100644 --- a/components/debug-menu.tsx +++ b/components/debug-menu.tsx @@ -1120,6 +1120,352 @@ function useShowDebugMenu() { }) }, []) + const showUpdatesMenu = useCallback(() => { + const currentEnv = getEnv() + const currentChannel = currentEnv // Channel matches environment + + const updatesMethods = { + "Current Update Info": () => { + Alert.alert( + "Current Update Info", + [ + `Environment: ${currentEnv}`, + `Channel: ${currentChannel}`, + `Update ID: ${currentlyRunning.updateId || "embedded"}`, + `Created: ${currentlyRunning.createdAt?.toLocaleString() || "N/A"}`, + `Runtime Version: ${currentlyRunning.runtimeVersion}`, + `Is Embedded: ${currentlyRunning.isEmbeddedLaunch}`, + ].join("\n"), + [ + { text: "OK" }, + { + text: "Copy Info", + onPress: () => { + const info = [ + `Environment: ${currentEnv}`, + `Channel: ${currentChannel}`, + `Update ID: ${currentlyRunning.updateId || "embedded"}`, + `Runtime Version: ${currentlyRunning.runtimeVersion}`, + ].join("\n") + Clipboard.setString(info) + }, + }, + ], + ) + }, + "List Available Branches": async () => { + Alert.alert( + "Available Branches", + `This will show branches available for the "${currentChannel}" channel.\n\nRun this command in your terminal:`, + [ + { + text: "Copy Command", + onPress: () => { + Clipboard.setString("eas branch:list") + Alert.alert("Copied", "Command copied to clipboard") + }, + }, + { text: "OK" }, + ], + ) + }, + ...(currentEnv === "preview" + ? { + "Switch to PR Branch (Smart)": async () => { + Alert.prompt( + "Switch to PR Branch", + "Enter the PR number to check compatibility and switch if safe.", + [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Check & Switch", + onPress: async (prNumber) => { + if (!prNumber || isNaN(Number(prNumber))) { + Alert.alert("Invalid Input", "Please enter a valid PR number") + return + } + + try { + useAppStore.getState().actions.setFullScreenLoaderOptions({ + isVisible: true, + texts: ["Checking PR compatibility..."], + }) + + // Check if the PR branch has a compatible runtime version + const currentRuntimeVersion = currentlyRunning.runtimeVersion + const prBranch = `pr-${prNumber}` + + // Try to fetch update info for the PR branch + const checkUpdate = await Updates.checkForUpdateAsync() + + // Override the update check to target the specific PR branch + await Updates.setUpdateURLAndRequestHeadersOverride({ + updateUrl: "https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2", + requestHeaders: { + "expo-channel-name": currentChannel, + "expo-branch-name": prBranch, + }, + }) + + // Now check for the PR update + const prUpdate = await Updates.checkForUpdateAsync() + + if (!prUpdate.isAvailable) { + // Reset override before showing error + await Updates.setUpdateURLAndRequestHeadersOverride({ + updateUrl: "https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2", + requestHeaders: { + "expo-channel-name": currentChannel, + }, + }) + + Alert.alert( + "PR Not Found", + `No update found for PR #${prNumber}.\n\nThis could mean:\n• PR hasn't been created yet\n• PR doesn't have an EAS Update\n• PR requires a new build (native changes)`, + ) + return + } + + // Check runtime version compatibility + const updateManifest = prUpdate.manifest + const prRuntimeVersion = (updateManifest as any)?.runtimeVersion + + if (prRuntimeVersion && prRuntimeVersion !== currentRuntimeVersion) { + // Reset override before showing error + await Updates.setUpdateURLAndRequestHeadersOverride({ + updateUrl: "https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2", + requestHeaders: { + "expo-channel-name": currentChannel, + }, + }) + + Alert.alert( + "āš ļø Incompatible Update", + `PR #${prNumber} requires a different runtime version and will crash your current build.\n\nYour build: ${currentRuntimeVersion}\nPR requires: ${prRuntimeVersion}\n\nšŸ”Ø This PR contains native changes and requires a new build.`, + [ + { + text: "OK", + style: "cancel", + }, + { + text: "Switch Anyway (Will Crash)", + style: "destructive", + onPress: async () => { + await performPRSwitch(prNumber, currentChannel) + }, + }, + ], + ) + return + } + + // Compatible update - safe to switch + Alert.alert( + "āœ… Compatible Update", + `PR #${prNumber} is compatible with your current build.\n\nRuntime version: ${prRuntimeVersion || currentRuntimeVersion}`, + [ + { + text: "Cancel", + style: "cancel", + onPress: async () => { + // Reset override if user cancels + await Updates.setUpdateURLAndRequestHeadersOverride({ + updateUrl: + "https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2", + requestHeaders: { + "expo-channel-name": currentChannel, + }, + }) + }, + }, + { + text: "Switch Now", + onPress: async () => { + // Override is already set, just reload + Alert.alert( + "Branch Override Set", + `Switched to PR #${prNumber} branch.\n\nClose and reopen the app to load the update.`, + [ + { + text: "Reload Now", + onPress: () => Updates.reloadAsync(), + }, + { + text: "Later", + style: "cancel", + }, + ], + ) + }, + }, + ], + ) + } catch (error) { + Alert.alert( + "Check Failed", + `Could not check PR #${prNumber} compatibility.\n\nError: ${error instanceof Error ? error.message : String(error)}\n\nThis might mean the PR doesn't exist or has no EAS Update.`, + ) + } finally { + useAppStore.getState().actions.setFullScreenLoaderOptions({ + isVisible: false, + }) + } + }, + }, + ], + "plain-text", + ) + }, + "Reset Branch Override": async () => { + try { + await Updates.setUpdateURLAndRequestHeadersOverride({ + updateUrl: "https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2", + requestHeaders: { + "expo-channel-name": currentChannel, + }, + }) + + Alert.alert( + "Override Reset", + `Reset to default "${currentChannel}" channel behavior.\n\nClose and reopen the app to apply.`, + [ + { + text: "Reload Now", + onPress: () => Updates.reloadAsync(), + }, + { + text: "Later", + style: "cancel", + }, + ], + ) + } catch (error) { + captureErrorWithToast( + new GenericError({ + error, + additionalMessage: "Error resetting branch override", + }), + { message: "Failed to reset branch override" }, + ) + } + }, + } + : {}), + "Check for Updates": async () => { + try { + const update = await Updates.checkForUpdateAsync() + if (update.isAvailable) { + Alert.alert( + "Update Available", + `A new update is available on the "${currentChannel}" channel.\n\nWould you like to download and install it?`, + [ + { + text: "Cancel", + style: "cancel", + }, + { + text: "Update", + onPress: async () => { + try { + const fetchResult = await Updates.fetchUpdateAsync() + if (fetchResult.isNew) { + await Updates.reloadAsync() + } + } catch (error) { + captureErrorWithToast( + new GenericError({ + error, + additionalMessage: "Error fetching update", + }), + { message: "Failed to fetch update" }, + ) + } + }, + }, + ], + ) + } else { + Alert.alert( + "No Updates", + `No new updates available on the "${currentChannel}" channel.`, + ) + } + } catch (error) { + captureErrorWithToast( + new GenericError({ + error, + additionalMessage: "Error checking for updates", + }), + { message: "Failed to check for updates" }, + ) + } + }, + Cancel: undefined, + } + + // Helper function to perform the PR switch + async function performPRSwitch(prNumber: string, channel: string) { + try { + await Updates.setUpdateURLAndRequestHeadersOverride({ + updateUrl: "https://u.expo.dev/f9089dfa-8871-4aff-93ea-da08af0370d2", + requestHeaders: { + "expo-channel-name": channel, + "expo-branch-name": `pr-${prNumber}`, + }, + }) + + Alert.alert( + "Branch Override Set", + `Switched to PR #${prNumber} branch.\n\nClose and reopen the app to load the update.`, + [ + { + text: "Reload Now", + onPress: () => Updates.reloadAsync(), + }, + { + text: "Later", + style: "cancel", + }, + ], + ) + } catch (error) { + captureErrorWithToast( + new GenericError({ + error, + additionalMessage: "Error switching to PR branch", + }), + { message: "Failed to switch to PR branch" }, + ) + } + } + + const options = Object.keys(updatesMethods) + + showActionSheet({ + options: { + title: `Updates Debug (${currentEnv.toUpperCase()})`, + options, + cancelButtonIndex: options.indexOf("Cancel"), + }, + callback: async (selectedIndex?: number) => { + if (selectedIndex === undefined) { + return + } + const method = updatesMethods[options[selectedIndex] as keyof typeof updatesMethods] + if (method) { + try { + await method() + } catch (error) { + captureError(new GenericError({ error, additionalMessage: "Error in Updates menu" })) + } + } + }, + }) + }, [currentlyRunning]) + const primaryMethods = useMemo(() => { return { Logout: async () => { @@ -1207,9 +1553,18 @@ function useShowDebugMenu() { "Notifications Menu": () => showNotificationsMenu(), "Logs Menu": () => showLogsMenu(), "XMTP Menu": () => showXmtpMenu(), + "Updates Menu": () => showUpdatesMenu(), Cancel: undefined, } - }, [logout, currentlyRunning, showLogsMenu, showNotificationsMenu, showXmtpMenu, showCacheMenu]) + }, [ + logout, + currentlyRunning, + showLogsMenu, + showNotificationsMenu, + showXmtpMenu, + showCacheMenu, + showUpdatesMenu, + ]) const showDebugMenu = useCallback(() => { const options = Object.keys(primaryMethods) diff --git a/features/conversation/conversation-chat/conversation-messages.query.ts b/features/conversation/conversation-chat/conversation-messages.query.ts index 558b0ace..2ea0e8c6 100644 --- a/features/conversation/conversation-chat/conversation-messages.query.ts +++ b/features/conversation/conversation-chat/conversation-messages.query.ts @@ -74,6 +74,8 @@ const conversationMessagesInfiniteQueryFn = async ( direction: "next", } + const isFirstPage = !cursorNs + const resolvedLimit = argLimit || DEFAULT_PAGE_SIZE if (!clientInboxId) { @@ -84,11 +86,13 @@ const conversationMessagesInfiniteQueryFn = async ( throw new Error("xmtpConversationId is required") } - await syncOneXmtpConversation({ - clientInboxId, - xmtpConversationId, - caller: "conversationMessagesInfiniteQueryFn", - }) + if (isFirstPage) { + await syncOneXmtpConversation({ + clientInboxId, + xmtpConversationId, + caller: "conversationMessagesInfiniteQueryFn", + }) + } const disappearingMessagesSettings = await ensureDisappearingMessageSettings({ clientInboxId, @@ -117,7 +121,7 @@ const conversationMessagesInfiniteQueryFn = async ( let combinedMessagesForPage: IConversationMessage[] = [...convosMessagesFromServer] // Only if we're fetching the first page - if (direction === "next" && !cursorNs) { + if (isFirstPage) { const currentInfiniteData = getConversationMessagesInfiniteQueryData({ clientInboxId, xmtpConversationId, diff --git a/features/conversation/conversation-chat/conversation-messages.tsx b/features/conversation/conversation-chat/conversation-messages.tsx index 2a77ff26..72342da0 100644 --- a/features/conversation/conversation-chat/conversation-messages.tsx +++ b/features/conversation/conversation-chat/conversation-messages.tsx @@ -573,7 +573,11 @@ const ConversationMessagesListItem = memo(function ConversationMessagesListItem( nextMessage={nextMessage ?? undefined} > diff --git a/features/conversation/conversation-list/conversation-list.screen.tsx b/features/conversation/conversation-list/conversation-list.screen.tsx index be1992bf..142d29a1 100644 --- a/features/conversation/conversation-list/conversation-list.screen.tsx +++ b/features/conversation/conversation-list/conversation-list.screen.tsx @@ -48,6 +48,7 @@ export const ConversationListScreen = memo(function ConversationListScreen( const insets = useSafeAreaInsets() useConversationListScreenHeader() + // usePreloadRecentConversations({ conversationsIds }) const handleRefresh = useCallback(async () => { try { @@ -193,3 +194,35 @@ const ListHeader = React.memo(function ListHeader() { ) }) + +// function usePreloadRecentConversations(args: { conversationsIds: IXmtpConversationId[] }) { +// const { conversationsIds } = args +// const router = useRouter() +// const currentSender = useSafeCurrentSender() +// const preloadedConversationsRef = useRef(new Set()) + +// useEffectAfterInteractions(() => { +// if (conversationsIds) { +// conversationsIds.forEach((conversationId) => { +// // Skip if already preloaded +// if (preloadedConversationsRef.current.has(conversationId)) { +// return +// } + +// const conversation = getConversationQueryData({ +// clientInboxId: currentSender.inboxId, +// xmtpConversationId: conversationId, +// }) + +// if (conversation) { +// router.preload("Conversation", { +// xmtpConversationId: conversation.xmtpId, +// }) + +// // Mark as preloaded +// preloadedConversationsRef.current.add(conversationId) +// } +// }) +// } +// }, [conversationsIds]) +// } diff --git a/fingerprint.config.js b/fingerprint.config.js new file mode 100644 index 00000000..8bd68273 --- /dev/null +++ b/fingerprint.config.js @@ -0,0 +1,53 @@ +/** @type {import('@expo/fingerprint').Config} */ +const config = { + // Skip certain sources that don't affect native compatibility + sourceSkips: [ + "ExpoConfigVersions", // Skip version changes (handled by our transition logic) + "ExpoConfigNames", // Skip app name changes + "ExpoConfigAssets", // Skip asset changes (icons, splash screens) + "PackageJsonAndroidAndIosScriptsIfNotContainRun", // Skip script changes that don't contain "run" + "GitIgnore", // Skip .gitignore changes + ], + + // Limit concurrent I/O operations for better performance + concurrentIoLimit: 10, + + // Use SHA-256 for better collision resistance + hashAlgorithm: "sha256", + + // Additional paths to ignore beyond .fingerprintignore + ignorePaths: [ + // Temporary files + "**/*.tmp", + "**/*.temp", + + // Log files + "**/*.log", + + // IDE files + ".vscode/**", + ".idea/**", + + // OS files + ".DS_Store", + "Thumbs.db", + + // Build artifacts that don't affect fingerprint + ".expo/**", + "node_modules/**", + + // Documentation + "**/*.md", + "docs/**", + + // Test files + "**/*.test.*", + "**/__tests__/**", + + // GitHub workflows except our PR preview + ".github/workflows/**", + "!.github/workflows/pr-preview.yml", + ], +} + +module.exports = config diff --git a/package.json b/package.json index 28c6e1a1..66526ed6 100644 --- a/package.json +++ b/package.json @@ -178,6 +178,7 @@ "devDependencies": { "@babel/core": "^7.25.2", "@eslint/compat": "^1.2.2", + "@expo/fingerprint": "^0.12.4", "@ianvs/prettier-plugin-sort-imports": "^4.4.1", "@tanstack/eslint-plugin-query": "^5.62.16", "@testing-library/react-native": "^12.6.1", diff --git a/plugins/notification-service-extension/plugin/src/with-my-plugin-ios.ts b/plugins/notification-service-extension/plugin/src/with-my-plugin-ios.ts index 1258a472..e775d3e2 100644 --- a/plugins/notification-service-extension/plugin/src/with-my-plugin-ios.ts +++ b/plugins/notification-service-extension/plugin/src/with-my-plugin-ios.ts @@ -19,7 +19,7 @@ import { } from "./iosConstants" import { Log } from "./Log" -const withNseFilesAndPlistMods: ConfigPlugin = (config) => { +const withNseFilesAndPlistMods: ConfigPlugin = function withNseFilesAndPlistMods(config) { assert(config.ios?.bundleIdentifier, "Missing 'ios.bundleIdentifier' in app config.") const appGroupId = `group.${config.ios.bundleIdentifier}` const keychainGroup = `$(AppIdentifierPrefix)${appGroupId}` // Prefix needed for keychain-access-groups @@ -157,7 +157,7 @@ const withNseFilesAndPlistMods: ConfigPlugin = (config) => { ]) } -const withXcodeProjectSettings: ConfigPlugin = (config) => { +const withXcodeProjectSettings: ConfigPlugin = function withXcodeProjectSettings(config) { return withXcodeProject(config, (newConfig) => { const xcodeProject = newConfig.modResults @@ -241,7 +241,7 @@ const withXcodeProjectSettings: ConfigPlugin = (config) => { }) } -const withPodfile: ConfigPlugin = (config) => { +const withPodfile: ConfigPlugin = function withPodfile(config) { return withDangerousMod(config, [ "ios", async (config) => { @@ -279,7 +279,7 @@ end ]) } -const withEasManagedCredentials: ConfigPlugin = (config) => { +const withEasManagedCredentials: ConfigPlugin = function withEasManagedCredentials(config) { const bundleIdentifier = config?.ios?.bundleIdentifier config.extra = { ...config.extra, @@ -310,7 +310,7 @@ const withEasManagedCredentials: ConfigPlugin = (config) => { return config } -export const withMyPluginTwoIos: ConfigPlugin = (config, props) => { +export const withMyPluginTwoIos: ConfigPlugin = function withMyPluginTwoIos(config, props) { // 1. Copy files AND modify copied entitlements/Info.plist config = withNseFilesAndPlistMods(config) // 2. Set up the Xcode project target, linking files and setting build settings diff --git a/plugins/notification-service-extension/plugin/src/with-my-plugin.ts b/plugins/notification-service-extension/plugin/src/with-my-plugin.ts index 35e64dfb..e8f6584a 100644 --- a/plugins/notification-service-extension/plugin/src/with-my-plugin.ts +++ b/plugins/notification-service-extension/plugin/src/with-my-plugin.ts @@ -1,6 +1,8 @@ import { withPlugins, type ConfigPlugin } from "@expo/config-plugins" import { withMyPluginTwoIos } from "./with-my-plugin-ios" -export const withMyPluginTwo: ConfigPlugin = (config) => withPlugins(config, [withMyPluginTwoIos]) +export const withMyPluginTwo: ConfigPlugin = function withMyPluginTwo(config) { + return withPlugins(config, [withMyPluginTwoIos]) +} export default withMyPluginTwo diff --git a/scripts/check-runtime-compatibility.js b/scripts/check-runtime-compatibility.js new file mode 100755 index 00000000..9b914375 --- /dev/null +++ b/scripts/check-runtime-compatibility.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +const { execSync } = require("child_process") +const { existsSync } = require("fs") + +/** + * Executes a command and returns the output, or null if it fails + */ +function execCommand(command, options = {}) { + try { + const result = execSync(command, { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + ...options, + }) + return result.trim() + } catch (error) { + console.error(`Command failed: ${command}`) + console.error(`Error: ${error.message}`) + return null + } +} + +/** + * Gets the current project's fingerprint using the official Expo CLI + */ +async function getCurrentFingerprint() { + console.log("šŸ” Getting current project fingerprint...") + + // Use the official fingerprint CLI + const output = execCommand("EXPO_ENV=preview npx @expo/fingerprint fingerprint:generate") + + if (!output) { + console.error("āŒ Failed to generate fingerprint") + return null + } + + // The fingerprint output is a JSON object with a final "hash" field + // Extract the last hash value from the output + const hashMatch = output.match(/"hash":"([^"]+)"/g) + + if (hashMatch && hashMatch.length > 0) { + // Get the last hash (which is the final fingerprint) + const lastHash = hashMatch[hashMatch.length - 1] + const fingerprint = lastHash.match(/"hash":"([^"]+)"/)[1] + + console.log(`āœ… Current fingerprint: ${fingerprint}`) + return fingerprint + } + + console.error("āŒ Could not extract fingerprint from output") + return null +} + +/** + * Gets the latest preview build's runtime version from EAS + */ +async function getPreviewBuildRuntimeVersion() { + console.log("šŸ” Getting latest preview build runtime version...") + + const output = execCommand( + "eas build:list --platform=all --profile=preview --limit=1 --json --non-interactive", + ) + + if (!output) { + console.error("āŒ Failed to get build list") + return null + } + + try { + const builds = JSON.parse(output) + if (builds.length === 0) { + console.error("āŒ No preview builds found") + return null + } + + const latestBuild = builds[0] + const runtimeVersion = latestBuild.runtimeVersion + + console.log(`āœ… Latest preview build runtime version: ${runtimeVersion}`) + return runtimeVersion + } catch (error) { + console.error("āŒ Failed to parse build list JSON:", error.message) + return null + } +} + +/** + * Main function to check runtime compatibility + */ +async function checkRuntimeCompatibility() { + console.log("šŸš€ Checking runtime compatibility for PR preview...\n") + + const currentFingerprint = await getCurrentFingerprint() + const previewRuntimeVersion = await getPreviewBuildRuntimeVersion() + + if (!currentFingerprint || !previewRuntimeVersion) { + console.log("\nāŒ Cannot determine compatibility - missing data") + process.exit(1) + } + + console.log("\nšŸ“Š Comparison:") + console.log(`Current PR fingerprint: ${currentFingerprint}`) + console.log(`Preview build runtime: ${previewRuntimeVersion}`) + + // Check if they match exactly + if (currentFingerprint === previewRuntimeVersion) { + console.log("\nāœ… Runtime versions match - EAS Update is compatible!") + console.log("can_create_update=true") + setGitHubOutput("can_create_update", "true") + setGitHubOutput("current_fingerprint", currentFingerprint) + setGitHubOutput("preview_fingerprint", previewRuntimeVersion) + process.exit(0) + } + + // Check if preview build uses old version format (like "1.0.1") + // and current app version matches + if (previewRuntimeVersion.match(/^\d+\.\d+\.\d+$/)) { + console.log("\nāš ļø Preview build uses old runtime version format") + + // Get current app version + const configOutput = execCommand("EXPO_ENV=preview npx expo config --json") + if (configOutput) { + try { + const config = JSON.parse(configOutput) + const appVersion = config.version + + if (appVersion === previewRuntimeVersion) { + console.log(`āœ… App version (${appVersion}) matches build runtime version`) + console.log("can_create_update=true") + console.log( + "\nšŸ’” Consider creating a new preview build with fingerprint policy for better compatibility detection", + ) + setGitHubOutput("can_create_update", "true") + setGitHubOutput("current_fingerprint", currentFingerprint) + setGitHubOutput("preview_fingerprint", previewRuntimeVersion) + process.exit(0) + } + } catch (error) { + console.error("Failed to parse expo config:", error.message) + } + } + } + + console.log("\nāŒ Runtime versions do not match - EAS Update would be incompatible!") + console.log("can_create_update=false") + console.log("\nšŸ’” This PR likely contains native changes that require a new build") + setGitHubOutput("can_create_update", "false") + setGitHubOutput("current_fingerprint", currentFingerprint) + setGitHubOutput("preview_fingerprint", previewRuntimeVersion) + process.exit(1) +} + +// Set GitHub Actions outputs if running in CI +function setGitHubOutput(key, value) { + if (process.env.GITHUB_OUTPUT) { + const fs = require("fs") + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${key}=${value}\n`) + } +} + +// Run the check +checkRuntimeCompatibility().catch((error) => { + console.error("āŒ Script failed:", error.message) + setGitHubOutput("can_create_update", "false") + process.exit(1) +}) + +module.exports = { checkRuntimeCompatibility } diff --git a/yarn.lock b/yarn.lock index 06909eb1..0a50097c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3253,6 +3253,26 @@ __metadata: languageName: node linkType: hard +"@expo/fingerprint@npm:^0.12.4": + version: 0.12.4 + resolution: "@expo/fingerprint@npm:0.12.4" + dependencies: + "@expo/spawn-async": "npm:^1.7.2" + arg: "npm:^5.0.2" + chalk: "npm:^4.1.2" + debug: "npm:^4.3.4" + find-up: "npm:^5.0.0" + getenv: "npm:^1.0.0" + minimatch: "npm:^9.0.0" + p-limit: "npm:^3.1.0" + resolve-from: "npm:^5.0.0" + semver: "npm:^7.6.0" + bin: + fingerprint: bin/cli.js + checksum: 10c0/3cac838023567cafd2e3d53e681b6c00fad887152f31adb2fdeed0eeffcb0ad59c73b17e012b52884a081043b2bcd3250432c517f6ea52fef98df26b0f13474c + languageName: node + linkType: hard + "@expo/image-utils@npm:^0.3.22": version: 0.3.23 resolution: "@expo/image-utils@npm:0.3.23" @@ -11016,6 +11036,7 @@ __metadata: "@dev-plugins/react-query": "npm:~0.2.0" "@eslint/compat": "npm:^1.2.2" "@expo/config-plugins": "npm:~9.0.0" + "@expo/fingerprint": "npm:^0.12.4" "@expo/metro-config": "npm:~0.19.0" "@expo/metro-runtime": "npm:~4.0.0" "@expo/react-native-action-sheet": "npm:^4.0.1" @@ -17971,7 +17992,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.4": +"minimatch@npm:^9.0.0, minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: