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.
This commit is contained in:
@@ -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
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.trentlarson.timesafari.share</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
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
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.trentlarson.timesafari.share</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
`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.
|
||||
@@ -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
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
```
|
||||
|
||||
`$(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
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
```
|
||||
|
||||
| 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.
|
||||
@@ -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 (`<shareId>.<ext>`) |
|
||||
| `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}` (`<shareId>.<ext>`) |
|
||||
|
||||
---
|
||||
|
||||
## 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=<id>`:
|
||||
|
||||
| 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: `<shareId>.<extension>`, 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 `<shareId>.<ext>` |
|
||||
| `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=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
|
||||
[ShareTarget] metadata stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
|
||||
[ShareTarget] shareId=<id> retrieved
|
||||
[ShareTarget] shareId=<id> 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=<id> retrieved
|
||||
[ShareTarget] shareId=<id> 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` |
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user