diff --git a/doc/share-target-ios-audit.md b/doc/share-target-ios-audit.md index 035f2573..0a4c3a02 100644 --- a/doc/share-target-ios-audit.md +++ b/doc/share-target-ios-audit.md @@ -436,6 +436,81 @@ All removal logic was in `SharedImageUtility.getSharedImageData()`: --- +## Deterministic Startup Plugin Readiness + +**Implemented:** 2026-06-26 (Phase 2A) + +Phase 2A removes the iOS startup race between native `SharedImage` plugin +registration and the first JS shared-image check. All changes are confined to +`src/main.capacitor.ts`; no Swift code changed. + +### Race condition removed + +Previously the initial iOS shared-image check ran on a fixed timer: + +```ts +const checkDelays = ... : [1000]; // iOS +checkDelays.forEach((delay) => setTimeout(() => checkForSharedImageAndNavigate(), delay)); +``` + +The native `SharedImagePlugin` is registered asynchronously by +`AppDelegate.didFinishLaunchingWithOptions` with up to 5 retries starting at +T+500ms (`AppDelegate.swift:21–40`). The fixed 1000ms JS delay only *assumed* +registration had completed by then. When registration was slow (or the WebView +booted unusually fast), the first `checkForSharedImageAndNavigate()` could call +`SharedImage.getSharedImage()` before the native plugin existed, the call would +throw, and the cold-start share could be missed until a later `appStateChange`. +This corresponds to race condition #3 in *Potential Race Conditions* above. + +### How plugin readiness is now determined + +The fixed 1000ms iOS delay is replaced with an explicit, deterministic wait +(`waitForSharedImagePluginReady()` in `main.capacitor.ts`): + +- The plugin is probed with a lightweight, read-only `SharedImage.hasSharedImage()` + call. A successful resolution proves the native plugin instance is registered + and reachable from JS. `hasSharedImage()` does not consume or mutate the pending + share (non-destructive since Phase 1C), so probing is side-effect free. +- If the probe throws (plugin not yet registered), it retries within a bounded + budget: `STARTUP_PLUGIN_MAX_ATTEMPTS = 10` attempts spaced + `STARTUP_PLUGIN_RETRY_DELAY_MS = 300` ms apart (~3s ceiling, covering the + native registration window). No arbitrary sleep is used to *assume* readiness; + the delay is only the inter-retry backoff while polling for actual availability. +- The very first `checkForSharedImageAndNavigate()` runs only after the probe + succeeds. If the budget is exhausted (should not happen in practice), the check + is still attempted once as a best-effort fallback so behavior is never worse + than the previous fixed-delay path, and `appStateChange` retries on the next + activation. + +### Temporary diagnostics + +The retry sequence emits `[ShareTarget]` console diagnostics, consistent with the +existing TEMPORARY SHARE TARGET DIAGNOSTICS convention: + +``` +[ShareTarget] Startup shared-image check waiting for SharedImage plugin +[ShareTarget] SharedImage plugin ready after N attempt(s) +[ShareTarget] Startup shared-image check giving up after N attempt(s) +``` + +### Phase 2A Scope (Intentionally Unchanged) + +The retry/readiness logic applies **only** to the initial startup shared-image +check. The following are deliberately untouched: + +- `appStateChange` handling (`CapacitorApp.addListener("appStateChange", ...)`) +- `appUrlOpen` handling (`handleDeepLink`, `registerDeepLinkListener`) +- Router navigation to `/shared-photo` +- Share processing (`checkAndStoreNativeSharedImage`, `storeSharedImageInTempDB`) +- Android startup behavior (still `[500, 1500, 3000]` ms multi-delay checks) +- All native Swift code, including the `AppDelegate` plugin-registration retry + +Because readiness is now confirmed by an actual plugin response rather than a +timer, the startup check no longer depends on registration timing, while every +other detection path keeps its previous semantics as redundant backstops. + +--- + ## Configuration References | Resource | Value | diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 95ae27e1..83dcdbd0 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -74,18 +74,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = TimeSafariShareExtension; - sourceTree = ""; - }; + C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index 93f393e1..e22faa3a 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -462,25 +462,94 @@ logger.log("[Capacitor] 🚀 Mounting app"); app.mount("#app"); logger.info(`[Main] ✅ App mounted successfully`); +/** + * Phase 2A: deterministic startup readiness for the SharedImage plugin (iOS). + * + * The native SharedImage plugin is registered asynchronously by AppDelegate + * (with its own retry budget), so a fixed startup delay cannot guarantee the + * plugin is reachable from JS before the first shared-image check. Instead of + * assuming readiness after an arbitrary sleep, we probe the plugin with a + * lightweight, read-only hasSharedImage() call and retry for a short bounded + * period until it responds or the budget is exhausted. + * + * This applies ONLY to the very first startup shared-image check. The + * appStateChange and appUrlOpen paths are intentionally left unchanged. + */ +const STARTUP_PLUGIN_MAX_ATTEMPTS = 10; +const STARTUP_PLUGIN_RETRY_DELAY_MS = 300; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Wait until the iOS SharedImage native plugin is available. + * + * Probes the plugin with a read-only hasSharedImage() call; a successful + * resolution means the native plugin instance is registered and reachable. + * Retries up to STARTUP_PLUGIN_MAX_ATTEMPTS with STARTUP_PLUGIN_RETRY_DELAY_MS + * between attempts. + * + * @returns true once the plugin responds, false if the retry budget is exhausted + */ +async function waitForSharedImagePluginReady(): Promise { + // TEMPORARY SHARE TARGET DIAGNOSTICS + console.info( + "[ShareTarget] Startup shared-image check waiting for SharedImage plugin", + ); + + for (let attempt = 1; attempt <= STARTUP_PLUGIN_MAX_ATTEMPTS; attempt++) { + try { + // Lightweight, read-only probe. hasSharedImage() does not consume or + // mutate the pending share, so probing is side-effect free. + await SharedImage.hasSharedImage(); + // TEMPORARY SHARE TARGET DIAGNOSTICS + console.info( + `[ShareTarget] SharedImage plugin ready after ${attempt} attempt(s)`, + ); + return true; + } catch (error) { + // Plugin not registered yet; wait and retry within the bounded budget. + logger.debug( + `[Main] SharedImage plugin not ready (attempt ${attempt}/${STARTUP_PLUGIN_MAX_ATTEMPTS}):`, + error instanceof Error ? error.message : String(error), + ); + if (attempt < STARTUP_PLUGIN_MAX_ATTEMPTS) { + await sleep(STARTUP_PLUGIN_RETRY_DELAY_MS); + } + } + } + + // TEMPORARY SHARE TARGET DIAGNOSTICS + console.info( + `[ShareTarget] Startup shared-image check giving up after ${STARTUP_PLUGIN_MAX_ATTEMPTS} attempt(s)`, + ); + return false; +} + // Check for shared image on initial load (in case app was launched from share sheet) // On Android, share intents are processed in MainActivity.onCreate, so we need to check // after a delay to ensure the native code has finished processing -if ( - Capacitor.isNativePlatform() && - (Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android") -) { - // Use multiple checks with increasing delays to handle timing issues - // Android share intent processing happens in onCreate, which may complete after JS loads - const checkDelays = - Capacitor.getPlatform() === "android" - ? [500, 1500, 3000] // Android needs more time for share intent processing - : [1000]; // iOS is faster +if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android") { + // Android behavior unchanged: multiple checks with increasing delays because + // share intent processing happens in onCreate, which may complete after JS loads. + const checkDelays = [500, 1500, 3000]; // Android needs more time for share intent processing checkDelays.forEach((delay) => { setTimeout(async () => { await checkForSharedImageAndNavigate(); }, delay); }); +} else if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") { + // Phase 2A: replace the fixed 1000ms delay with a deterministic wait for the + // SharedImage plugin. The very first startup check runs only after the plugin + // is confirmed available; if the bounded retry budget is exhausted we still + // attempt once as a best-effort fallback (appStateChange retries on the next + // activation), so startup is never worse than the previous fixed-delay path. + void (async () => { + await waitForSharedImagePluginReady(); + await checkForSharedImageAndNavigate(); + })(); } // Listen for app state changes to detect when app becomes active