docs(ios): add share target implementation audit
Document the Share Extension → App Group → main app flow, including read/write/delete points, startup detection hooks, timing behavior, and race conditions to support share-target reliability work.
This commit is contained in:
280
doc/share-target-ios-audit.md
Normal file
280
doc/share-target-ios-audit.md
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
# iOS Share Target Implementation Audit
|
||||||
|
|
||||||
|
**Generated:** 2026-06-23 17:07:21 PST
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The iOS share target uses a **Share Extension** (`TimeSafariShareExtension`) that writes a shared image to an **App Group** container (`group.app.timesafari.share`), then opens the main app via `timesafari://`. The main app reads the image through a native **Capacitor plugin** (`SharedImagePlugin`) and stores it in the JS temp database before routing to `/shared-photo`.
|
||||||
|
|
||||||
|
### App Group Storage Model
|
||||||
|
|
||||||
|
| Key | Storage | Written by | Purpose |
|
||||||
|
|-----|---------|------------|---------|
|
||||||
|
| `sharedPhotoFilePath` | UserDefaults (suite) | Share Extension | Relative filename of image file in container |
|
||||||
|
| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Display/original filename |
|
||||||
|
| `sharedPhotoReady` | UserDefaults (suite) | Share Extension | Boolean signal that a new share is available |
|
||||||
|
| `sharedPhotoBase64` | UserDefaults (suite) | *(legacy, not written)* | Removed on write for cleanup |
|
||||||
|
| Image file | App Group filesystem | Share Extension | Raw image bytes at `{container}/{sharedPhotoFilePath}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## End-to-End Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
External App (Photos, Safari, etc.)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ TimeSafariShareExtension │
|
||||||
|
│ ShareViewController │
|
||||||
|
│ 1. viewDidLoad → processAndOpenApp │
|
||||||
|
│ 2. processSharedImage (async) │
|
||||||
|
│ 3. storeImageData → file + metadata │
|
||||||
|
│ 4. setSharedPhotoReadyFlag │
|
||||||
|
│ 5. openMainApp (timesafari://) │
|
||||||
|
│ 6. completeRequest │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼ App Group: group.app.timesafari.share
|
||||||
|
│ (UserDefaults keys + image file)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Main App (app.timesafari) │
|
||||||
|
│ │
|
||||||
|
│ Native detection: │
|
||||||
|
│ • AppDelegate.applicationDidBecome │
|
||||||
|
│ Active → checkForSharedImageOn │
|
||||||
|
│ Activation (flag only) │
|
||||||
|
│ • AppDelegate.application(open:) │
|
||||||
|
│ → Capacitor URL handling │
|
||||||
|
│ │
|
||||||
|
│ JS detection (main.capacitor.ts): │
|
||||||
|
│ • setTimeout 1000ms startup check │
|
||||||
|
│ • appStateChange (isActive) │
|
||||||
|
│ • appUrlOpen → timesafari:// │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ SharedImagePlugin.getSharedImage() │
|
||||||
|
│ → SharedImageUtility │
|
||||||
|
│ .getSharedImageData() │
|
||||||
|
│ (reads file, deletes native data) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ main.capacitor.ts │
|
||||||
|
│ storeSharedImageInTempDB() │
|
||||||
|
│ → SQLite temp table │
|
||||||
|
│ → router.push/replace /shared-photo│
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ SharedPhotoView.vue │
|
||||||
|
│ loadSharedImage() │
|
||||||
|
│ → reads temp DB, deletes temp row │
|
||||||
|
│ → displays image, user action │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Responsibilities
|
||||||
|
|
||||||
|
### Share Extension (Writer)
|
||||||
|
|
||||||
|
| File | Method | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | `viewDidLoad()` | Entry point; triggers share processing on load |
|
||||||
|
| | `processAndOpenApp()` | Orchestrates image extraction, flag set, app open, extension completion |
|
||||||
|
| | `processSharedImage(from:completion:)` | Iterates `NSExtensionItem` attachments; loads first `UTType.image` via `loadItem` |
|
||||||
|
| | `storeImageData(_:fileName:)` | Writes image file to App Group container; writes metadata to UserDefaults |
|
||||||
|
| | `setSharedPhotoReadyFlag()` | Sets `sharedPhotoReady = true` in App Group UserDefaults |
|
||||||
|
| | `openMainApp()` | Opens `timesafari://` via responder chain or `extensionContext.open` |
|
||||||
|
| | `getFileNameWithExtension(_:newExtension:)` | Helper for PNG fallback filename |
|
||||||
|
| `ios/App/TimeSafariShareExtension/Info.plist` | — | Declares share-services extension; accepts 1 image max |
|
||||||
|
| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | — | Grants App Group `group.app.timesafari.share` |
|
||||||
|
|
||||||
|
### Main App Native Layer (Reader)
|
||||||
|
|
||||||
|
| File | Method | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `ios/App/App/SharedImageUtility.swift` | `getSharedImageData()` | Reads file from App Group, returns base64 + fileName; **deletes metadata and file** |
|
||||||
|
| | `hasSharedImage()` | Non-destructive existence check (metadata + file on disk) |
|
||||||
|
| | `isSharedPhotoReady()` | Reads `sharedPhotoReady` flag |
|
||||||
|
| | `clearSharedPhotoReadyFlag()` | Removes `sharedPhotoReady` key |
|
||||||
|
| `ios/App/App/SharedImagePlugin.swift` | `getSharedImage(_:)` | Capacitor bridge to `getSharedImageData()` |
|
||||||
|
| | `hasSharedImage(_:)` | Capacitor bridge to `hasSharedImage()` |
|
||||||
|
| `ios/App/App/AppDelegate.swift` | `application(_:didFinishLaunchingWithOptions:)` | Registers `SharedImagePlugin` with retry loop |
|
||||||
|
| | `registerSharedImagePlugin()` | Manually registers plugin instance on Capacitor bridge |
|
||||||
|
| | `applicationDidBecomeActive(_:)` | Calls `checkForSharedImageOnActivation()` |
|
||||||
|
| | `checkForSharedImageOnActivation()` | Checks ready flag, clears it, posts `SharedPhotoReady` NSNotification |
|
||||||
|
| | `application(_:open:options:)` | Forwards URL opens (including `timesafari://`) to Capacitor |
|
||||||
|
| `ios/App/App/App.entitlements` | — | Grants App Group `group.app.timesafari.share` |
|
||||||
|
|
||||||
|
### JavaScript Layer (Consumer)
|
||||||
|
|
||||||
|
| File | Method | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/plugins/SharedImagePlugin.ts` | — | Registers Capacitor plugin name `SharedImage` |
|
||||||
|
| `src/plugins/definitions.ts` | — | TypeScript interface for `getSharedImage` / `hasSharedImage` |
|
||||||
|
| `src/main.capacitor.ts` | `checkAndStoreNativeSharedImage()` | Calls `SharedImage.getSharedImage()`, stores in temp DB; guarded by `isProcessingSharedImage` lock |
|
||||||
|
| | `storeSharedImageInTempDB()` | Clears old temp row, inserts base64 data URL into SQLite `temp` table |
|
||||||
|
| | `checkForSharedImageAndNavigate()` | Checks native share, navigates to `/shared-photo` on success |
|
||||||
|
| | `handleDeepLink()` | Handles `timesafari://` empty-path URLs from share extension on iOS |
|
||||||
|
| | `registerDeepLinkListener()` | Registers Capacitor `appUrlOpen` listener |
|
||||||
|
| `src/views/SharedPhotoView.vue` | `mounted()` / `onRouteQueryChange()` | Loads image from temp DB for display |
|
||||||
|
| | `loadSharedImage()` | Reads `SHARED_PHOTO_BASE64_KEY` from temp DB; **deletes temp row** after load |
|
||||||
|
| `src/router/index.ts` | — | Defines `/shared-photo` route |
|
||||||
|
| `src/libs/util.ts` | `SHARED_PHOTO_BASE64_KEY` | Temp DB key constant (`"shared-photo-base64"`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read / Write / Delete Inventory
|
||||||
|
|
||||||
|
### Writes — Shared Image Metadata (App Group UserDefaults)
|
||||||
|
|
||||||
|
| File | Method | Keys Written |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
|
||||||
|
| `ShareViewController.swift` | `setSharedPhotoReadyFlag()` | `sharedPhotoReady` (= `true`) |
|
||||||
|
|
||||||
|
### Writes — Shared Image Files (App Group Container)
|
||||||
|
|
||||||
|
| File | Method | Details |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `imageData.write(to:)` at `{containerURL}/{actualFileName}` |
|
||||||
|
|
||||||
|
### Reads — Shared Image Metadata (App Group UserDefaults)
|
||||||
|
|
||||||
|
| File | Method | Keys Read |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
|
||||||
|
| `SharedImageUtility.swift` | `hasSharedImage()` | `sharedPhotoFilePath` |
|
||||||
|
| `SharedImageUtility.swift` | `isSharedPhotoReady()` | `sharedPhotoReady` |
|
||||||
|
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `isSharedPhotoReady()`) |
|
||||||
|
|
||||||
|
### Reads — Shared Image Files (App Group Container)
|
||||||
|
|
||||||
|
| File | Method | Details |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `ShareViewController.swift` | `processSharedImage` (URL path) | Reads source image via `Data(contentsOf: url)` from security-scoped URL |
|
||||||
|
| `SharedImageUtility.swift` | `getSharedImageData()` | `Data(contentsOf: fileURL)` from App Group container |
|
||||||
|
| `SharedImageUtility.swift` | `hasSharedImage()` | `FileManager.fileExists(atPath:)` only (no data read) |
|
||||||
|
|
||||||
|
### Deletes — Shared Image Metadata (App Group UserDefaults)
|
||||||
|
|
||||||
|
| File | Method | Keys Removed |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoBase64` (legacy cleanup) |
|
||||||
|
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
|
||||||
|
| `SharedImageUtility.swift` | `clearSharedPhotoReadyFlag()` | `sharedPhotoReady` |
|
||||||
|
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `clearSharedPhotoReadyFlag()`) |
|
||||||
|
|
||||||
|
### Deletes — Shared Image Files (App Group Container)
|
||||||
|
|
||||||
|
| File | Method | Details |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | Removes existing file at target path before write |
|
||||||
|
| `SharedImageUtility.swift` | `getSharedImageData()` | `FileManager.removeItem(at: fileURL)` after successful read |
|
||||||
|
|
||||||
|
### Secondary Storage (Post-Native Consumption)
|
||||||
|
|
||||||
|
After native read, image data lives in SQLite `temp` table under key `shared-photo-base64`:
|
||||||
|
|
||||||
|
| File | Method | Operation |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `main.capacitor.ts` | `storeSharedImageInTempDB()` | **DELETE** old row, then **INSERT OR REPLACE** |
|
||||||
|
| `SharedPhotoView.vue` | `loadSharedImage()` | **READ** then **DELETE** temp row |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timing, Delays, Retries, and Polling
|
||||||
|
|
||||||
|
| Location | Mechanism | Values | Purpose |
|
||||||
|
|----------|-----------|--------|---------|
|
||||||
|
| `AppDelegate.swift` `didFinishLaunching` | Plugin registration retry loop | Initial delay **0.5s**; up to **5** attempts; backoff **0.5s × attempt** | Ensure Capacitor bridge exists before registering `SharedImagePlugin` |
|
||||||
|
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** (iOS only) | Deferred check for shared image on cold launch |
|
||||||
|
| `main.capacitor.ts` startup | `setTimeout` | **2000ms** | Deferred registration of `appUrlOpen` deep-link listener |
|
||||||
|
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** | Log app initialization status |
|
||||||
|
| `main.capacitor.ts` | `CapacitorApp.addListener("appStateChange")` | On every `isActive === true` | Re-check shared image when app foregrounds |
|
||||||
|
| `main.capacitor.ts` | `isProcessingSharedImage` flag | Synchronous JS lock | Prevents concurrent `checkAndStoreNativeSharedImage()` calls |
|
||||||
|
| `main.capacitor.ts` | Comment at line 214 | References polling | **Stale comment** — `checkAndStoreNativeSharedImage()` does **not** poll or retry |
|
||||||
|
|
||||||
|
**No native polling or retry** exists for reading App Group data. `hasSharedImage()` is exposed but **never called** from application JS code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Startup Detection Points
|
||||||
|
|
||||||
|
| # | Layer | Trigger | File | Method | Action |
|
||||||
|
|---|-------|---------|------|--------|--------|
|
||||||
|
| 1 | Native | App becomes active (cold start + resume) | `AppDelegate.swift` | `applicationDidBecomeActive` → `checkForSharedImageOnActivation` | Reads `sharedPhotoReady` flag; clears flag; posts `SharedPhotoReady` NSNotification *(no JS listener)* |
|
||||||
|
| 2 | JS | Module load + 1000ms delay | `main.capacitor.ts` | `setTimeout` → `checkForSharedImageAndNavigate` | Calls `SharedImage.getSharedImage()`, stores in temp DB, navigates to `/shared-photo` |
|
||||||
|
| 3 | JS | App foreground | `main.capacitor.ts` | `appStateChange` listener → `checkForSharedImageAndNavigate` | Same as above |
|
||||||
|
| 4 | JS | Deep link `timesafari://` | `main.capacitor.ts` | `appUrlOpen` → `handleDeepLink` → `checkAndStoreNativeSharedImage` | iOS-only empty-path URL handling; navigates to `/shared-photo` |
|
||||||
|
| 5 | Native | URL open | `AppDelegate.swift` | `application(_:open:options:)` | Forwards to Capacitor `ApplicationDelegateProxy` (enables #4) |
|
||||||
|
| 6 | Native | Cold launch plugin setup | `AppDelegate.swift` | `didFinishLaunching` → `tryRegister` | Registers `SharedImagePlugin` (not a share check, but required for JS reads) |
|
||||||
|
|
||||||
|
**Note:** Detection points 1–4 can all fire for a single share event. Only the JS paths (#2–4) actually read and consume the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Deletion Points
|
||||||
|
|
||||||
|
### App Group (Native)
|
||||||
|
|
||||||
|
| When | File | Method | What is deleted |
|
||||||
|
|------|------|--------|-----------------|
|
||||||
|
| Before overwrite | `ShareViewController.swift` | `storeImageData` | Existing file at same filename |
|
||||||
|
| Legacy cleanup on write | `ShareViewController.swift` | `storeImageData` | `sharedPhotoBase64` UserDefaults key |
|
||||||
|
| On successful read | `SharedImageUtility.swift` | `getSharedImageData` | `sharedPhotoFilePath`, `sharedPhotoFileName`, image file |
|
||||||
|
| On app activation (flag only) | `AppDelegate.swift` | `checkForSharedImageOnActivation` | `sharedPhotoReady` flag |
|
||||||
|
|
||||||
|
### Temp Database (JS)
|
||||||
|
|
||||||
|
| When | File | Method | What is deleted |
|
||||||
|
|------|------|--------|-----------------|
|
||||||
|
| Before storing new share | `main.capacitor.ts` | `storeSharedImageInTempDB` | Prior `shared-photo-base64` temp row |
|
||||||
|
| After view loads image | `SharedPhotoView.vue` | `loadSharedImage` | `shared-photo-base64` temp row |
|
||||||
|
|
||||||
|
**Important:** Native image file and metadata survive until `getSharedImageData()` succeeds. The `sharedPhotoReady` flag is cleared independently and earlier by `AppDelegate`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Potential Race Conditions
|
||||||
|
|
||||||
|
1. **Multiple JS detection paths, single destructive read.** `applicationDidBecomeActive`, the 1000ms startup timer, `appStateChange`, and `appUrlOpen` can all invoke `checkAndStoreNativeSharedImage()` close together. `getSharedImageData()` deletes the file and metadata on first successful read; subsequent calls return null. The `isProcessingSharedImage` JS lock reduces but does not eliminate races between separate async entry points that start before the lock is set.
|
||||||
|
|
||||||
|
2. **Deep-link listener registered 2 seconds after mount.** The share extension opens `timesafari://` immediately. If Capacitor does not buffer the launch URL until the `appUrlOpen` listener is registered (at T+2000ms), the deep-link path may be missed on cold start. The 1000ms startup check and `appStateChange` paths partially compensate.
|
||||||
|
|
||||||
|
3. **Plugin registration vs. first `getSharedImage()` call.** `SharedImagePlugin` is registered with up to 5 retries starting at T+500ms. A `getSharedImage()` call before registration completes will fail. The 1000ms startup delay usually avoids this, but `appStateChange` can fire earlier.
|
||||||
|
|
||||||
|
4. **`sharedPhotoReady` flag cleared before JS reads image.** `AppDelegate.checkForSharedImageOnActivation` clears the flag and posts `SharedPhotoReady`, but no JavaScript code listens for that NSNotification. The flag is therefore a redundant signal; reliance is entirely on file/metadata presence. If file write failed but flag were set, the flag would be cleared with no image available (current code sets flag only after successful `storeImageData`).
|
||||||
|
|
||||||
|
5. **`SharedPhotoReady` NSNotification is a dead signal.** Posted in `AppDelegate` but not bridged to Capacitor/JS. All actual consumption happens through JS-initiated `getSharedImage()` calls.
|
||||||
|
|
||||||
|
6. **Concurrent share while app is open.** A second share overwrites the App Group file and metadata. If the first share has already been read into temp DB but the user has not yet reached `SharedPhotoView`, the second share can replace native data; navigation refresh via `_refresh` query param handles re-navigation but temp DB overwrite in `storeSharedImageInTempDB` can clobber an in-flight first image.
|
||||||
|
|
||||||
|
7. **Extension `completeRequest` timing.** `completeRequest` runs in the `processSharedImage` completion handler after `storeImageData`, flag set, and `openMainApp` — so the file should exist before the extension exits. However, `loadItem` is asynchronous; if the extension process is terminated aggressively by iOS after `completeRequest`, this is generally safe because all writes complete in the callback before completion.
|
||||||
|
|
||||||
|
8. **Stale comment implies polling that does not exist.** `handleDeepLink` comments reference internal polling in `checkAndStoreNativeSharedImage`, but no retry loop exists. A single failed read at the wrong moment is not retried on iOS (unlike Android's multi-delay startup checks).
|
||||||
|
|
||||||
|
9. **`hasSharedImage()` unused.** A non-destructive pre-check is available natively but JS always calls destructive `getSharedImage()` directly, making timing-sensitive false negatives more likely if called before the extension finishes writing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration References
|
||||||
|
|
||||||
|
| Resource | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| App Group ID | `group.app.timesafari.share` |
|
||||||
|
| URL scheme | `timesafari://` |
|
||||||
|
| Extension bundle ID | `app.timesafari.TimeSafariShareExtension` |
|
||||||
|
| Main app bundle ID | `app.timesafari` |
|
||||||
|
| Capacitor plugin name | `SharedImage` |
|
||||||
|
| Temp DB key | `shared-photo-base64` (`SHARED_PHOTO_BASE64_KEY`) |
|
||||||
|
| Route | `/shared-photo` |
|
||||||
Reference in New Issue
Block a user