feat(ios): wait for SharedImage plugin before initial startup check (Phase 2A)
Eliminate the iOS startup race between asynchronous SharedImage plugin registration and the first shared-image check. Previously the initial check fired on a fixed 1000ms timer that only assumed the native plugin (registered by AppDelegate with retries from T+500ms) was ready, so a slow registration could make the first getSharedImage() call throw and miss a cold-start share. Replace the fixed delay with waitForSharedImagePluginReady(), which probes the plugin via a read-only hasSharedImage() call and retries within a bounded budget (10 attempts, 300ms apart) until the plugin actually responds. The initial check runs only once readiness is confirmed, with a best-effort fallback if the budget is exhausted. Scope is limited to the initial startup check on iOS. appStateChange, appUrlOpen/handleDeepLink, router navigation, share processing, Android behavior, and all native Swift code are unchanged. Temporary share-target diagnostics are preserved and extended with startup readiness logging. Document the change as a Phase 2A section in doc/share-target-ios-audit.md.
This commit is contained in:
@@ -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
|
## Configuration References
|
||||||
|
|
||||||
| Resource | Value |
|
| Resource | Value |
|
||||||
|
|||||||
@@ -74,18 +74,7 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
|
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
|
|
||||||
);
|
|
||||||
explicitFileTypes = {
|
|
||||||
};
|
|
||||||
explicitFolders = (
|
|
||||||
);
|
|
||||||
path = TimeSafariShareExtension;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|||||||
@@ -462,25 +462,94 @@ logger.log("[Capacitor] 🚀 Mounting app");
|
|||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
logger.info(`[Main] ✅ App mounted successfully`);
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
// 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)
|
// 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
|
// 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
|
// after a delay to ensure the native code has finished processing
|
||||||
if (
|
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android") {
|
||||||
Capacitor.isNativePlatform() &&
|
// Android behavior unchanged: multiple checks with increasing delays because
|
||||||
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
|
// 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
|
||||||
// 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
|
|
||||||
|
|
||||||
checkDelays.forEach((delay) => {
|
checkDelays.forEach((delay) => {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await checkForSharedImageAndNavigate();
|
await checkForSharedImageAndNavigate();
|
||||||
}, delay);
|
}, 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
|
// Listen for app state changes to detect when app becomes active
|
||||||
|
|||||||
Reference in New Issue
Block a user