docs(ios): add share-target launch and deep-link flow audit

Add doc/share-target-ios-launch-flow-audit.md documenting the iOS
Share Extension launch path from openMainApp("timesafari://") through
the native AppDelegate, Capacitor bridge, and JS bootstrap to
/shared-photo navigation.

Covers cold- vs warm-start execution order, the single appUrlOpen
listener and its call graph, all shared-image detection mechanisms
(startup timer, appStateChange, applicationDidBecomeActive,
sharedPhotoReady flag, SharedPhotoReady notification), native launch-URL
handling, and a timing analysis of the race conditions.

Key findings: cold-start shares rely on the T+1000ms startup timer
reading the App Group payload (not appUrlOpen, which registers at
T+2000ms after the launch URL is delivered); warm-start shares are
driven by appUrlOpen/appStateChange; the launch URL event and the
SharedPhotoReady notification can be lost before JS is ready, but the
durable App Group payload is not. Audit only; no code changes.
This commit is contained in:
Jose Olarte III
2026-06-26 15:43:42 +08:00
parent 7b1fec779b
commit c9061e669e

View File

@@ -0,0 +1,515 @@
# iOS Share-Target Launch & Deep-Link Flow Audit
**Date:** 2026-06-26 15:32:14 PST
**Scope:** iOS Share Extension launch path and `timesafari://` deep-link handling only.
**Status:** Read-only audit. No code was modified. **No code changes are recommended.**
This document traces the complete execution path that begins when the iOS
Share Extension calls `application.open("timesafari://")` and ends with
navigation to `/shared-photo`. All references cite file, method, and
approximate line numbers as of the audit date.
### Files in scope
| File | Role |
| --- | --- |
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | Share Extension: stores image to App Group, opens `timesafari://` |
| `ios/App/App/AppDelegate.swift` | Native app delegate: lifecycle + URL open proxy |
| `ios/App/App/SharedImagePlugin.swift` | Capacitor plugin bridge (`SharedImage`) |
| `ios/App/App/SharedImageUtility.swift` | App Group read/write helpers + ready flag |
| `src/main.capacitor.ts` | JS bootstrap, deep-link listener, shared-image checks, navigation |
| `src/main.common.ts` | `initializeApp()` — Vue app + router construction |
| `src/router/index.ts` | Router creation, `/shared-photo` route |
| `src/libs/capacitor/app.ts` | Type-safe wrapper around `@capacitor/app` listeners |
| `src/plugins/SharedImagePlugin.ts` | JS `registerPlugin("SharedImage")` |
| `capacitor.config.ts` | `appUrlOpen` handler config for `timesafari://*` |
---
## 1. Cold-start launch path
"Cold start" = the main app process is **not** running when the user taps Share.
### Execution order
1. **Share Extension `viewDidLoad()`**
`ShareViewController.swift:47` → calls `processAndOpenApp()` (`:64`).
2. **`processAndOpenApp()`** — `ShareViewController.swift:70`
Reads `extensionContext.inputItems`, then calls `processSharedImage(...)` (`:97`).
3. **`processSharedImage(...)`** — `ShareViewController.swift:148`
Loads the first image attachment, then `storeImageData(...)` (`:245`).
4. **`storeImageData(...)`** — `ShareViewController.swift:292`
Writes the image file into the App Group container and writes metadata keys
(`sharedPhotoFilePath`, `sharedPhotoFileName`, `sharedPhotoShareId`) to
`UserDefaults(suiteName: "group.app.trentlarson.timesafari.share")` (`:340347`).
5. **`setSharedPhotoReadyFlag()`** — `ShareViewController.swift:129` (called at `:108`)
Sets `sharedPhotoReady = true` in the App Group UserDefaults (`:140`).
6. **`openMainApp()`** — `ShareViewController.swift:356` (called at `:110`)
Builds `URL(string: "timesafari://")` (`:362`), walks the responder chain to
find a `UIApplication`, and calls `application.open(url, ...)` (`:373`).
Fallback: `extensionContext?.open(url, ...)` (`:383`).
7. **`context.completeRequest(...)`** — `ShareViewController.swift:119`
Extension finishes; iOS hands the URL to the main app, launching the process.
8. **Main app process launches → `AppDelegate.application(_:didFinishLaunchingWithOptions:)`**
`AppDelegate.swift:11`.
- Sets `UNUserNotificationCenter.delegate` (`:13`).
- Schedules `SharedImagePlugin` registration on the main queue starting at
**T+0.5s**, with up to 5 retries at increasing delays (`:2140`,
`registerSharedImagePlugin()` at `:46`). Registration requires the
Capacitor bridge to exist (`:4851`).
- **Note:** `launchOptions` is received but is **never inspected** for a launch
URL. AppDelegate does not read `launchOptions[.url]`.
9. **Capacitor native bridge boots** (`CAPBridgeViewController` as `window.rootViewController`).
The bridge loads the WKWebView and the JS bundle.
10. **`AppDelegate.application(_:open:options:)`** — `AppDelegate.swift:133`
When iOS delivers `timesafari://`, this proxies straight to
`ApplicationDelegateProxy.shared.application(app, open: url, options:)` (`:138`).
Capacitor's proxy is responsible for emitting the `appUrlOpen` event to JS —
**but only to listeners that are already registered** (see §5).
11. **JS startup — `src/main.capacitor.ts` executes top-to-bottom on bundle load:**
- Logging banner (`:4748`).
- `const app = initializeApp();` (`:50`) → `src/main.common.ts:33`
builds the Vue app, Pinia, axios, and **`app.use(router)`** (`main.common.ts:39`).
The router itself is created at module import time in
`src/router/index.ts:320` (`createRouter` with `createWebHistory("/")`).
- `new DeepLinkHandler(router)` (`main.capacitor.ts:59`).
- `app.mount("#app")` (`main.capacitor.ts:462`).
- **Startup shared-image timer scheduled:** iOS uses `[1000]`ms delay
(`:474483`) → `checkForSharedImageAndNavigate()` at **T+1000ms**.
- **`appStateChange` listener registered** (`:491496`).
- **Deep-link listener registration scheduled** via `setTimeout(..., 2000)`
(`:500510`) → `registerDeepLinkListener()` at **T+2000ms**.
12. **`registerDeepLinkListener()`** — `main.capacitor.ts:298`
Awaits `router.isReady()` (`:322`), then
`CapacitorApp.addListener("appUrlOpen", handleDeepLink)` (`:329`).
This is the **only** `appUrlOpen` registration in the codebase, and it occurs
~2 seconds after mount.
### Cold-start order summary
```
ShareViewController.viewDidLoad
→ processAndOpenApp → processSharedImage → storeImageData (App Group file + metadata)
→ setSharedPhotoReadyFlag (sharedPhotoReady = true)
→ openMainApp → application.open("timesafari://")
→ completeRequest
AppDelegate.didFinishLaunchingWithOptions (launchOptions URL ignored)
→ schedules SharedImagePlugin registration (T+0.5s, ≤5 retries)
Capacitor bridge + WKWebView boot → JS bundle loads
main.capacitor.ts (top-level):
initializeApp() → router created/used → DeepLinkHandler → app.mount("#app")
→ schedule startup check (T+1000ms)
→ register appStateChange listener
→ schedule appUrlOpen registration (T+2000ms)
AppDelegate.application(_:open:) → ApplicationDelegateProxy (appUrlOpen emitted)
applicationDidBecomeActive → checkForSharedImageOnActivation (native flag path)
```
**Key cold-start fact:** because `application.open("timesafari://")` is delivered
during/just after process launch, the Capacitor `appUrlOpen` event fires **before**
the JS `appUrlOpen` listener is registered (registration is at T+2000ms). On cold
start, the successful navigation is therefore driven by the **T+1000ms startup
timer** (and/or `appStateChange`), not by `appUrlOpen`. See §6 and §7.
---
## 2. Warm-start launch path
"Warm start" = the main app process is already running (foreground or background)
when the user taps Share.
1. **Share Extension** runs the identical sequence as §1 steps 17
(`storeImageData``setSharedPhotoReadyFlag``openMainApp`
`application.open("timesafari://")``completeRequest`).
2. **iOS resumes the existing app process** (no new launch, no
`didFinishLaunchingWithOptions`).
3. **`AppDelegate.application(_:open:options:)`** — `AppDelegate.swift:133`
Proxies to `ApplicationDelegateProxy.shared.application(...)` (`:138`).
Because the JS `appUrlOpen` listener was registered during the earlier launch
(T+2000ms after the first mount), Capacitor delivers the event to JS.
4. **`AppDelegate.applicationDidBecomeActive`** — `AppDelegate.swift:77`
Also fires on resume:
- re-sets the notification delegate (`:81`),
- calls `checkForSharedImageOnActivation()` (`:84`).
`checkForSharedImageOnActivation()` (`:117`) reads `isSharedPhotoReady()`
(`SharedImageUtility.swift:183`), clears the flag (`:121`), and posts the
`SharedPhotoReady` NSNotification (`:125`). **No JS code listens for that
NSNotification** (see §4 / §7) — it is a dead signal.
5. **JS `appUrlOpen` → `handleDeepLink`**`main.capacitor.ts:194`
- `url === "timesafari://"` matches the empty-path branch (`:201`).
- On iOS native (`:203207`), calls `checkAndStoreNativeSharedImage()` (`:216`).
6. **`checkAndStoreNativeSharedImage()`** — `main.capacitor.ts:131`
- Guards against re-entrancy via `isProcessingSharedImage` (`:136`, `:143`).
- Calls `SharedImage.getSharedImage()` (`:159`) → native
`SharedImagePlugin.getSharedImage` (`SharedImagePlugin.swift:43`) →
`SharedImageUtility.getSharedImageData()` (`SharedImageUtility.swift:51`)
which reads the App Group file and returns `{ base64, fileName }`.
- On success, `storeSharedImageInTempDB(...)` (`main.capacitor.ts:72`) writes
the data URL into the SQLite `temp` table under `SHARED_PHOTO_BASE64_KEY`.
7. **Navigation to `/shared-photo`**`main.capacitor.ts:218248`
- `await router.isReady()` (`:224`).
- If already on `/shared-photo`, `router.replace(...)` with a `_refresh`
timestamp (`:234`); otherwise `router.push({ path: "/shared-photo", query: { fileName } })`
(`:239`).
### Warm-start callback chain
```
ShareViewController.openMainApp → application.open("timesafari://") → completeRequest
iOS resumes running process
AppDelegate.application(_:open:) → ApplicationDelegateProxy ── emits appUrlOpen ──┐
AppDelegate.applicationDidBecomeActive → checkForSharedImageOnActivation │
(reads + clears sharedPhotoReady, posts SharedPhotoReady NSNotification = no JS listener)
JS appUrlOpen listener (registered earlier) ←───────────────────────────────────────┘
→ handleDeepLink (empty-path branch)
→ checkAndStoreNativeSharedImage
→ SharedImage.getSharedImage → SharedImageUtility.getSharedImageData (App Group file)
→ storeSharedImageInTempDB (SQLite temp table)
→ router.isReady → router.push/replace("/shared-photo")
```
In parallel, the `appStateChange` listener (`main.capacitor.ts:491`) also fires
on `isActive` and independently calls `checkForSharedImageAndNavigate()`. Both
paths converge on the same `isProcessingSharedImage` lock and the same
`/shared-photo` navigation.
---
## 3. appUrlOpen audit
### Registrations
There is exactly **one** runtime registration of `appUrlOpen` in the codebase:
- **`main.capacitor.ts:329`** inside `registerDeepLinkListener()`:
```ts
const listenerHandle = await CapacitorApp.addListener("appUrlOpen", handleDeepLink);
```
(`CapacitorApp` = `@capacitor/app`, imported at `main.capacitor.ts:32`.)
Supporting / non-runtime references:
- `capacitor.config.ts:1119` declares the `App.appUrlOpen` handler for
`timesafari://*` with `autoVerify: true` (config, not a JS listener).
- `src/libs/capacitor/app.ts:3235, 4259` is a typed wrapper exposing
`addListener("appUrlOpen", ...)`, but `main.capacitor.ts` calls
`@capacitor/app` directly, **not** this wrapper.
### When it is registered relative to startup
- Scheduled by `setTimeout(..., 2000)` at `main.capacitor.ts:500`, i.e.
**~2000 ms after `app.mount("#app")`** (`:462`).
- Inside `registerDeepLinkListener()`, registration additionally waits for
`await router.isReady()` (`:322`) before calling `addListener` (`:329`).
### Handlers that execute because of it
- **`handleDeepLink(data)`** — `main.capacitor.ts:194` is the only handler.
### Calls made by the handler
- **`handleDeepLink`** (`:194`)
- **`checkAndStoreNativeSharedImage()`** (`:216`) — for empty-path
`timesafari://` / `timesafari:///` on iOS native (`:201207`).
- **`router.isReady()`** (`:224`), then **`router.replace`** (`:234`) or
**`router.push`** (`:239`) → navigation to `/shared-photo`.
- For non-empty deep links: `router.isReady()` (`:264`) then
`deepLinkHandler.handleDeepLink(url)` (`:269`) — the `DeepLinkHandler` class
instance (`:59`). (Empty-path share URLs never reach this branch.)
`handleDeepLink` is **not** the same function as `deepLinkHandler.handleDeepLink`
(`src/services/deepLinks.ts`); the module-level function wraps the class method.
### Call graph (appUrlOpen)
```
CapacitorApp.addListener("appUrlOpen", handleDeepLink) [main.capacitor.ts:329]
│ (fires on timesafari:// open, warm start only in practice — see §6)
handleDeepLink(data) [:194]
├─ if url == "timesafari://" / "timesafari:///" and iOS native [:201207]
│ └─ checkAndStoreNativeSharedImage() [:216 → :131]
│ └─ SharedImage.getSharedImage() [:159]
│ └─ (native) getSharedImageData() [SharedImageUtility.swift:51]
│ └─ storeSharedImageInTempDB() [:177 → :72]
│ └─ router.isReady() [:224]
│ └─ router.replace / router.push → /shared-photo [:234 / :239]
└─ else (non-empty deep link)
└─ router.isReady() [:264]
└─ deepLinkHandler.handleDeepLink(url) [:269]
```
---
## 4. Startup / shared-image detection audit
Every place the app checks for a shared image, and when each runs:
| # | Mechanism | Location | When it executes | Cold start? | Warm start? |
| --- | --- | --- | --- | --- | --- |
| A | **Startup timer** `setTimeout(..., 1000)` → `checkForSharedImageAndNavigate()` | `main.capacitor.ts:474483` (iOS delays `[1000]`) | ~1000 ms after JS bundle loads / mount | **Yes** (primary cold-start path) | Only if bundle reloaded (normally no) |
| B | **`appStateChange` listener** → `checkForSharedImageAndNavigate()` | `main.capacitor.ts:491496` | Every time app becomes active (`isActive === true`) | Yes (initial activation can fire) | **Yes** (primary on resume) |
| C | **`appUrlOpen` listener** → `handleDeepLink` → `checkAndStoreNativeSharedImage()` | `main.capacitor.ts:329`, handler `:194/:216` | On `timesafari://` open, **only if listener already registered** (T+2000ms) | Usually **No** (URL arrives before listener) | **Yes** |
| D | **Native `applicationDidBecomeActive`** → `checkForSharedImageOnActivation()` | `AppDelegate.swift:77, 117` | Every activation (launch + resume) | Yes | Yes |
| E | **Native ready-flag read** `isSharedPhotoReady()` / `clearSharedPhotoReadyFlag()` | `SharedImageUtility.swift:183, 195` (called from D) | Inside D | Yes | Yes |
| F | **`SharedPhotoReady` NSNotification** posted | `AppDelegate.swift:125` | Inside D, when flag was set | Yes | Yes |
### Detail per mechanism
- **A — JS startup timer.** `main.capacitor.ts:474483`. iOS uses a single
`[1000]` ms delay (Android uses `[500, 1500, 3000]`). Calls
`checkForSharedImageAndNavigate()` (`:353`), which calls
`checkAndStoreNativeSharedImage()` (`:423`) and then pushes/replaces
`/shared-photo` (`:439449`). This is the mechanism that actually carries
cold-start shares to `/shared-photo`.
- **B — `appStateChange`.** `main.capacitor.ts:491`. Fires on every transition to
active. Calls the same `checkForSharedImageAndNavigate()`. Primary warm-start /
resume detector and a backstop for cold start.
- **C — `appUrlOpen`.** Registered at `main.capacitor.ts:329` (T+2000ms after
mount). Handler `handleDeepLink` (`:194`). Effective only when the listener is
already registered when the URL arrives — i.e. warm starts.
- **D — Native `applicationDidBecomeActive`.** `AppDelegate.swift:77`. Calls
`checkForSharedImageOnActivation()` (`:117`).
- **E — ready flag.** `checkForSharedImageOnActivation()` reads
`SharedImageUtility.isSharedPhotoReady()` (`:119` → `SharedImageUtility.swift:183`)
and clears it via `clearSharedPhotoReadyFlag()` (`:121` →
`SharedImageUtility.swift:195`). The flag is **set** by the extension at
`ShareViewController.swift:140`.
- **F — `SharedPhotoReady` NSNotification.** Posted at `AppDelegate.swift:125`.
**No JavaScript or Capacitor bridge code observes this notification anywhere in
the repo** (only doc references and the post site exist). It is therefore a
dead/no-op signal as far as JS navigation is concerned.
### Polling / retry logic
- **JS retry:** No active polling loop inside `checkAndStoreNativeSharedImage()`.
The code comments at `main.capacitor.ts:213214` mention "polling internally,"
but the implementation (`:131192`) makes a **single** `getSharedImage()` call.
The only "retry-like" behavior on the JS side is the **multiple invocation
surfaces** (A startup timer, B appStateChange, C appUrlOpen), all gated by the
`isProcessingSharedImage` lock (`:62, :136, :143`).
- **Native retry:** `AppDelegate.swift:2140` retries **plugin registration**
(not image detection) up to 5 times starting at T+0.5s.
---
## 5. Native launch information
### Does AppDelegate receive the launch URL before JavaScript is initialized?
**No — the AppDelegate does not capture the launch URL at all.**
- `AppDelegate.application(_:didFinishLaunchingWithOptions:)`
(`AppDelegate.swift:11`) receives `launchOptions`, but the body
(`:1343`) **never reads `launchOptions[.url]`** or otherwise extracts a launch
URL. It only configures notifications and schedules plugin registration.
- The only URL entry point is `AppDelegate.application(_:open:options:)`
(`AppDelegate.swift:133`), which immediately forwards to
`ApplicationDelegateProxy.shared.application(app, open: url, options:)` (`:138`)
with **no local storage** of the URL. Capacitor's proxy owns the URL from here.
### Where is it stored?
- It is **not** stored in the app's own native code. Whatever buffering exists is
internal to Capacitor's `ApplicationDelegateProxy` / `@capacitor/app` plugin.
The app code does not call `App.getLaunchUrl()` anywhere (only referenced in
docs at `doc/native-share-target-implementation.md:436`).
- The **share payload** (not the URL) is durably stored by the extension in the
App Group container: the image file plus metadata keys at
`ShareViewController.swift:340347`, and the `sharedPhotoReady` boolean at
`:140`. This payload is what the JS side later reads via
`SharedImage.getSharedImage()`.
### Is the URL forwarded to JS?
- Only through Capacitor's `appUrlOpen` event, and only to listeners present at
emit time. The single JS listener is registered at
`main.capacitor.ts:329`, **~2000 ms after mount**.
### Can the launch URL be lost before listeners are registered?
- **Yes, the `appUrlOpen` event can be lost on cold start.** Because the URL is
delivered through `application(_:open:)` during/just after launch, and the JS
listener is registered at T+2000ms (`:500`), an event emitted before that point
will have no JS listener — unless Capacitor buffers the launch URL until a
listener attaches. The app code does not rely on (or verify) such buffering;
there is no `getLaunchUrl()` call to recover a missed event.
- **The share payload itself is NOT lost.** Because the image and metadata persist
in the App Group container, the cold-start startup timer (mechanism A,
`main.capacitor.ts:474`) and `appStateChange` (mechanism B, `:491`) can still
retrieve it via `getSharedImage()` independent of whether the `appUrlOpen`
event was delivered. The only thing at risk is the **URL event/signal**, not the
data.
### Complete native-launch flow
```
Extension writes App Group file + metadata + sharedPhotoReady=true [ShareViewController.swift:340,140]
Extension: application.open("timesafari://") [ShareViewController.swift:373]
iOS launches/resumes app
├─ didFinishLaunchingWithOptions(launchOptions) [AppDelegate.swift:11] ← launchOptions URL NOT read
├─ application(_:open:options:) [AppDelegate.swift:133] → ApplicationDelegateProxy (Capacitor buffers/emits appUrlOpen)
└─ applicationDidBecomeActive [AppDelegate.swift:77] → checkForSharedImageOnActivation [:117]
→ isSharedPhotoReady [:119] → clear flag [:121] → post SharedPhotoReady [:125] (no JS listener)
JS bundle loads later → appUrlOpen listener attaches at T+2000ms [main.capacitor.ts:329]
```
---
## 6. Timing analysis
`T0` = moment the JS bundle begins executing / `app.mount("#app")`
(`main.capacitor.ts:462`). Native launch precedes T0.
### Cold start timeline
| Time | Actor | Event | Reference |
| --- | --- | --- | --- |
| pre-launch | Extension | write file + metadata + `sharedPhotoReady=true`; `open("timesafari://")`; `completeRequest` | `ShareViewController.swift:340,140,373,119` |
| launch | Native | `didFinishLaunchingWithOptions` (launch URL ignored) | `AppDelegate.swift:11` |
| ~launch | Native | `application(_:open:)` → ApplicationDelegateProxy → (Capacitor) `appUrlOpen` emitted | `AppDelegate.swift:133` |
| launch +0.5s..2.5s | Native | `SharedImagePlugin` registration (≤5 retries) | `AppDelegate.swift:2140` |
| ~launch | Native | `applicationDidBecomeActive` → `checkForSharedImageOnActivation` → clear flag + post `SharedPhotoReady` (dead) | `AppDelegate.swift:77,117,125` |
| T0 | JS | bundle executes: `initializeApp`, router created/used, `DeepLinkHandler`, `app.mount` | `main.capacitor.ts:50,59,462`; `main.common.ts:39`; `router/index.ts:320` |
| T0 | JS | register `appStateChange` listener | `main.capacitor.ts:491` |
| T0 + ~1000ms | JS | **startup timer** → `checkForSharedImageAndNavigate` → `getSharedImage` → push `/shared-photo` | `main.capacitor.ts:474483,353,423,449` |
| T0 + ~2000ms | JS | `registerDeepLinkListener` → `await router.isReady` → `addListener("appUrlOpen")` | `main.capacitor.ts:500,322,329` |
```
Extension → openMainApp → AppDelegate(launch) → Capacitor bridge → JS bootstrap (T0)
→ router ready → [appUrlOpen registered @ T0+2000ms]
→ startup shared-image check @ T0+1000ms ──► getSharedImage ──► /shared-photo
→ applicationDidBecomeActive (native flag path, no JS effect)
```
**Cold-start race conditions / ordering dependencies:**
1. **`appUrlOpen` arrives before its listener exists.** The URL is delivered at
launch, but the listener attaches at T0+2000ms (`:500`/`:329`). Unless
Capacitor buffers the launch URL, the `appUrlOpen` path does not fire on cold
start. Cold-start success relies on the **T0+1000ms startup timer** instead.
2. **Plugin registration vs. first `getSharedImage()`.** Plugin registration runs
T+0.5s..~2.5s (`AppDelegate.swift:2140`); the startup `getSharedImage()` call
is at ~T0+1000ms. If the bridge/plugin is not yet registered when JS calls
`getSharedImage()`, the call throws and is caught
(`main.capacitor.ts:160167`), returning `{ success: false }`. Recovery then
depends on a later `appStateChange` firing.
3. **Native flag cleared with no JS consumer.** `applicationDidBecomeActive`
clears `sharedPhotoReady` and posts `SharedPhotoReady` (`AppDelegate.swift:121,125`),
but no JS listens. Clearing the flag has no effect on JS navigation because JS
reads the **file/metadata**, not the flag — so this does not cause loss, but
the posted notification is inert.
### Warm start timeline
| Time | Actor | Event | Reference |
| --- | --- | --- | --- |
| t | Extension | write file + metadata + flag; `open("timesafari://")`; `completeRequest` | `ShareViewController.swift:340,140,373,119` |
| t | Native | `application(_:open:)` → ApplicationDelegateProxy → `appUrlOpen` (listener already attached) | `AppDelegate.swift:133`; `main.capacitor.ts:329` |
| t | Native | `applicationDidBecomeActive` → clear flag + post `SharedPhotoReady` (dead) | `AppDelegate.swift:77,121,125` |
| t (≈same) | JS | `appStateChange(isActive)` → `checkForSharedImageAndNavigate` | `main.capacitor.ts:491` |
| t (≈same) | JS | `appUrlOpen` → `handleDeepLink` → `checkAndStoreNativeSharedImage` | `main.capacitor.ts:194,216` |
| t+ε | JS | `getSharedImage` → store temp DB → `router.push/replace("/shared-photo")` | `main.capacitor.ts:159,177,234/239` |
```
Extension → appUrlOpen (listener present) ─┐
├─► handleDeepLink / checkForSharedImageAndNavigate
appStateChange(isActive) ──────────────────┘ → getSharedImage → /shared-photo
```
**Warm-start race conditions / ordering dependencies:**
1. **Duplicate triggers.** `appUrlOpen` (C) and `appStateChange` (B) fire at
nearly the same time, both calling `checkAndStoreNativeSharedImage()`. The
`isProcessingSharedImage` boolean lock (`main.capacitor.ts:62,136,143`)
prevents concurrent processing, but it is a simple non-reentrant flag: the
second caller returns `{ success: false }` immediately and does not navigate,
so navigation is driven by whichever caller wins. Because the lock is released
synchronously at the end of each path, ordering determines which trigger
performs the navigation.
2. **Read-only native retrieval.** `getSharedImageData()`
(`SharedImageUtility.swift:51`) leaves the file/metadata intact after reading
(`:7576`), so repeated reads from B and C return the same data rather than one
"consuming" the other.
---
## 7. Final summary
(No code changes recommended — answers only.)
1. **What mechanism actually causes successful warm-start shares to navigate to
`/shared-photo`?**
The **JS `appUrlOpen` listener** (`main.capacitor.ts:329`) firing
`handleDeepLink` (`:194`), and/or the **`appStateChange` listener** (`:491`),
each calling `checkAndStoreNativeSharedImage()` → `SharedImage.getSharedImage()`
→ `router.push/replace("/shared-photo")`. On a warm start the `appUrlOpen`
listener already exists, so the deep-link path is available; `appStateChange`
is a redundant parallel trigger. Both converge through the
`isProcessingSharedImage` lock onto the same navigation.
2. **What mechanism is supposed to cause successful cold-start shares to navigate
to `/shared-photo`?**
The **JS startup timer** at `main.capacitor.ts:474483` (iOS `[1000]` ms) →
`checkForSharedImageAndNavigate()` (`:353`) →
`checkAndStoreNativeSharedImage()` (`:423`) → `getSharedImage()` (reads the App
Group file/metadata persisted by the extension) → `router.push("/shared-photo")`
(`:449`). `appStateChange` (`:491`) acts as a backstop. This path does **not**
depend on the `appUrlOpen` event, because the `appUrlOpen` listener is not
registered until ~T0+2000ms (`:500`), after the launch URL has already been
delivered.
3. **Are those mechanisms the same or different?**
**Different.**
- Warm start: driven primarily by the **`appUrlOpen` deep-link event**
(`handleDeepLink`, `:194`/`:216`), with `appStateChange` as parallel backup.
- Cold start: driven by the **startup `setTimeout` poll** (`:474`) /
`appStateChange` (`:491`) calling `checkForSharedImageAndNavigate()`.
They share the same downstream code (`checkAndStoreNativeSharedImage` →
`getSharedImage` → router navigation) but are entered through **different
triggers**, because the `appUrlOpen` event is unavailable during cold start.
4. **Is there any point where a launch URL or launch signal could be lost before
JavaScript is ready?**
**Yes — the `appUrlOpen` URL event can be lost on cold start.** The launch URL
is delivered to `AppDelegate.application(_:open:)` (`AppDelegate.swift:133`)
and proxied into Capacitor at process launch, but the JS `appUrlOpen` listener
is not registered until ~2000 ms after mount (`main.capacitor.ts:500,329`).
If Capacitor does not buffer the launch URL until that listener attaches, the
`appUrlOpen` event is dropped. Additionally, the native
`SharedPhotoReady` NSNotification (`AppDelegate.swift:125`) is posted with **no
JS/bridge listener**, so that signal is always lost.
**However, the share payload (image + metadata in the App Group container,
`ShareViewController.swift:340347`) is durable and is not lost**; it is
recovered by the startup timer / `appStateChange` calling
`getSharedImage()`, which is why cold-start shares can still reach
`/shared-photo` despite the `appUrlOpen` event being unavailable.