From c9061e669ec2008d8592520415f308d6bb816d6c Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Fri, 26 Jun 2026 15:43:42 +0800 Subject: [PATCH] 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. --- doc/share-target-ios-launch-flow-audit.md | 515 ++++++++++++++++++++++ 1 file changed, 515 insertions(+) create mode 100644 doc/share-target-ios-launch-flow-audit.md diff --git a/doc/share-target-ios-launch-flow-audit.md b/doc/share-target-ios-launch-flow-audit.md new file mode 100644 index 00000000..f66b6522 --- /dev/null +++ b/doc/share-target-ios-launch-flow-audit.md @@ -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")` (`:340–347`). + +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 (`:21–40`, + `registerSharedImagePlugin()` at `:46`). Registration requires the + Capacitor bridge to exist (`:48–51`). + - **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 (`:47–48`). + - `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 + (`:474–483`) → `checkForSharedImageAndNavigate()` at **T+1000ms**. + - **`appStateChange` listener registered** (`:491–496`). + - **Deep-link listener registration scheduled** via `setTimeout(..., 2000)` + (`:500–510`) → `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 1–7 + (`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 (`:203–207`), 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:218–248` + - `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:11–19` declares the `App.appUrlOpen` handler for + `timesafari://*` with `autoVerify: true` (config, not a JS listener). +- `src/libs/capacitor/app.ts:32–35, 42–59` 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 (`:201–207`). + - **`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 [:201–207] + │ └─ 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:474–483` (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:491–496` | 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:474–483`. 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` (`:439–449`). 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:213–214` mention "polling internally," + but the implementation (`:131–192`) 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:21–40` 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 + (`:13–43`) **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:340–347`, 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:21–40` | +| ~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:474–483,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:21–40`); 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:160–167`), 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 + (`:75–76`), 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:474–483` (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:340–347`) 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.