From 0a70914d0c2b2c77efaf9b6bf758c90991a3df63 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Mon, 29 Jun 2026 18:00:31 +0800 Subject: [PATCH] docs(ios): remove completed share-target investigation audits Remove the point-in-time, read-only audit documents produced during the iOS share-target investigation. Each was a "no code modified" snapshot and is no longer needed now that the investigation is complete. None were referenced by other docs or code. - doc/share-target-ios-audit.md - doc/share-target-ios-launch-flow-audit.md - doc/share-extension-configuration-audit.md - doc/share-extension-app-group-audit.md Permanent implementation, setup, design, and decision docs are retained. --- doc/share-extension-app-group-audit.md | 155 ------ doc/share-extension-configuration-audit.md | 188 -------- doc/share-target-ios-audit.md | 524 --------------------- doc/share-target-ios-launch-flow-audit.md | 515 -------------------- 4 files changed, 1382 deletions(-) delete mode 100644 doc/share-extension-app-group-audit.md delete mode 100644 doc/share-extension-configuration-audit.md delete mode 100644 doc/share-target-ios-audit.md delete mode 100644 doc/share-target-ios-launch-flow-audit.md diff --git a/doc/share-extension-app-group-audit.md b/doc/share-extension-app-group-audit.md deleted file mode 100644 index fe7518fe..00000000 --- a/doc/share-extension-app-group-audit.md +++ /dev/null @@ -1,155 +0,0 @@ -# iOS App Group Configuration Audit - -**Generated:** 2026-06-25 17:31:15 PST - -## Scope - -Static inspection of App Group configuration for the **App** target and the **TimeSafariShareExtension** target: entitlements, capabilities, bundle identifiers, Debug/Release build settings, and signing. No code was modified. - -### Files Inspected - -| File | Role | -|------|------| -| `ios/App/App/App.entitlements` | App target App Group declaration | -| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | Extension App Group declaration | -| `ios/App/App.xcodeproj/project.pbxproj` | Bundle IDs, teams, signing, entitlement linkage | -| `ios/App/App/SharedImageUtility.swift` | App Group identifier used by main app code | -| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | App Group identifier used by extension code | - ---- - -## CRITICAL FINDING — Code vs Entitlements App Group Mismatch - -The entitlements and the Swift source declare **different** App Group identifiers: - -| Location | App Group identifier | -|----------|----------------------| -| `App.entitlements` | `group.app.trentlarson.timesafari.share` | -| `TimeSafariShareExtension.entitlements` | `group.app.trentlarson.timesafari.share` | -| `SharedImageUtility.swift` (`appGroupIdentifier`) | `group.app.timesafari.share` | -| `ShareViewController.swift` (`appGroupIdentifier`) | `group.app.timesafari.share` | - -The runtime code targets `group.app.timesafari.share`, but **neither target is entitled to that group** — both entitlements now grant `group.app.trentlarson.timesafari.share`. - -This is an **uncommitted change**: `git diff` shows both entitlements were just changed from `group.app.timesafari.share` → `group.app.trentlarson.timesafari.share`, while the Swift code still uses the old value. Before this edit the code and entitlements matched; after it they do not. - -### Runtime Consequences - -- `FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.app.timesafari.share")` returns **nil** (the app is not entitled to that group). The extension's `storeImageData` aborts via `guard let containerURL` → image file is never written; the main app's reads return nil. -- `UserDefaults(suiteName: "group.app.timesafari.share")` does **not** resolve to the shared, entitled suite. Writes fall back to each process's own preferences domain, so the extension's keys (`sharedPhotoFilePath`, `sharedPhotoShareId`, `shareExtensionLastStart`, `sharedPhotoReady`) are **not visible** to the main app. - -Net effect: the entire share-target handoff via the App Group breaks while this mismatch exists. This is the most likely root cause of "App Group UserDefaults writes failing." - -**Note:** This affects both Debug and Release (the entitlements have no per-configuration variants), not Debug only. - ---- - -## Direct Answers - -### Do both targets declare the same App Group? - -**Yes — the two entitlements files match each other.** Both `App.entitlements` and `TimeSafariShareExtension.entitlements` declare exactly: - -```xml -com.apple.security.application-groups - - group.app.trentlarson.timesafari.share - -``` - -However, **the code does not match the entitlements** (see Critical Finding). So "same App Group" is true at the entitlement level, false at the entitlement-vs-code level. - -### Are there any Debug vs Release differences? - -**Entitlements / App Group:** No. A single entitlements file per target applies to both configurations; the App Group string is identical in Debug and Release (`group.app.trentlarson.timesafari.share`). - -**Bundle identifiers:** Yes — they differ by configuration: - -| Target | Debug | Release | -|--------|-------|---------| -| App | `app.trentlarson.timesafari` | `app.timesafari` | -| Extension | `app.trentlarson.timesafari.TimeSafariShareExtension` | `app.timesafari.TimeSafariShareExtension` | - -(The Debug bundle IDs were just changed from the `app.timesafari*` form per `git diff`.) - -**Development team:** Yes — differs by configuration (see next answer). - -In both configurations the extension bundle ID is correctly nested under the app bundle ID, which is required for an app extension. - -### Are there any team-ID differences that could affect App Group access? - -| Configuration | App team | Extension team | Match? | -|---------------|----------|----------------|--------| -| Debug | `7XVXYPEQYJ` | `7XVXYPEQYJ` | ✅ same | -| Release | `GM3FS5JQPH` | `GM3FS5JQPH` | ✅ same | - -- **Within each configuration, both targets use the same team** — this is the condition required for two targets to share an App Group, and it is satisfied. -- **Across configurations the teams differ** (Debug `7XVXYPEQYJ` vs Release `GM3FS5JQPH`). The Debug team was just changed from `GM3FS5JQPH` per `git diff`. - -Implications: -1. The App Group container is namespaced by Team ID at runtime (`$(TeamID).group...`). A Debug install (team `7XVXYPEQYJ`) and a Release install (team `GM3FS5JQPH`) use **different physical containers** and cannot share data with each other. This is normal and only matters if you expect data continuity between Debug and Release builds. -2. With **Automatic** signing, the App Group `group.app.trentlarson.timesafari.share` must be registered/enabled for **both** teams. If it is not provisioned under the Debug team `7XVXYPEQYJ`, automatic signing of the Debug build can fail to include the App Group entitlement (or fail to sign), which would also break App Group access in Debug. - -### Are there signing/entitlement mismatches that could cause App Group UserDefaults writes to fail in Debug builds? - -**Yes.** In order of severity: - -1. **(Primary) Code/entitlement group-ID mismatch.** Code uses `group.app.timesafari.share`; entitlements grant `group.app.trentlarson.timesafari.share`. The code's group is not entitled, so shared `UserDefaults`/container access fails. Affects Debug and Release. - -2. **(Debug-specific risk) App Group provisioning under the Debug team.** Debug now signs with team `7XVXYPEQYJ` (changed from `GM3FS5JQPH`). Under Automatic signing, if `group.app.trentlarson.timesafari.share` is not enabled for team `7XVXYPEQYJ`, the Debug build's App Group entitlement may not be granted, causing writes to silently fall back to the local domain. - -3. **(Consistency) Bundle-ID change accompanying the team change.** Debug bundle IDs changed to `app.trentlarson.timesafari*`. App Groups don't have to match bundle IDs, so this is not a direct cause, but combined with the new team it means Debug provisioning is a distinct profile/identifier set that must independently carry the App Group capability. - -No mismatch was found **between the two entitlement files themselves**, and no per-configuration entitlement override exists. - ---- - -## Detailed Configuration - -### Entitlements (identical content in both files) - -```xml -com.apple.security.application-groups - - group.app.trentlarson.timesafari.share - -``` - -`CODE_SIGN_ENTITLEMENTS` linkage (both Debug and Release): - -| Target | Entitlements path | -|--------|-------------------| -| App | `App/App.entitlements` | -| Extension | `TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | - -### Bundle Identifiers, Teams, Signing (project.pbxproj) - -| Setting | App Debug | App Release | Ext Debug | Ext Release | -|---------|-----------|-------------|-----------|-------------| -| `PRODUCT_BUNDLE_IDENTIFIER` | `app.trentlarson.timesafari` | `app.timesafari` | `app.trentlarson.timesafari.TimeSafariShareExtension` | `app.timesafari.TimeSafariShareExtension` | -| `DEVELOPMENT_TEAM` | `7XVXYPEQYJ` | `GM3FS5JQPH` | `7XVXYPEQYJ` | `GM3FS5JQPH` | -| `CODE_SIGN_STYLE` | Automatic | Automatic | Automatic | Automatic | -| `CODE_SIGN_ENTITLEMENTS` | `App/App.entitlements` | same | `TimeSafariShareExtension/...entitlements` | same | -| App Group (from entitlements) | `group.app.trentlarson.timesafari.share` | same | same | same | - -### App Group Identifier Used in Code - -```swift -// SharedImageUtility.swift:13 and ShareViewController.swift:13 -private let appGroupIdentifier = "group.app.timesafari.share" // ← does NOT match entitlements -``` - ---- - -## Recommendations (no code changed) - -1. **Resolve the group-ID mismatch.** Either revert the entitlements back to `group.app.timesafari.share`, or update the two Swift constants to `group.app.trentlarson.timesafari.share`. Both sides must use one identical string. -2. **Confirm App Group provisioning per team.** Ensure `group.app.trentlarson.timesafari.share` (whichever string is chosen) is enabled for both `7XVXYPEQYJ` (Debug) and `GM3FS5JQPH` (Release) so Automatic signing includes the capability in both configurations. -3. **Decide whether the Debug↔Release team/bundle-ID split is intentional.** If cross-config data continuity is ever expected, note that different Team IDs yield different App Group containers. -4. **Verify at runtime** using the existing `getShareExtensionDiagnostics()` / `[ShareTarget]` logs: after aligning identifiers, `shareExtensionLastStart` written by the extension should become readable by the main app. - ---- - -## Conclusion - -The two **entitlement files agree** on the App Group (`group.app.trentlarson.timesafari.share`) and, **within each build configuration**, both targets share the same Development Team and consistent nested bundle IDs — the structural requirements for App Group sharing are met. The decisive problem is that the **Swift code still references the old group `group.app.timesafari.share`**, which no entitlement grants; this breaks both shared `UserDefaults` and the shared container in all builds. Secondarily, the recent Debug switch to team `7XVXYPEQYJ` means the chosen App Group must be provisioned under that team for Debug App Group access to work under Automatic signing. diff --git a/doc/share-extension-configuration-audit.md b/doc/share-extension-configuration-audit.md deleted file mode 100644 index be8526a6..00000000 --- a/doc/share-extension-configuration-audit.md +++ /dev/null @@ -1,188 +0,0 @@ -# iOS Share Extension Configuration Audit - -**Generated:** 2026-06-25 15:33:39 PST - -## Scope - -Static inspection of the `TimeSafariShareExtension` target configuration to determine the extension entry point, principal view controller, storyboard vs. code-based setup, and whether `ShareViewController.viewDidLoad()` is guaranteed to execute. No code was modified. - -### Files Inspected - -| File | Role | -|------|------| -| `ios/App/App.xcodeproj/project.pbxproj` | Target, build settings, file membership | -| `ios/App/TimeSafariShareExtension/Info.plist` | NSExtension configuration | -| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | Principal class implementation | -| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | App Group access | - ---- - -## Direct Answers - -### 1. What class is configured as the extension entry point? - -`ShareViewController`, resolved via `Info.plist` key `NSExtensionPrincipalClass`: - -```xml -NSExtensionPrincipalClass -$(PRODUCT_MODULE_NAME).ShareViewController -``` - -`$(PRODUCT_MODULE_NAME)` resolves to `TimeSafariShareExtension` (derived from `PRODUCT_NAME = $(TARGET_NAME)`), so the runtime entry point is `TimeSafariShareExtension.ShareViewController`. - -### 2. Is ShareViewController actually the configured principal view controller? - -**Yes.** `ShareViewController.swift` declares: - -```swift -class ShareViewController: UIViewController { -``` - -within the `TimeSafariShareExtension` target. The class name, module, and `UIViewController` base class match the `NSExtensionPrincipalClass` reference. There is no competing storyboard-designated initial controller, so `ShareViewController` is unambiguously the principal view controller. - -### 3. Is the extension storyboard-based or code-based? - -**Code-based.** - -- `Info.plist` contains `NSExtensionPrincipalClass` and does **not** contain `NSExtensionMainStoryboard`. -- The extension folder contains no `.storyboard` file (only `Info.plist`, `ShareViewController.swift`, and the entitlements file). -- The only storyboards in the project (`Main.storyboard`, `LaunchScreen.storyboard`) belong exclusively to the **App** target's resources, not the extension. - -This deviates from the default Xcode Share Extension template (which ships a `MainInterface.storyboard` + `NSExtensionMainStoryboard`). The deviation is intentional and internally consistent. - -### 4. Does the configuration guarantee that ShareViewController.viewDidLoad() executes when the extension launches? - -**Yes, under normal launch.** Because: - -- The principal class is a `UIViewController` subclass, the extension host instantiates it and installs its view into the extension's window. This triggers the standard view lifecycle: `loadView()` → `viewDidLoad()`. -- `ShareViewController` overrides `viewDidLoad()` and calls `super.viewDidLoad()`, then immediately runs `processAndOpenApp()`. The startup marker (`shareExtensionLastStart`) and `[ShareTarget] viewDidLoad started` log execute before any other logic. -- The Swift source is compiled into the extension target via the Xcode 16 **file-system synchronized group** (`PBXFileSystemSynchronizedRootGroup` for `TimeSafariShareExtension`), so the class is guaranteed to be present in the built `.appex`. - -**Caveats (not failures, but worth noting):** -- The guarantee holds only if the OS successfully resolves and instantiates the principal class. If `$(PRODUCT_MODULE_NAME)` ever diverges from the actual Swift module name (e.g., a custom `PRODUCT_MODULE_NAME`), runtime class lookup would fail and the extension would not launch. Currently they match. -- `viewDidLoad()` executing does not, by itself, guarantee the *share* succeeds — the asynchronous `loadItem` work in `processSharedImage` happens after `viewDidLoad` returns. - -### 5. Are there any mismatches between Info.plist, storyboard, and ShareViewController? - -**No blocking mismatches.** Details: - -| Check | Result | -|-------|--------| -| `NSExtensionPrincipalClass` ↔ Swift class name | Match (`ShareViewController`) | -| Principal class module ↔ target module | Match (`TimeSafariShareExtension`) | -| `NSExtensionMainStoryboard` ↔ storyboard file | Consistent — neither exists (code-based) | -| Activation rule ↔ implementation | Consistent — `NSExtensionActivationSupportsImageWithMaxCount = 1` matches first-image-only handling | -| `NSExtensionPointIdentifier` | `com.apple.share-services` (correct for a Share extension) | -| Source file membership | `ShareViewController.swift` compiled via synchronized group | - -See "Observations / Non-Blocking Notes" for environment-specific items. - ---- - -## Detailed Configuration - -### NSExtension (Info.plist) - -```xml -NSExtension - - NSExtensionAttributes - - NSExtensionActivationRule - - NSExtensionActivationSupportsImageWithMaxCount - 1 - - - NSExtensionPointIdentifier - com.apple.share-services - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).ShareViewController - -``` - -| Key | Value | Meaning | -|-----|-------|---------| -| `NSExtensionPointIdentifier` | `com.apple.share-services` | Registers as a Share sheet extension | -| `NSExtensionPrincipalClass` | `$(PRODUCT_MODULE_NAME).ShareViewController` | Code-based entry point | -| `NSExtensionActivationRule` | `…ImageWithMaxCount = 1` | Activates for shares containing at least one image; processes one | -| `NSExtensionMainStoryboard` | *absent* | Confirms code-based (no storyboard UI) | - -### TimeSafariShareExtension Target (project.pbxproj) - -| Attribute | Value | -|-----------|-------| -| `isa` | `PBXNativeTarget` | -| `productType` | `com.apple.product-type.app-extension` | -| `productReference` | `TimeSafariShareExtension.appex` | -| `CreatedOnToolsVersion` | `26.1.1` | -| File membership | `fileSystemSynchronizedGroups` → `TimeSafariShareExtension` (auto-membership) | -| Sources build phase | Empty explicit list (handled by synchronized group) | -| `Info.plist` membership | Excepted from synchronized group (`PBXFileSystemSynchronizedBuildFileExceptionSet`) | -| Embedded into | App target's "Embed Foundation Extensions" copy phase | -| Target dependency | App target depends on `TimeSafariShareExtension` | - -### Extension Build Settings (Debug / Release) - -| Setting | Debug | Release | -|---------|-------|---------| -| `INFOPLIST_FILE` | `TimeSafariShareExtension/Info.plist` | same | -| `GENERATE_INFOPLIST_FILE` | `YES` | `YES` | -| `PRODUCT_NAME` | `$(TARGET_NAME)` → `TimeSafariShareExtension` | same | -| `PRODUCT_BUNDLE_IDENTIFIER` | `app.trentlarson.timesafari.TimeSafariShareExtension` | `app.timesafari.TimeSafariShareExtension` | -| `CODE_SIGN_ENTITLEMENTS` | `TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | same | -| `IPHONEOS_DEPLOYMENT_TARGET` | `14.0` | `14.0` | -| `DEVELOPMENT_TEAM` | `7XVXYPEQYJ` | `GM3FS5JQPH` | -| `SWIFT_VERSION` | `5.0` | `5.0` | -| `SKIP_INSTALL` | `YES` | `YES` | - -`PRODUCT_MODULE_NAME` is not overridden, so it defaults to `PRODUCT_NAME` = `TimeSafariShareExtension`, making the principal class resolve to `TimeSafariShareExtension.ShareViewController`. - -### ShareViewController Linkage - -```swift -import UIKit -import UniformTypeIdentifiers - -class ShareViewController: UIViewController { - ... - override func viewDidLoad() { - // writes shareExtensionLastStart, logs, then super + processAndOpenApp() - } -} -``` - -- Subclass of `UIViewController` → eligible as a code-based principal class. -- Lives in the `TimeSafariShareExtension` target via the synchronized group. -- No `@objc(...)` annotation is required because the principal class is referenced with the fully-qualified Swift name (`module.Class`). - -### Scene / Lifecycle Configuration - -- **No** `UIApplicationSceneManifest` / `UISceneConfigurations` in the extension `Info.plist`. -- **No** `SceneDelegate` in the extension target. -- The extension relies entirely on the principal `UIViewController` lifecycle (`viewDidLoad` → `processAndOpenApp` → `processSharedImage` → `completeRequest`). -- The main app (`AppDelegate`) is a `UIApplicationDelegate` and is unrelated to the extension's lifecycle except via the shared App Group. - -### App Group Linkage - -`TimeSafariShareExtension.entitlements` grants `group.app.timesafari.share`, matching the App target's entitlement. This is what allows `viewDidLoad()`'s `shareExtensionLastStart` write to be visible to the main app's `getShareExtensionDiagnostics()`. - ---- - -## Observations / Non-Blocking Notes - -1. **Config-dependent bundle IDs & teams.** Debug uses `app.trentlarson.timesafari*` with team `7XVXYPEQYJ`; Release uses `app.timesafari*` with team `GM3FS5JQPH`. Within each configuration the extension bundle ID is correctly nested under the app bundle ID. Ensure provisioning profiles for both teams include the App Group capability. - -2. **`GENERATE_INFOPLIST_FILE = YES` alongside an explicit `INFOPLIST_FILE`.** Xcode merges auto-generated keys into the supplied `Info.plist`. This is supported and the explicit `NSExtension` block is preserved; no conflict observed. - -3. **Deployment target gap.** Extension targets iOS 14.0 while the App target targets iOS 15.5. Valid (an extension may target lower), and not a launch concern. - -4. **Principal-class resolution dependency.** The launch guarantee depends on `$(PRODUCT_MODULE_NAME)` matching the compiled module. If `PRODUCT_MODULE_NAME` is later customized or the target renamed without updating expectations, the OS would fail to instantiate `ShareViewController` and `viewDidLoad()` would never run. Currently consistent. - -5. **Code-based template divergence.** Since there is no `MainInterface.storyboard`, any future tooling or documentation that assumes the stock storyboard-based Share Extension template will not apply here. - ---- - -## Conclusion - -The `TimeSafariShareExtension` is a **code-based** Share extension whose entry point is `ShareViewController` (a `UIViewController` subclass) via `NSExtensionPrincipalClass`. The Info.plist, (absent) storyboard, and Swift implementation are mutually consistent. Under normal extension launch, `ShareViewController.viewDidLoad()` is guaranteed to run, executing the startup marker and the share-processing pipeline. No blocking misconfiguration was found; only environment-specific items (signing identities, principal-class resolution dependency) warrant ongoing attention. diff --git a/doc/share-target-ios-audit.md b/doc/share-target-ios-audit.md deleted file mode 100644 index 0a4c3a02..00000000 --- a/doc/share-target-ios-audit.md +++ /dev/null @@ -1,524 +0,0 @@ -# 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 | On-disk filename in container (`.`) | -| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Original display filename from source app | -| `sharedPhotoShareId` | UserDefaults (suite) | Share Extension | Unique UUID per incoming share (Phase 1A) | -| `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() │ -│ (read-only; leaves native data intact) │ -└─────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────┐ -│ 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()` | Read-only: reads file from App Group, returns base64 + fileName; leaves metadata and file intact (Phase 1C) | -| | `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` | `clearSharedPhotoReadyFlag()` | `sharedPhotoReady` | -| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `clearSharedPhotoReadyFlag()`) | - -**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`): - -| Keys / files | Mechanism | -|--------------|-----------| -| `sharedPhotoFilePath` | `userDefaults.removeObject(forKey:)` | -| `sharedPhotoFileName` | `userDefaults.removeObject(forKey:)` | -| Image file at `sharedPhotoFilePath` | `FileManager.removeItem(at:)` | -| `userDefaults.synchronize()` after deletion | Called after metadata/file removal | - -### Deletes — Shared Image Files (App Group Container) - -| File | Method | Details | -|------|--------|---------| -| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | Removes previous pending share file at prior `sharedPhotoFilePath` before write | - -**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`): - -| Operation | Mechanism | -|-----------|-----------| -| Delete image file after successful read | `FileManager.removeItem(at: fileURL)` | - -### 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 new share write | `ShareViewController.swift` | `storeImageData` | Previous pending share file at prior `sharedPhotoFilePath` | -| Legacy cleanup on write | `ShareViewController.swift` | `storeImageData` | `sharedPhotoBase64` UserDefaults key | -| On app activation (flag only) | `AppDelegate.swift` | `checkForSharedImageOnActivation` | `sharedPhotoReady` flag | - -**Removed in Phase 1C** (no longer deleted on retrieve): - -| When | File | Method | What was deleted | -|------|------|--------|------------------| -| On successful read | `SharedImageUtility.swift` | `getSharedImageData` | `sharedPhotoFilePath`, `sharedPhotoFileName`, image file | - -### 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 persist after `getSharedImageData()` (Phase 1C). Cleanup is deferred to a later phase. The `sharedPhotoReady` flag is still cleared independently by `AppDelegate` on activation. - ---- - -## Potential Race Conditions - -1. **Multiple JS detection paths, repeatable native read.** `applicationDidBecomeActive`, the 1000ms startup timer, `appStateChange`, and `appUrlOpen` can all invoke `checkAndStoreNativeSharedImage()` close together. Since Phase 1C, `getSharedImageData()` is read-only and returns the same data on every call until a new share overwrites metadata or explicit cleanup is added. The `isProcessingSharedImage` JS lock still reduces duplicate temp-DB writes and navigations. - -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 `getSharedImage()` directly. Since Phase 1C both methods are non-destructive on the native layer. - ---- - -## Share ID Tracking - -**Implemented:** 2026-06-23 (Phase 1A) - -Phase 1A adds a unique share identifier to the iOS share flow for observability and future reliability work. Existing retrieval and deletion behavior is unchanged. - -### Identifier - -| Property | Value | -|----------|-------| -| UserDefaults key | `sharedPhotoShareId` | -| Format | `UUID().uuidString` (e.g. `A1B2C3D4-E5F6-7890-ABCD-EF1234567890`) | -| Generated in | `ShareViewController.processSharedImage` when the first image attachment is found | -| Persisted in | `ShareViewController.storeImageData` alongside `sharedPhotoFilePath` and `sharedPhotoFileName` | - -### Logging - -All log lines use the prefix `[ShareTarget]` and include `shareId=`: - -| Event | File | Method | When | -|-------|------|--------|------| -| share received | `ShareViewController.swift` | `processSharedImage` | UUID generated before `loadItem` | -| file stored | `ShareViewController.swift` | `storeImageData` | After successful `imageData.write(to:)` | -| metadata stored | `ShareViewController.swift` | `storeImageData` | After UserDefaults `synchronize()` | -| share retrieved | `SharedImageUtility.swift` | `getSharedImageData` | After successful file read (Phase 1C log format) | - -Example log sequence for a single share: - -``` -[ShareTarget] share received shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 -[ShareTarget] file stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg -[ShareTarget] metadata stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg -[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 retrieved -[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 left intact after retrieval -``` - -### Phase 1A Scope (Intentionally Unchanged) - -- `getSharedImageData()` still returns only `base64` and `fileName` to JavaScript -- `sharedPhotoShareId` is not deleted on retrieve (cleanup deferred to a later phase) -- `hasSharedImage()`, `isSharedPhotoReady()`, and JS consumption paths are unchanged -- Android code is unchanged - -### Write Inventory Addition - -| File | Method | Key Written | -|------|--------|-------------| -| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | `sharedPhotoShareId` | - -### Read Inventory Addition - -| File | Method | Key Read | -|------|--------|----------| -| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoShareId` (logging only) | - ---- - -## Unique Stored Filenames - -**Implemented:** 2026-06-23 (Phase 1B) - -Phase 1B eliminates on-disk filename collisions by storing each shared image under a UUID-based filename while preserving the original filename as metadata for consumers. - -### On-Disk vs Metadata - -| Field | UserDefaults key | Example | Purpose | -|-------|------------------|---------|---------| -| Stored filename | `sharedPhotoFilePath` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg` | Unique file in App Group container | -| Original filename | `sharedPhotoFileName` | `vacation-photo.jpg` | Returned to JS as `fileName` | -| Share ID | `sharedPhotoShareId` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890` | Correlates logs across extension and main app | - -Stored filename format: `.`, where extension is taken from the original filename (defaults to `jpg` when absent). - -### Implementation - -| File | Method | Change | -|------|--------|--------| -| `ShareViewController.swift` | `fileExtension(from:)` | Extracts extension from original filename | -| `ShareViewController.swift` | `storedFileName(shareId:originalFileName:)` | Builds `.` | -| `ShareViewController.swift` | `storeImageData` | Writes to stored filename; saves original in `sharedPhotoFileName` | -| `SharedImageUtility.swift` | `getSharedImageData` | Reads file via `sharedPhotoFilePath`; returns original `sharedPhotoFileName` | - -When a new share arrives before the previous one is retrieved, `storeImageData` removes the file at the previous `sharedPhotoFilePath` before writing, preserving single-pending-share semantics. - -### Logging (Phase 1B) - -Store and retrieve events include all three identifiers: - -``` -[ShareTarget] file stored shareId= originalFilename= storedFilename=. -[ShareTarget] metadata stored shareId= originalFilename= storedFilename=. -[ShareTarget] shareId= retrieved -[ShareTarget] shareId= left intact after retrieval -``` - -### Phase 1B Scope (Intentionally Unchanged) - -- `getSharedImageData()` still returns only `base64` and original `fileName` to JavaScript -- Android code is unchanged - ---- - -## Non-Destructive Retrieval - -**Implemented:** 2026-06-24 (Phase 1C) - -Phase 1C makes native shared-content retrieval read-only. `getSharedImageData()` and `SharedImagePlugin.getSharedImage()` no longer delete App Group metadata or image files after a successful read. Explicit cleanup is deferred to a later phase. - -### Behavior Change - -| Aspect | Before Phase 1C | After Phase 1C | -|--------|-----------------|----------------| -| `sharedPhotoFilePath` after retrieve | Removed | Retained | -| `sharedPhotoFileName` after retrieve | Removed | Retained | -| `sharedPhotoShareId` after retrieve | Retained (since Phase 1A) | Retained | -| Image file after retrieve | Deleted | Retained | -| Return value to JS | `{ base64, fileName }` | Unchanged | -| Repeat `getSharedImage()` calls | Return `null` after first success | Return same data until overwritten or cleaned up | - -### Logging - -After a successful read: - -``` -[ShareTarget] shareId= retrieved -[ShareTarget] shareId= left intact after retrieval -``` - -### Removed Deletion Paths - -All removal logic was in `SharedImageUtility.getSharedImageData()`: - -| # | What was deleted | Code removed | -|---|------------------|--------------| -| 1 | `sharedPhotoFilePath` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFilePathKey)` | -| 2 | `sharedPhotoFileName` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFileNameKey)` | -| 3 | Image file at `{container}/{sharedPhotoFilePath}` | `FileManager.default.removeItem(at: fileURL)` | -| 4 | Post-deletion UserDefaults flush | `userDefaults.synchronize()` after removals | - -`SharedImagePlugin.getSharedImage(_:)` delegated to `getSharedImageData()` and had no independent deletion logic. Comment updated to reflect read-only behavior. - -### Phase 1C Scope (Intentionally Unchanged) - -- No new cleanup or purge APIs added -- `clearSharedPhotoReadyFlag()` and share-extension write-side file removal unchanged -- JS temp DB deletion in `main.capacitor.ts` and `SharedPhotoView.vue` unchanged -- Android code unchanged - ---- - -## Deterministic Startup Plugin Readiness - -**Implemented:** 2026-06-26 (Phase 2A) - -Phase 2A removes the iOS startup race between native `SharedImage` plugin -registration and the first JS shared-image check. All changes are confined to -`src/main.capacitor.ts`; no Swift code changed. - -### Race condition removed - -Previously the initial iOS shared-image check ran on a fixed timer: - -```ts -const checkDelays = ... : [1000]; // iOS -checkDelays.forEach((delay) => setTimeout(() => checkForSharedImageAndNavigate(), delay)); -``` - -The native `SharedImagePlugin` is registered asynchronously by -`AppDelegate.didFinishLaunchingWithOptions` with up to 5 retries starting at -T+500ms (`AppDelegate.swift:21–40`). The fixed 1000ms JS delay only *assumed* -registration had completed by then. When registration was slow (or the WebView -booted unusually fast), the first `checkForSharedImageAndNavigate()` could call -`SharedImage.getSharedImage()` before the native plugin existed, the call would -throw, and the cold-start share could be missed until a later `appStateChange`. -This corresponds to race condition #3 in *Potential Race Conditions* above. - -### How plugin readiness is now determined - -The fixed 1000ms iOS delay is replaced with an explicit, deterministic wait -(`waitForSharedImagePluginReady()` in `main.capacitor.ts`): - -- The plugin is probed with a lightweight, read-only `SharedImage.hasSharedImage()` - call. A successful resolution proves the native plugin instance is registered - and reachable from JS. `hasSharedImage()` does not consume or mutate the pending - share (non-destructive since Phase 1C), so probing is side-effect free. -- If the probe throws (plugin not yet registered), it retries within a bounded - budget: `STARTUP_PLUGIN_MAX_ATTEMPTS = 10` attempts spaced - `STARTUP_PLUGIN_RETRY_DELAY_MS = 300` ms apart (~3s ceiling, covering the - native registration window). No arbitrary sleep is used to *assume* readiness; - the delay is only the inter-retry backoff while polling for actual availability. -- The very first `checkForSharedImageAndNavigate()` runs only after the probe - succeeds. If the budget is exhausted (should not happen in practice), the check - is still attempted once as a best-effort fallback so behavior is never worse - than the previous fixed-delay path, and `appStateChange` retries on the next - activation. - -### Temporary diagnostics - -The retry sequence emits `[ShareTarget]` console diagnostics, consistent with the -existing TEMPORARY SHARE TARGET DIAGNOSTICS convention: - -``` -[ShareTarget] Startup shared-image check waiting for SharedImage plugin -[ShareTarget] SharedImage plugin ready after N attempt(s) -[ShareTarget] Startup shared-image check giving up after N attempt(s) -``` - -### Phase 2A Scope (Intentionally Unchanged) - -The retry/readiness logic applies **only** to the initial startup shared-image -check. The following are deliberately untouched: - -- `appStateChange` handling (`CapacitorApp.addListener("appStateChange", ...)`) -- `appUrlOpen` handling (`handleDeepLink`, `registerDeepLinkListener`) -- Router navigation to `/shared-photo` -- Share processing (`checkAndStoreNativeSharedImage`, `storeSharedImageInTempDB`) -- Android startup behavior (still `[500, 1500, 3000]` ms multi-delay checks) -- All native Swift code, including the `AppDelegate` plugin-registration retry - -Because readiness is now confirmed by an actual plugin response rather than a -timer, the startup check no longer depends on registration timing, while every -other detection path keeps its previous semantics as redundant backstops. - ---- - -## Configuration References - -| 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` | diff --git a/doc/share-target-ios-launch-flow-audit.md b/doc/share-target-ios-launch-flow-audit.md deleted file mode 100644 index f66b6522..00000000 --- a/doc/share-target-ios-launch-flow-audit.md +++ /dev/null @@ -1,515 +0,0 @@ -# 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.