feat(native): deferred deep linking through the store install (TASK-20772) - #2557
feat(native): deferred deep linking through the store install (TASK-20772)#2557kushagrasarathe wants to merge 5 commits into
Conversation
Third fix, same root cause each time: I verified in an environment that was not
the one it runs in.
GitHub runs the step as `bash -e {0}`. Combined with `set -o pipefail` that
means any non-zero anywhere kills the script at that line with NO output — a red
run and an empty log, which is the least debuggable possible failure. It hit
twice: the Actions-API 403, then `grep` returning 1 because a commit touched no
mirrored paths. grep exiting 1 on no-match is a normal answer, not an error, and
it was the very first loop iteration — so the last run produced literally
nothing between the env block and `exit 1`.
`set +e` explicitly. Every branch here decides for itself and reports why; `-e`
adds nothing but the ability to die mid-check without saying so.
Also filter LIVE/TIP/EXPECTED through a 40-hex check. On failure `gh` prints its
error body to stdout, which was landing in those vars and being compared as if
it were a sha; now a broken call resolves to empty and trips the labelled
DEGRADED branch instead.
Verified by extracting the step and running it under `bash -e` against the real
APIs, both paths: healthy -> "production content is current", exit 0, silent;
broken token -> one WATCHDOG DEGRADED line, exit 1. That is the check I should
have been running from the first commit.
…0772) Carry locale + invite code + campaign tag + destination path from mobile web through the app-store hop. Android rides the Play Install Referrer (app-local InstallReferrerPlugin); iOS rides a clipboard hand-off gated by the prompt-free clipboardHasStrings check. One-shot restore on first launch applies the existing inviteCode/campaignTag cookies, persists preferredLocale for future in-app i18n, and navigates only when the launch wasn't already a real deep link.
dev's in-app i18n landed while this branched off main — the restore now returns the normalized AppLocale and applies it via useAppLocale's setLocale (live switch + native Preferences persistence) instead of a parallel localStorage key. unsupported tags return null so a garbage payload can never override the device language.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughChangesDeferred deep-link flow
Content pipeline watchdog resilience
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant WebPage
participant Store
participant NativeApp
participant DeferredLink
WebPage->>DeferredLink: build payload and handoff URL
DeferredLink->>Store: encode payload in Play referrer or iOS handoff
Store->>NativeApp: install and launch
NativeApp->>DeferredLink: restore deferred context
DeferredLink-->>NativeApp: destination and locale
NativeApp->>NativeApp: apply locale and navigate when eligible
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install timed out. The project may have too many dependencies for the sandbox. Comment |
🧪 UI test report — ✅ all greenSuites
📊 Coverage (unit)
⏱ 10 slowest test cases
|
Code-analysis diffPainscore total: 6656.05 → 6671.93 (+15.88) 🆕 New findings (14)
✅ Resolved (8)
📈 Painscore deltas (top movers)
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
- 30-day normalized cookies (session cookies died before signup; uppercase
campaign tags broke the case-sensitive badge award)
- strip marketing locale prefix from dest (native export has no /{locale})
- InstallReferrerPlugin: resolve exactly once + disconnect handler + 5s
timeout so the PluginCall can never hang app init
- restore no longer awaited in init: iOS paste prompt / slow referrer
service can't block the push listener or splash hide
- dest yields only to a deep link that actually navigated, not to a
rejected launch url; locale applies after startup locale paints
- iOS prompt gated by prompt-free UIPasteboard.detectPatterns probable-URL
check: unrelated clipboard text never triggers the paste alert
- in-flight guard: strict-mode double-effects share one read
- hook wiring tests (dest precedence, locale apply, failure isolation)
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/content-pipeline-watchdog.yml:
- Around line 94-102: Sanitize every API-derived value before use: apply the
existing only_sha helper to TIP_AT before the date/AGE calculation, ensuring
invalid or failed responses produce the safe empty path rather than reaching
date -d. Update MIRRORED validation to require an exact 40-character lowercase
SHA, matching only_sha, before performing the prefix comparison.
In `@src/app/dev/deferred/page.tsx`:
- Around line 22-59: Gate DeferredLinkDevPage from production access by
restoring the existing /dev proxy restriction; alternatively, remove its
mutating controls and related state-changing actions, including simulate/apply
behavior that can rewrite cookies or clear deferredLinkConsumed. Keep the
diagnostic functionality only where it cannot modify production state.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: e2d0ea92-320f-4b98-b2a1-54ad0afa0785
⛔ Files ignored due to path filters (4)
android/app/build.gradleis excluded by!android/**android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.javais excluded by!android/**android/app/src/main/java/me/peanut/wallet/MainActivity.javais excluded by!android/**ios/App/App/ClipboardDetectPlugin.swiftis excluded by!ios/**
📒 Files selected for processing (8)
.github/workflows/content-pipeline-watchdog.ymlsrc/app/dev/deferred/page.tsxsrc/constants/general.consts.tssrc/hooks/__tests__/useNativePlugins.test.tsxsrc/hooks/useNativePlugins.tssrc/utils/__tests__/deferred-link.test.tssrc/utils/clipboard-detect.tssrc/utils/deferred-link.ts
| # On failure `gh` prints its error body to stdout, which would land | ||
| # in these vars and read as a bogus sha. Keep only real ones, so a | ||
| # broken call resolves to empty and trips the DEGRADED branch below | ||
| # instead of being compared as if it were data. | ||
| only_sha() { printf '%s' "${1:-}" | grep -Eox '[0-9a-f]{40}' || true; } | ||
| LIVE=$(only_sha "$LIVE") | ||
| TIP=$(only_sha "$TIP") | ||
| EXPECTED=$(only_sha "$EXPECTED") | ||
|
|
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Sanitize all API-derived values before continuing.
only_sha() protects LIVE, TIP, and EXPECTED, but a failed TIP_AT request can still place gh’s error body into TIP_AT; Line [135] then passes it to date -d, potentially leaving AGE invalid and aborting under set -u. Also validate MIRRORED from Line [103] as an exact 40-character SHA instead of accepting [0-9a-f]+ before the prefix comparison.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/content-pipeline-watchdog.yml around lines 94 - 102,
Sanitize every API-derived value before use: apply the existing only_sha helper
to TIP_AT before the date/AGE calculation, ensuring invalid or failed responses
produce the safe empty path rather than reaching date -d. Update MIRRORED
validation to require an exact 40-character lowercase SHA, matching only_sha,
before performing the prefix comparison.
| export default function DeferredLinkDevPage() { | ||
| const { locale, setLocale } = useAppLocale() | ||
| const [state, setState] = useState<Record<string, string>>({}) | ||
| const [payload, setPayload] = useState('') | ||
| const [rawReferrer, setRawReferrer] = useState('(not read)') | ||
| const [simulateInput, setSimulateInput] = useState('') | ||
| const [simulateResult, setSimulateResult] = useState('') | ||
| const [copied, setCopied] = useState(false) | ||
|
|
||
| const refresh = useCallback(() => { | ||
| setState({ | ||
| platform: getPlatform(), | ||
| appLocale: locale, | ||
| consumedFlag: localStorage.getItem(CONSUMED_KEY) ?? '(unset)', | ||
| inviteCodeCookie: String(getFromCookie('inviteCode') ?? '(unset)'), | ||
| campaignTagCookie: String(getFromCookie('campaignTag') ?? '(unset)'), | ||
| }) | ||
| }, [locale]) | ||
|
|
||
| useEffect(() => refresh(), [refresh]) | ||
|
|
||
| const readReferrer = async () => { | ||
| setRawReferrer((await readInstallReferrer()) ?? '(null / unavailable)') | ||
| } | ||
|
|
||
| const simulate = async () => { | ||
| const parsed = parseDeferredPayload(simulateInput) | ||
| if (!parsed) { | ||
| setSimulateResult('rejected (no pnutdl marker)') | ||
| } else { | ||
| const { dest, locale: restoredLocale } = applyDeferredPayload(parsed) | ||
| if (restoredLocale) await setLocale(restoredLocale) | ||
| setSimulateResult( | ||
| `applied ${JSON.stringify(parsed)} → dest: ${dest ?? 'none'}, locale: ${restoredLocale ?? 'none'}` | ||
| ) | ||
| } | ||
| refresh() | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
fd -t f middleware.ts src
rg -n "NODE_ENV|process.env" src/app/dev --type=tsx --type=ts -g '!**/node_modules/**'
fd -t d dev src/appRepository: peanutprotocol/peanut-ui
Length of output: 260
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== app/dev tree ==\n'
find src/app/dev -maxdepth 3 -type f | sort
printf '\n== app route/layout files near dev ==\n'
find src/app -maxdepth 3 \( -name 'layout.tsx' -o -name 'page.tsx' -o -name 'middleware.ts' -o -name 'route.ts' \) | sort | sed -n '1,200p'
printf '\n== search for dev-only gating ==\n'
rg -n "NODE_ENV|process\.env|DEV|development|production|draftMode|redirect\\(|notFound\\(" src/app src/middleware.ts src -g '!**/node_modules/**' || trueRepository: peanutprotocol/peanut-ui
Length of output: 50380
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- src/app/dev/deferred/page.tsx ---'
cat -n src/app/dev/deferred/page.tsx | sed -n '1,220p'
echo
echo '--- src/app/dev tree files ---'
find src/app/dev -maxdepth 3 -type f | sort | xargs -I{} sh -c 'echo; echo "### {}"; cat -n "{}" | sed -n "1,220p"'
echo
echo '--- search for route-level gating around dev paths ---'
rg -n "redirect\\(|notFound\\(|draftMode|process\\.env|NODE_ENV|export const dynamic|export const runtime|cookies\\(|headers\\(" src/app/dev src/app -g '!**/node_modules/**' || trueRepository: peanutprotocol/peanut-ui
Length of output: 50382
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- src/proxy.ts ---'
cat -n src/proxy.ts | sed -n '1,120p'
echo
echo '--- deferred-link utilities ---'
rg -n "export .*applyDeferredPayload|export .*parseDeferredPayload|export .*CONSUMED_KEY|inviteCode|campaignTag|setCookie|cookie" src/utils/deferred-link* src/utils -g '!**/node_modules/**' || true
echo
echo '--- src/utils/deferred-link file(s) ---'
for f in $(git ls-files 'src/utils/deferred-link*'); do
echo
echo "### $f"
cat -n "$f" | sed -n '1,260p'
doneRepository: peanutprotocol/peanut-ui
Length of output: 27478
Gate /dev/deferred behind a production check. /dev/* is already reachable on peanut.me, so this page can still rewrite inviteCode/campaignTag cookies and clear deferredLinkConsumed in production. If it’s meant to stay internal, re-add the /dev block in the proxy or remove the mutating controls from the shipped page.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/dev/deferred/page.tsx` around lines 22 - 59, Gate DeferredLinkDevPage
from production access by restoring the existing /dev proxy restriction;
alternatively, remove its mutating controls and related state-changing actions,
including simulate/apply behavior that can rewrite cookies or clear
deferredLinkConsumed. Keep the diagnostic functionality only where it cannot
modify production state.
|
Superseded by #2559 — the rebase onto dev had replayed main's watchdog hotfix (e7c688b) into this branch, polluting the diff with a file outside this feature's scope. #2559 is the identical feature set cherry-picked onto a clean dev base (plus the /dev/deferred prod-wall from the review here). Closing. |
Summary
TASK-20772 — deferred deep linking v1 (DIY). When a mobile-web user is bounced to the Play Store / App Store, their context (locale, invite code, campaign tag, destination path) now survives the install into the native app:
&referrer=<payload>to the Play URL; a new app-local Capacitor plugin (InstallReferrerPlugin.java, Play Install Referrer library) reads it back on first launch. Deterministic, first-party.https://peanut.me/?pnutdl=1&…to the clipboard; first launch reads it via@capacitor/clipboard, gated by the existing prompt-freeclipboardHasStrings()so organic installs with an empty clipboard never see the iOS paste prompt.deferredLinkConsumedflag): sets the existinginviteCode/campaignTagcookies (setup flow already reads them), applies the locale live viauseAppLocale().setLocale(normalized throughresolveLocale— unsupported tags are dropped so a garbage payload can never override the device language), and navigates to the destination only when the launch wasn't already a real deep link./dev/deferredtest page only — the user-facing download modal (TASK-20769) will consumebuildDeferredPayload/playStoreUrlWithReferrer/copyIOSHandoffwhen it lands.Design notes / accepted trade-offs
localStorage(survives Capgo OTA; resets on reinstall — which is exactly fresh-install semantics).hasStrings+ newdetectPatternsprobable-web-URL inClipboardDetectPlugin.swift): organic installs with unrelated clipboard text (a password, a draft) are never prompted — only a clipboard that plausibly holds the hand-off URL is worth the alert./es-419/…) are stripped from dests on both sides — those routes don't exist in the native static export.utm_source=google-play…) is rejected by thepnutdl=1marker.Risks / breaking changes
ClipboardDetectPlugin.swift— rides the next IPA (iOS is mid-submission; no binaries in the field). On a binary without the method the JS skips the clipboard read entirely: no prompt, no restore, status quo.QA
deferred-link.test.ts): build→parse round-trip, Play URL encoding hop, iOS hand-off form, organic-referrer rejection, one-shot consume, off-origin dest rejection, locale normalization.:app:compileDebugJavaWithJavacgreen with the new plugin./dev/deferredshows restored state); Android via Play internal testing track with a&referrer=install link; sideloaded builds verified to no-op.Screenshots: N/A (no visible change — dev-only page + native plumbing)
Summary by CodeRabbit
New Features
Bug Fixes
Tests