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:
515
doc/share-target-ios-launch-flow-audit.md
Normal file
515
doc/share-target-ios-launch-flow-audit.md
Normal 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")` (`: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.
|
||||
Reference in New Issue
Block a user