From e7c688b3ae1c6e43a3f9f69a2bce1d154a7105d8 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Tue, 28 Jul 2026 11:59:08 +0100 Subject: [PATCH 1/5] fix(watchdog): stop the script dying silently under bash -e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../workflows/content-pipeline-watchdog.yml | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/.github/workflows/content-pipeline-watchdog.yml b/.github/workflows/content-pipeline-watchdog.yml index c230a0c79..b3aad9a5e 100644 --- a/.github/workflows/content-pipeline-watchdog.yml +++ b/.github/workflows/content-pipeline-watchdog.yml @@ -49,11 +49,15 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | set -uo pipefail - - # Every API call is `|| true`: the step shell is `bash -e`, so an - # unguarded 403 aborts mid-script and surfaces as a bare red run - # indistinguishable from a real content alert. That happened once - # (actions:read 403). Collect what we can, then decide explicitly. + # `set +e` is deliberate, and `-e` here is actively harmful. + # GitHub runs this step as `bash -e {0}`, which combined with + # pipefail means ANY non-zero anywhere kills the script at that + # line with no output at all — a red run and an empty log. It bit + # twice: a 403 from the Actions API, then a `grep` with no match + # inside the mirrored-paths filter (grep exits 1 on no-match, which + # is a normal answer, not an error). This script decides everything + # explicitly below; it must never die silently mid-check. + set +e # ---- hop 3: what production is serving ----------------------- LIVE=$(GH_TOKEN=$UI_TOKEN gh api "repos/$REPO/contents/src/content?ref=main" --jq '.sha' || true) @@ -87,6 +91,15 @@ jobs: if [ -n "$PUBLISHED" ]; then EXPECTED="$SHA"; break; fi done + # 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") + MIRRORED=$(printf '%s' "$TIP_MSG" | sed -nE 's/.*mono@([0-9a-f]+).*/\1/p') echo "expected mono@${EXPECTED:0:7} | mirrored @${MIRRORED:-none} | peanut-content ${TIP:0:7} | live ${LIVE:0:7}" From 20ddaf3cd91c2c76690a19ab071360f2c10e0a94 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:26:27 +0530 Subject: [PATCH 2/5] feat(native): deferred deep linking through the store install (TASK-20772) 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. --- android/app/build.gradle | 3 + .../peanut/wallet/InstallReferrerPlugin.java | 57 ++++++ .../java/me/peanut/wallet/MainActivity.java | 2 + src/app/dev/deferred/page.tsx | 141 ++++++++++++++ src/constants/general.consts.ts | 2 + src/hooks/useNativePlugins.ts | 7 + src/utils/__tests__/deferred-link.test.ts | 162 ++++++++++++++++ src/utils/deferred-link.ts | 180 ++++++++++++++++++ 8 files changed, 554 insertions(+) create mode 100644 android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java create mode 100644 src/app/dev/deferred/page.tsx create mode 100644 src/utils/__tests__/deferred-link.test.ts create mode 100644 src/utils/deferred-link.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index cf4bb6fbc..d856012c6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,6 +92,9 @@ dependencies { implementation "androidx.credentials:credentials:1.5.0" implementation "androidx.credentials:credentials-play-services-auth:1.5.0" + // play install referrer — deferred deep linking (InstallReferrerPlugin.java) + implementation "com.android.installreferrer:installreferrer:2.2" + testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java b/android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java new file mode 100644 index 000000000..36b7b76e7 --- /dev/null +++ b/android/app/src/main/java/me/peanut/wallet/InstallReferrerPlugin.java @@ -0,0 +1,57 @@ +package me.peanut.wallet; + +import com.android.installreferrer.api.InstallReferrerClient; +import com.android.installreferrer.api.InstallReferrerStateListener; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * exposes the play install referrer to JS for deferred deep linking: the web + * store-bounce appends ?referrer= to the play url, and after install + * the JS side (src/utils/deferred-link.ts) reads it back here exactly once. + * always resolves — {referrer: null} on any failure — so the JS restore path + * can treat every outcome the same way. + */ +@CapacitorPlugin(name = "InstallReferrer") +public class InstallReferrerPlugin extends Plugin { + + @PluginMethod + public void getReferrer(PluginCall call) { + final InstallReferrerClient client = InstallReferrerClient.newBuilder(getContext()).build(); + try { + client.startConnection(new InstallReferrerStateListener() { + @Override + public void onInstallReferrerSetupFinished(int responseCode) { + JSObject ret = new JSObject(); + try { + if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) { + ret.put("referrer", client.getInstallReferrer().getInstallReferrer()); + } else { + // SERVICE_UNAVAILABLE / FEATURE_NOT_SUPPORTED / DEVELOPER_ERROR + ret.put("referrer", (String) null); + } + } catch (Exception e) { + ret.put("referrer", (String) null); + } finally { + try { + client.endConnection(); + } catch (Exception ignored) {} + } + call.resolve(ret); + } + + @Override + public void onInstallReferrerServiceDisconnected() { + // one-shot read; the JS consumed flag prevents retries + } + }); + } catch (Exception e) { + JSObject ret = new JSObject(); + ret.put("referrer", (String) null); + call.resolve(ret); + } + } +} diff --git a/android/app/src/main/java/me/peanut/wallet/MainActivity.java b/android/app/src/main/java/me/peanut/wallet/MainActivity.java index 943813c9f..761e55f94 100644 --- a/android/app/src/main/java/me/peanut/wallet/MainActivity.java +++ b/android/app/src/main/java/me/peanut/wallet/MainActivity.java @@ -14,6 +14,8 @@ public class MainActivity extends BridgeActivity { @Override protected void onCreate(Bundle savedInstanceState) { + // app-local plugin, not auto-discovered — must register before super.onCreate + registerPlugin(InstallReferrerPlugin.class); super.onCreate(savedInstanceState); Bridge bridge = this.getBridge(); diff --git a/src/app/dev/deferred/page.tsx b/src/app/dev/deferred/page.tsx new file mode 100644 index 000000000..de945c866 --- /dev/null +++ b/src/app/dev/deferred/page.tsx @@ -0,0 +1,141 @@ +'use client' + +// dev tool for TASK-20772 deferred deep linking: inspect the hand-off state, +// build/copy payloads on web, read the raw android referrer, and simulate a +// restore from any raw string (the android dev loop — real referrer data only +// exists for play-delivered installs). +import { useCallback, useEffect, useState } from 'react' +import { registerPlugin } from '@capacitor/core' +import { + applyDeferredPayload, + buildDeferredPayload, + copyIOSHandoff, + iosHandoffString, + parseDeferredPayload, + playStoreUrlWithReferrer, + CONSUMED_KEY, + PREFERRED_LOCALE_KEY, +} from '@/utils/deferred-link' +import { getFromCookie } from '@/utils/general.utils' +import { getPlatform } from '@/utils/capacitor' + +const InstallReferrer = registerPlugin<{ getReferrer(): Promise<{ referrer: string | null }> }>('InstallReferrer') + +export default function DeferredLinkDevPage() { + const [state, setState] = useState>({}) + 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(), + consumedFlag: localStorage.getItem(CONSUMED_KEY) ?? '(unset)', + preferredLocale: localStorage.getItem(PREFERRED_LOCALE_KEY) ?? '(unset)', + inviteCodeCookie: String(getFromCookie('inviteCode') ?? '(unset)'), + campaignTagCookie: String(getFromCookie('campaignTag') ?? '(unset)'), + }) + }, []) + + useEffect(() => refresh(), [refresh]) + + const readReferrer = async () => { + try { + const { referrer } = await InstallReferrer.getReferrer() + setRawReferrer(referrer ?? '(null)') + } catch (e) { + setRawReferrer(`unavailable: ${e instanceof Error ? e.message : String(e)}`) + } + } + + const simulate = () => { + const parsed = parseDeferredPayload(simulateInput) + if (!parsed) { + setSimulateResult('rejected (no pnutdl marker)') + } else { + const dest = applyDeferredPayload(parsed) + setSimulateResult(`applied ${JSON.stringify(parsed)} → dest: ${dest ?? 'none'}`) + } + refresh() + } + + return ( +
+

deferred deep link — dev

+ +
+

state

+
{JSON.stringify(state, null, 2)}
+
+ + +
+
+ +
+

web → store hand-off

+ + {payload && ( +
+                        payload: {payload}
+                        {'\n\n'}play url: {playStoreUrlWithReferrer(payload)}
+                        {'\n\n'}ios hand-off: {iosHandoffString(payload)}
+                    
+ )} + {payload && ( + + )} +
+ +
+

native: raw install referrer

+ +
{rawReferrer}
+
+ +
+

simulate restore

+