Compare commits

..

12 Commits

Author SHA1 Message Date
Jose Olarte III
02e6e3427d refactor(ios): remove SharedImage plugin-readiness polling (Phase 2B-3)
The SharedImage plugin is now registered deterministically from
AppBridgeViewController.capacitorDidLoad() before the web layer loads, so
JS no longer needs to wait for it. Remove the readiness machinery that
existed solely to tolerate the old async AppDelegate registration:
waitForSharedImagePluginReady(), the sleep() helper, the
STARTUP_PLUGIN_MAX_ATTEMPTS/RETRY_DELAY_MS constants and retry loop, and
the three readiness-only console diagnostics (waiting / ready after N /
giving up).

The iOS startup branch now calls checkForSharedImageAndNavigate()
immediately. All other share-target behavior is unchanged: cold-start,
extension, and launch diagnostics; native trace APIs; the Share Target
Debug Panel; appStateChange/appUrlOpen handling; actual shared-image
handling; and the Android startup path. No JS readiness diagnostics
remain; no Android changes.
2026-06-26 21:14:59 +08:00
Jose Olarte III
337a8f7536 refactor(ios): remove AppDelegate SharedImage registration (Phase 2B-2)
Now that AppBridgeViewController registers SharedImagePlugin from
capacitorDidLoad(), remove the obsolete plugin-registration logic from
AppDelegate: the 5-attempt retry loop in didFinishLaunchingWithOptions,
the registerSharedImagePlugin() method, and the registration-specific
logging. SharedImagePlugin is now registered from a single, deterministic
site.

All non-registration responsibilities are unchanged: notification
handling, URL and universal-link proxying, SharedPhotoReady activation
logic, every lifecycle callback, the temporary app launch tracing, and
all temporary share-target diagnostics remain intact. No JS or Android
changes.
2026-06-26 19:35:43 +08:00
Jose Olarte III
4978e93711 feat(ios): register SharedImage via CAPBridgeViewController subclass (Phase 2B-1)
Introduce AppBridgeViewController, a CAPBridgeViewController subclass that
registers the app-local SharedImagePlugin from capacitorDidLoad(), where
the Capacitor bridge is guaranteed to exist. This is the first step toward
replacing AppDelegate's timed retry registration with a deterministic,
bridge-ready registration point.

Point the existing root bridge controller in Main.storyboard at
AppBridgeViewController (same VC id/scene; only the custom class changes)
and add the new file to the App target in project.pbxproj.

The existing AppDelegate registration and its retry loop are intentionally
left in place as a temporary safety net, so the plugin is now registered
from both sites during this phase. No diagnostics, retry logic, JS, or
Android code changed; plugin name ("SharedImage") and implementation are
unchanged.
2026-06-26 19:26:00 +08:00
Jose Olarte III
9941264022 chore(ios): add temporary Share Target debug panel (temporary)
Add a dedicated, temporary debug view for the iOS Share Target
investigation so native traces can be inspected without attaching Xcode.
Kept separate from the Notification/Notiwind test panel so it is easy to
remove once the investigation is complete.

The panel (src/views/ShareTargetDebugView.vue, route
/share-target-debug) has three buttons:
- Dump Native Traces: calls SharedImage.getShareExtensionTrace() and
  getAppLaunchTrace(), logs both in full (untruncated) between
  EXTENSION TRACE START/END and APP LAUNCH TRACE START/END markers, and
  shows them in read-only scrollable fields.
- Clear Share Extension Trace: clears the native log and the field.
- Clear App Launch Trace: clears the native log and the field.

Register the route alongside the existing /test debug route and link to
the panel from TestView. No share-target behavior changed; Android
untouched. All additions marked TEMPORARY SHARE TARGET DIAGNOSTICS.
2026-06-26 18:10:08 +08:00
Jose Olarte III
256018d30d chore(ios): add native app launch lifecycle trace diagnostics (temporary)
Add temporary, append-only tracing of the native iOS application launch
lifecycle to diagnose failed cold-start shares when Xcode is not
attached during launch. Writes app-launch-trace.log in the same App
Group container as share-extension-trace.log, reusing the same style
(ISO8601 timestamps, append-only, all logging failures swallowed).

AppDelegate now traces every lifecycle callback
(didFinishLaunchingWithOptions, application(open:),
applicationDidBecomeActive, applicationWill/DidEnterForeground/Background,
applicationWillResignActive, applicationWillTerminate, and
checkForSharedImageOnActivation), recording the callback name, whether a
URL was supplied, the URL value, whether it matches timesafari://,
whether launch options carried a URL, and whether shared-image
activation ran.

Expose read-only getAppLaunchTrace()/clearAppLaunchTrace() plugin APIs
(mirroring the share-extension trace APIs) with matching TypeScript
definitions and web stubs. main.capacitor.ts logs the full launch trace
between APP LAUNCH TRACE START/END markers inside the existing
diagnostics block.

No share-target behavior changed; Android untouched. All additions are
marked TEMPORARY SHARE TARGET DIAGNOSTICS.
2026-06-26 17:06:50 +08:00
Jose Olarte III
c1a5bae5c8 feat(ios): wait for SharedImage plugin before initial startup check (Phase 2A)
Eliminate the iOS startup race between asynchronous SharedImage plugin
registration and the first shared-image check. Previously the initial
check fired on a fixed 1000ms timer that only assumed the native plugin
(registered by AppDelegate with retries from T+500ms) was ready, so a
slow registration could make the first getSharedImage() call throw and
miss a cold-start share.

Replace the fixed delay with waitForSharedImagePluginReady(), which
probes the plugin via a read-only hasSharedImage() call and retries
within a bounded budget (10 attempts, 300ms apart) until the plugin
actually responds. The initial check runs only once readiness is
confirmed, with a best-effort fallback if the budget is exhausted.

Scope is limited to the initial startup check on iOS. appStateChange,
appUrlOpen/handleDeepLink, router navigation, share processing, Android
behavior, and all native Swift code are unchanged. Temporary
share-target diagnostics are preserved and extended with startup
readiness logging.

Document the change as a Phase 2A section in
doc/share-target-ios-audit.md.
2026-06-26 16:48:00 +08:00
Jose Olarte III
c9061e669e docs(ios): add share-target launch and deep-link flow audit
Add doc/share-target-ios-launch-flow-audit.md documenting the iOS
Share Extension launch path from openMainApp("timesafari://") through
the native AppDelegate, Capacitor bridge, and JS bootstrap to
/shared-photo navigation.

Covers cold- vs warm-start execution order, the single appUrlOpen
listener and its call graph, all shared-image detection mechanisms
(startup timer, appStateChange, applicationDidBecomeActive,
sharedPhotoReady flag, SharedPhotoReady notification), native launch-URL
handling, and a timing analysis of the race conditions.

Key findings: cold-start shares rely on the T+1000ms startup timer
reading the App Group payload (not appUrlOpen, which registers at
T+2000ms after the launch URL is delivered); warm-start shares are
driven by appUrlOpen/appStateChange; the launch URL event and the
SharedPhotoReady notification can be lost before JS is ready, but the
durable App Group payload is not. Audit only; no code changes.
2026-06-26 15:43:42 +08:00
Jose Olarte III
7b1fec779b chore(ios): log full Share Extension trace on startup (temporary)
Surface the complete Share Extension execution trace during app startup
so the cold-start share path can be inspected even when Xcode truncates
console output.

In checkForSharedImageAndNavigate(), after the existing diagnostics
call, retrieve SharedImage.getShareExtensionTrace() and:
- log the full untruncated trace between TRACE FULL START/END markers
- show the full trace in a user-visible alert (iOS only)
- log TRACE LENGTH and the first 500 characters

Share-target behavior is unchanged and the trace work is gated to iOS,
so Android is unaffected. All additions are marked with
"TEMPORARY SHARE TARGET DIAGNOSTICS".
2026-06-25 19:57:42 +08:00
Jose Olarte III
d1106d9aec chore(ios): add Share Extension execution trace diagnostics (temporary)
Add a lightweight, temporary trace logger to diagnose where the iOS
Share Extension stops executing during a failed cold-start share.

- ShareViewController: add appendTrace() helper that writes ISO8601-
  prefixed lines to share-extension-trace.log in the App Group
  container, ignoring failures (diagnostics only)
- Add trace entries across the share flow: viewDidLoad,
  processAndOpenApp, processSharedImage, image attachment/load,
  storeImageData, setSharedPhotoReadyFlag, openMainApp, completeRequest
- SharedImageUtility: add getShareExtensionTrace() (read-only) and
  clearShareExtensionTrace() (deletes the trace file)
- SharedImagePlugin: expose getShareExtensionTrace() and
  clearShareExtensionTrace() to JS
- definitions.ts / SharedImagePlugin.web.ts: add ShareExtensionTrace
  type, method signatures, and web stubs

Share behavior is unchanged and Android is untouched. All additions are
marked with "TEMPORARY SHARE TARGET DIAGNOSTICS".
2026-06-25 19:10:57 +08:00
Jose Olarte III
6f7be2e3b2 chore(ios): add pendingShareExists cold-start share diagnostics (temporary)
Extend ShareExtensionDiagnostics with pendingShareExists across
SharedImageUtility, definitions, and the web stub, and log cold-start
state (pending share + current route) in the iOS startup share check.
2026-06-25 18:37:04 +08:00
Jose Olarte III
4fc30562fb docs(ios): add App Group configuration audit for share extension
Document App/extension entitlements, bundle IDs, teams, and signing,
flagging a code-vs-entitlement App Group ID mismatch (code uses
group.app.timesafari.share while entitlements grant
group.app.trentlarson.timesafari.share) that breaks shared storage.
2026-06-25 17:48:53 +08:00
Jose Olarte III
6afe40bc23 docs(ios): add Share Extension configuration audit
Document that TimeSafariShareExtension is a code-based extension with
ShareViewController as NSExtensionPrincipalClass, confirm viewDidLoad
executes on launch, and note no blocking Info.plist/storyboard mismatches.
2026-06-25 17:28:20 +08:00
18 changed files with 1598 additions and 94 deletions

View File

@@ -0,0 +1,155 @@
# 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.

View File

@@ -0,0 +1,188 @@
# 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.

View File

@@ -436,6 +436,81 @@ All removal logic was in `SharedImageUtility.getSharedImageData()`:
---
## 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:2140`). 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 |

View File

@@ -0,0 +1,515 @@
# iOS Share-Target Launch & Deep-Link Flow Audit
**Date:** 2026-06-26 15:32:14 PST
**Scope:** iOS Share Extension launch path and `timesafari://` deep-link handling only.
**Status:** Read-only audit. No code was modified. **No code changes are recommended.**
This document traces the complete execution path that begins when the iOS
Share Extension calls `application.open("timesafari://")` and ends with
navigation to `/shared-photo`. All references cite file, method, and
approximate line numbers as of the audit date.
### Files in scope
| File | Role |
| --- | --- |
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | Share Extension: stores image to App Group, opens `timesafari://` |
| `ios/App/App/AppDelegate.swift` | Native app delegate: lifecycle + URL open proxy |
| `ios/App/App/SharedImagePlugin.swift` | Capacitor plugin bridge (`SharedImage`) |
| `ios/App/App/SharedImageUtility.swift` | App Group read/write helpers + ready flag |
| `src/main.capacitor.ts` | JS bootstrap, deep-link listener, shared-image checks, navigation |
| `src/main.common.ts` | `initializeApp()` — Vue app + router construction |
| `src/router/index.ts` | Router creation, `/shared-photo` route |
| `src/libs/capacitor/app.ts` | Type-safe wrapper around `@capacitor/app` listeners |
| `src/plugins/SharedImagePlugin.ts` | JS `registerPlugin("SharedImage")` |
| `capacitor.config.ts` | `appUrlOpen` handler config for `timesafari://*` |
---
## 1. Cold-start launch path
"Cold start" = the main app process is **not** running when the user taps Share.
### Execution order
1. **Share Extension `viewDidLoad()`**
`ShareViewController.swift:47` → calls `processAndOpenApp()` (`:64`).
2. **`processAndOpenApp()`** — `ShareViewController.swift:70`
Reads `extensionContext.inputItems`, then calls `processSharedImage(...)` (`:97`).
3. **`processSharedImage(...)`** — `ShareViewController.swift:148`
Loads the first image attachment, then `storeImageData(...)` (`:245`).
4. **`storeImageData(...)`** — `ShareViewController.swift:292`
Writes the image file into the App Group container and writes metadata keys
(`sharedPhotoFilePath`, `sharedPhotoFileName`, `sharedPhotoShareId`) to
`UserDefaults(suiteName: "group.app.trentlarson.timesafari.share")` (`:340347`).
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 (`:2140`,
`registerSharedImagePlugin()` at `:46`). Registration requires the
Capacitor bridge to exist (`:4851`).
- **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 (`:4748`).
- `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
(`:474483`) → `checkForSharedImageAndNavigate()` at **T+1000ms**.
- **`appStateChange` listener registered** (`:491496`).
- **Deep-link listener registration scheduled** via `setTimeout(..., 2000)`
(`:500510`) → `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 17
(`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 (`:203207`), 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:218248`
- `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:1119` declares the `App.appUrlOpen` handler for
`timesafari://*` with `autoVerify: true` (config, not a JS listener).
- `src/libs/capacitor/app.ts:3235, 4259` 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 (`:201207`).
- **`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 [:201207]
│ └─ 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:474483` (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:491496` | 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:474483`. 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` (`:439449`). 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:213214` mention "polling internally,"
but the implementation (`:131192`) 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:2140` 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
(`:1343`) **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:340347`, 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:2140` |
| ~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:474483,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:2140`); 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:160167`), 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
(`:7576`), 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:474483` (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:340347`) 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.

View File

@@ -18,6 +18,7 @@
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
C8C56E182EE0700A00737D0E /* AppBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E172EE0700A00737D0E /* AppBridgeViewController.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -59,6 +60,7 @@
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.swift; sourceTree = "<group>"; };
C8C56E172EE0700A00737D0E /* AppBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBridgeViewController.swift; sourceTree = "<group>"; };
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -74,18 +76,7 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = TimeSafariShareExtension;
sourceTree = "<group>";
};
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = TimeSafariShareExtension; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -138,6 +129,7 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8C56E172EE0700A00737D0E /* AppBridgeViewController.swift */,
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
@@ -357,6 +349,7 @@
buildActionMask = 2147483647;
files = (
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
C8C56E182EE0700A00737D0E /* AppBridgeViewController.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
);

View File

@@ -0,0 +1,30 @@
//
// AppBridgeViewController.swift
// App
//
// Capacitor bridge view controller subclass.
//
// Phase 2B-1: registers the app-local SharedImagePlugin from the deterministic
// capacitorDidLoad() lifecycle callback, where the Capacitor bridge is
// guaranteed to exist. The existing AppDelegate registration is intentionally
// left in place as a temporary safety net during this phase.
//
import UIKit
import Capacitor
class AppBridgeViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
super.capacitorDidLoad()
// Register the app-local SharedImage plugin using the same approach as
// AppDelegate. The @objc(SharedImage) annotation exposes it as
// "SharedImage" to JavaScript. At this point the bridge is guaranteed
// to be available (capacitorDidLoad runs immediately after the bridge
// is created).
let pluginInstance = SharedImagePlugin()
bridge?.registerPluginInstance(pluginInstance)
print("[AppBridgeViewController] ✅ Registered SharedImagePlugin (exposed as 'SharedImage' via @objc annotation)")
}
}

View File

@@ -9,72 +9,58 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// TEMPORARY SHARE TARGET DIAGNOSTICS
let launchURL = launchOptions?[.url] as? URL
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"didFinishLaunchingWithOptions urlSupplied=\(launchURL != nil) url=\(launchURL?.absoluteString ?? "nil") matchesTimesafari=\(AppDelegate.isTimesafariURL(launchURL)) launchOptionsHasURL=\(launchURL != nil) sharedImageActivationInvoked=false"
)
// Set notification center delegate so notifications show in foreground and rollover is triggered
UNUserNotificationCenter.current().delegate = self
// Initialize SQLite
//let sqlite = SQLite()
//sqlite.initialize()
// Register SharedImage plugin manually after bridge is ready
// Try multiple times with increasing delays to ensure bridge is initialized
var attempts = 0
let maxAttempts = 5
func tryRegister() {
attempts += 1
if registerSharedImagePlugin() {
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
} else if attempts < maxAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
tryRegister()
}
} else {
print("[AppDelegate] ⚠️ Failed to register plugin after \(maxAttempts) attempts")
}
}
// Start registration attempts
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
tryRegister()
}
// SharedImagePlugin is registered from AppBridgeViewController.capacitorDidLoad()
// (Phase 2B-2). The previous AppDelegate retry-based registration was removed.
// Override point for customization after application launch.
return true
}
@discardableResult
private func registerSharedImagePlugin() -> Bool {
guard let window = self.window,
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
let bridge = bridgeVC.bridge else {
return false
}
// Create plugin instance
// The @objc(SharedImage) annotation makes it available as "SharedImage" to Objective-C
// which matches the JavaScript registration name
let pluginInstance = SharedImagePlugin()
bridge.registerPluginInstance(pluginInstance)
print("[AppDelegate] ✅ Registered SharedImagePlugin (exposed as 'SharedImage' via @objc annotation)")
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"applicationWillResignActive urlSupplied=false url=nil matchesTimesafari=false launchOptionsHasURL=false sharedImageActivationInvoked=false"
)
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
// Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game.
}
func applicationDidEnterBackground(_ application: UIApplication) {
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"applicationDidEnterBackground urlSupplied=false url=nil matchesTimesafari=false launchOptionsHasURL=false sharedImageActivationInvoked=false"
)
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
}
func applicationWillEnterForeground(_ application: UIApplication) {
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"applicationWillEnterForeground urlSupplied=false url=nil matchesTimesafari=false launchOptionsHasURL=false sharedImageActivationInvoked=false"
)
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
}
func applicationDidBecomeActive(_ application: UIApplication) {
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"applicationDidBecomeActive urlSupplied=false url=nil matchesTimesafari=false launchOptionsHasURL=false sharedImageActivationInvoked=true"
)
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
// Re-set notification delegate when app becomes active (in case Capacitor resets it)
@@ -116,7 +102,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
*/
private func checkForSharedImageOnActivation() {
// Check if shared photo is ready
if SharedImageUtility.isSharedPhotoReady() {
// TEMPORARY SHARE TARGET DIAGNOSTICS
let isReady = SharedImageUtility.isSharedPhotoReady()
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"checkForSharedImageOnActivation urlSupplied=false url=nil matchesTimesafari=false launchOptionsHasURL=false sharedImageActivationInvoked=true sharedPhotoReady=\(isReady)"
)
if isReady {
// Clear the flag
SharedImageUtility.clearSharedPhotoReadyFlag()
@@ -127,10 +119,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
func applicationWillTerminate(_ application: UIApplication) {
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"applicationWillTerminate urlSupplied=false url=nil matchesTimesafari=false launchOptionsHasURL=false sharedImageActivationInvoked=false"
)
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// TEMPORARY SHARE TARGET DIAGNOSTICS
SharedImageUtility.appendAppLaunchTrace(
"application(open:) urlSupplied=true url=\(url.absoluteString) matchesTimesafari=\(AppDelegate.isTimesafariURL(url)) launchOptionsHasURL=false sharedImageActivationInvoked=false"
)
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
@@ -138,6 +138,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/// Returns true when the supplied URL uses the timesafari:// scheme.
/// Diagnostics-only helper; does not affect URL handling.
private static func isTimesafariURL(_ url: URL?) -> Bool {
return url?.scheme?.lowercased() == "timesafari"
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
// Called when the app was launched with an activity, including Universal Links.
// Feel free to add additional processing here, but if you want the App API to support

View File

@@ -11,7 +11,7 @@
<!--Bridge View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
<viewController id="BYZ-38-t0r" customClass="AppBridgeViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>

View File

@@ -2,6 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.timesafari.dailynotification.fetch</string>
<string>org.timesafari.dailynotification.notify</string>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
@@ -18,6 +25,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
@@ -26,6 +44,13 @@
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Time Safari allows you to upload photos.</string>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -47,30 +72,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>org.timesafari.dailynotification.fetch</string>
<string>org.timesafari.dailynotification.notify</string>
<string>org.timesafari.dailynotification.content-fetch</string>
<string>org.timesafari.dailynotification.notification-delivery</string>
</array>
<key>NSUserNotificationAlertStyle</key>
<string>alert</string>
</dict>
</plist>

View File

@@ -25,7 +25,15 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
return [
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise),
CAPPluginMethod(#selector(getShareExtensionDiagnostics(_:)), returnType: .promise)
CAPPluginMethod(#selector(getShareExtensionDiagnostics(_:)), returnType: .promise),
// TEMPORARY SHARE TARGET DIAGNOSTICS
CAPPluginMethod(#selector(getShareExtensionTrace(_:)), returnType: .promise),
// TEMPORARY SHARE TARGET DIAGNOSTICS
CAPPluginMethod(#selector(clearShareExtensionTrace(_:)), returnType: .promise),
// TEMPORARY SHARE TARGET DIAGNOSTICS
CAPPluginMethod(#selector(getAppLaunchTrace(_:)), returnType: .promise),
// TEMPORARY SHARE TARGET DIAGNOSTICS
CAPPluginMethod(#selector(clearAppLaunchTrace(_:)), returnType: .promise)
]
}
@@ -70,5 +78,43 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
@objc public func getShareExtensionDiagnostics(_ call: CAPPluginCall) {
call.resolve(SharedImageUtility.getShareExtensionDiagnostics())
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Return the raw Share Extension execution trace log from the App Group container
*/
@objc public func getShareExtensionTrace(_ call: CAPPluginCall) {
call.resolve([
"trace": SharedImageUtility.getShareExtensionTrace()
])
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Delete the Share Extension execution trace log if present
*/
@objc public func clearShareExtensionTrace(_ call: CAPPluginCall) {
SharedImageUtility.clearShareExtensionTrace()
call.resolve()
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Return the raw app launch lifecycle trace log from the App Group container
*/
@objc public func getAppLaunchTrace(_ call: CAPPluginCall) {
call.resolve([
"trace": SharedImageUtility.getAppLaunchTrace()
])
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Delete the app launch lifecycle trace log if present
*/
@objc public func clearAppLaunchTrace(_ call: CAPPluginCall) {
SharedImageUtility.clearAppLaunchTrace()
call.resolve()
}
}

View File

@@ -16,6 +16,10 @@ public class SharedImageUtility {
private static let sharedPhotoShareIdKey = "sharedPhotoShareId"
private static let shareExtensionLastStartKey = "shareExtensionLastStart"
private static let sharedPhotoReadyKey = "sharedPhotoReady"
// TEMPORARY SHARE TARGET DIAGNOSTICS
private static let shareExtensionTraceFileName = "share-extension-trace.log"
// TEMPORARY SHARE TARGET DIAGNOSTICS
private static let appLaunchTraceFileName = "app-launch-trace.log"
/// Get the App Group container URL for accessing shared files
private static var appGroupContainerURL: URL? {
@@ -108,7 +112,9 @@ public class SharedImageUtility {
"shareExtensionLastStart": NSNull(),
"sharedPhotoShareId": NSNull(),
"sharedPhotoFilePath": NSNull(),
"fileExists": false
"fileExists": false,
// TEMPORARY SHARE TARGET DIAGNOSTICS
"pendingShareExists": false
]
}
@@ -123,16 +129,101 @@ public class SharedImageUtility {
fileExists = false
}
print("[ShareTarget] getShareExtensionDiagnostics shareExtensionLastStart=\(shareExtensionLastStart ?? "nil") sharedPhotoShareId=\(shareId ?? "nil") sharedPhotoFilePath=\(filePath ?? "nil") fileExists=\(fileExists)")
// TEMPORARY SHARE TARGET DIAGNOSTICS
let pendingShareExists = (
shareExtensionLastStart != nil ||
shareId != nil ||
filePath != nil ||
fileExists == true
)
print("[ShareTarget] getShareExtensionDiagnostics shareExtensionLastStart=\(shareExtensionLastStart ?? "nil") sharedPhotoShareId=\(shareId ?? "nil") sharedPhotoFilePath=\(filePath ?? "nil") fileExists=\(fileExists) pendingShareExists=\(pendingShareExists)")
return [
"shareExtensionLastStart": shareExtensionLastStart ?? NSNull(),
"sharedPhotoShareId": shareId ?? NSNull(),
"sharedPhotoFilePath": filePath ?? NSNull(),
"fileExists": fileExists
"fileExists": fileExists,
// TEMPORARY SHARE TARGET DIAGNOSTICS
"pendingShareExists": pendingShareExists
]
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Read the Share Extension trace file from the App Group container.
* Read-only: does not modify or delete the trace file.
*
* @returns the full trace contents, or an empty string if no trace exists
*/
static func getShareExtensionTrace() -> String {
guard let containerURL = appGroupContainerURL else {
return ""
}
let fileURL = containerURL.appendingPathComponent(shareExtensionTraceFileName)
return (try? String(contentsOf: fileURL, encoding: .utf8)) ?? ""
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Delete the Share Extension trace file from the App Group container if present.
*/
static func clearShareExtensionTrace() {
guard let containerURL = appGroupContainerURL else {
return
}
let fileURL = containerURL.appendingPathComponent(shareExtensionTraceFileName)
try? FileManager.default.removeItem(at: fileURL)
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Append a single timestamped line to the app launch trace file in the
* App Group container. Each line is prefixed with an ISO8601 timestamp.
* Append-only; logging failures are intentionally swallowed (diagnostics only).
*/
static func appendAppLaunchTrace(_ message: String) {
guard let containerURL = appGroupContainerURL else { return }
let fileURL = containerURL.appendingPathComponent(appLaunchTraceFileName)
let timestamp = ISO8601DateFormatter().string(from: Date())
let line = "\(timestamp) \(message)\n"
guard let data = line.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: fileURL) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
try? data.write(to: fileURL, options: .atomic)
}
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Read the app launch trace file from the App Group container.
* Read-only: does not modify or delete the trace file.
*
* @returns the full trace contents, or an empty string if no trace exists
*/
static func getAppLaunchTrace() -> String {
guard let containerURL = appGroupContainerURL else {
return ""
}
let fileURL = containerURL.appendingPathComponent(appLaunchTraceFileName)
return (try? String(contentsOf: fileURL, encoding: .utf8)) ?? ""
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Delete the app launch trace file from the App Group container if present.
*/
static func clearAppLaunchTrace() {
guard let containerURL = appGroupContainerURL else {
return
}
let fileURL = containerURL.appendingPathComponent(appLaunchTraceFileName)
try? FileManager.default.removeItem(at: fileURL)
}
/**
* Check if shared photo ready flag is set
* This flag is set by the Share Extension when image is ready

View File

@@ -17,12 +17,36 @@ class ShareViewController: UIViewController {
private let shareExtensionLastStartKey = "shareExtensionLastStart"
private let sharedImageFileName = "shared-image"
// TEMPORARY SHARE TARGET DIAGNOSTICS
private let shareExtensionTraceFileName = "share-extension-trace.log"
/// Get the App Group container URL for storing shared files
private var appGroupContainerURL: URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/// Append a single timestamped line to the Share Extension trace file in the
/// App Group container. Each line is prefixed with an ISO8601 timestamp.
/// Logging failures are intentionally ignored (diagnostics only).
private func appendTrace(_ message: String) {
guard let containerURL = appGroupContainerURL else { return }
let fileURL = containerURL.appendingPathComponent(shareExtensionTraceFileName)
let timestamp = ISO8601DateFormatter().string(from: Date())
let line = "\(timestamp) \(message)\n"
guard let data = line.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: fileURL) {
defer { try? handle.close() }
_ = try? handle.seekToEnd()
try? handle.write(contentsOf: data)
} else {
try? data.write(to: fileURL, options: .atomic)
}
}
override func viewDidLoad() {
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("viewDidLoad START")
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) {
let timestamp = ISO8601DateFormatter().string(from: Date())
userDefaults.set(timestamp, forKey: shareExtensionLastStartKey)
@@ -39,19 +63,29 @@ class ShareViewController: UIViewController {
// Process image immediately without showing UI
processAndOpenApp()
print("[ShareTarget] viewDidLoad completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("viewDidLoad END")
}
private func processAndOpenApp() {
print("[ShareTarget] processAndOpenApp started")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("processAndOpenApp START")
// extensionContext is automatically available on UIViewController when used as extension principal class
guard let context = extensionContext,
let inputItems = context.inputItems as? [NSExtensionItem] else {
print("[ShareTarget] processAndOpenApp failed: missing extensionContext or inputItems")
print("[ShareTarget] completeRequest starting")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("completeRequest START")
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
print("[ShareTarget] completeRequest completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("completeRequest END")
print("[ShareTarget] processAndOpenApp completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("processAndOpenApp END")
return
}
@@ -64,6 +98,8 @@ class ShareViewController: UIViewController {
guard let self = self, let context = self.extensionContext else {
print("[ShareTarget] processAndOpenApp failed: self or extensionContext unavailable in completion")
print("[ShareTarget] processAndOpenApp completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self?.appendTrace("processAndOpenApp END")
return
}
@@ -78,23 +114,35 @@ class ShareViewController: UIViewController {
// Complete immediately - no UI shown
print("[ShareTarget] completeRequest starting")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("completeRequest START")
context.completeRequest(returningItems: [], completionHandler: nil)
print("[ShareTarget] completeRequest completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("completeRequest END")
print("[ShareTarget] processAndOpenApp completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("processAndOpenApp END")
}
}
private func setSharedPhotoReadyFlag() {
print("[ShareTarget] setSharedPhotoReadyFlag started")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("setSharedPhotoReadyFlag START")
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("[ShareTarget] setSharedPhotoReadyFlag failed: UserDefaults unavailable for app group")
print("[ShareTarget] setSharedPhotoReadyFlag completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("setSharedPhotoReadyFlag FAILURE")
return
}
userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize()
print("[ShareTarget] setSharedPhotoReadyFlag success")
print("[ShareTarget] setSharedPhotoReadyFlag completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("setSharedPhotoReadyFlag SUCCESS")
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
@@ -102,6 +150,8 @@ class ShareViewController: UIViewController {
count + (item.attachments?.count ?? 0)
}
print("[ShareTarget] processSharedImage started attachmentCount=\(attachmentCount)")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("processSharedImage START")
// Find the first image attachment
for item in items {
@@ -120,6 +170,8 @@ class ShareViewController: UIViewController {
let shareId = UUID().uuidString
print("[ShareTarget] processSharedImage found image attachment shareId=\(shareId) UTType=\(UTType.image.identifier)")
print("[ShareTarget] share received shareId=\(shareId)")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("image attachment found shareId=\(shareId)")
// Try to load raw data first to preserve original format
// This preserves the original image format without conversion
@@ -134,6 +186,8 @@ class ShareViewController: UIViewController {
if let error = error {
print("[ShareTarget] processSharedImage failed: loadItem error shareId=\(shareId) error=\(error.localizedDescription)")
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("processSharedImage END success=false")
completion(false)
return
}
@@ -177,19 +231,27 @@ class ShareViewController: UIViewController {
guard let finalImageData = imageData else {
print("[ShareTarget] processSharedImage failed: no image data shareId=\(shareId)")
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("processSharedImage END success=false")
completion(false)
return
}
print("[ShareTarget] image loaded bytes=\(finalImageData.count) filename=\(fileName) shareId=\(shareId)")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("image loaded shareId=\(shareId) bytes=\(finalImageData.count)")
// Store image as file in App Group container
if self.storeImageData(finalImageData, fileName: fileName, shareId: shareId) {
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=true")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("processSharedImage END success=true")
completion(true)
} else {
print("[ShareTarget] processSharedImage failed: storeImageData returned false shareId=\(shareId)")
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
// TEMPORARY SHARE TARGET DIAGNOSTICS
self.appendTrace("processSharedImage END success=false")
completion(false)
}
}
@@ -200,6 +262,8 @@ class ShareViewController: UIViewController {
// No image found
print("[ShareTarget] processSharedImage failed: no image attachment found attachmentCount=\(attachmentCount)")
print("[ShareTarget] processSharedImage completed success=false")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("processSharedImage END success=false")
completion(false)
}
@@ -227,10 +291,14 @@ class ShareViewController: UIViewController {
/// Returns true if successful, false otherwise
private func storeImageData(_ imageData: Data, fileName: String, shareId: String) -> Bool {
print("[ShareTarget] storeImageData started shareId=\(shareId) bytes=\(imageData.count) filename=\(fileName)")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("storeImageData START shareId=\(shareId)")
guard let containerURL = appGroupContainerURL else {
print("[ShareTarget] storeImageData failed: app group container unavailable shareId=\(shareId)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("storeImageData FAILURE shareId=\(shareId)")
return false
}
@@ -253,6 +321,8 @@ class ShareViewController: UIViewController {
} catch {
print("[ShareTarget] storeImageData failed: file write error shareId=\(shareId) error=\(error.localizedDescription)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("storeImageData FAILURE shareId=\(shareId)")
return false
}
print("[ShareTarget] file stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)")
@@ -261,6 +331,8 @@ class ShareViewController: UIViewController {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
print("[ShareTarget] storeImageData failed: UserDefaults unavailable shareId=\(shareId)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("storeImageData FAILURE shareId=\(shareId)")
return false
}
@@ -276,16 +348,22 @@ class ShareViewController: UIViewController {
print("[ShareTarget] metadata stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)")
print("[ShareTarget] storeImageData success shareId=\(shareId)")
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=true")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("storeImageData SUCCESS shareId=\(shareId)")
return true
}
private func openMainApp() {
print("[ShareTarget] openMainApp starting")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("openMainApp START")
// Open the main app with minimal URL - app will detect shared data on activation
guard let url = URL(string: "timesafari://") else {
print("[ShareTarget] openMainApp failed: could not create timesafari:// URL")
print("[ShareTarget] openMainApp completed")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("openMainApp FAILURE")
return
}
@@ -294,6 +372,8 @@ class ShareViewController: UIViewController {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
print("[ShareTarget] openMainApp completed via UIApplication")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("openMainApp SUCCESS")
return
}
responder = responder?.next
@@ -302,6 +382,8 @@ class ShareViewController: UIViewController {
// Fallback: use extension context
extensionContext?.open(url, completionHandler: nil)
print("[ShareTarget] openMainApp completed via extensionContext fallback")
// TEMPORARY SHARE TARGET DIAGNOSTICS
appendTrace("openMainApp SUCCESS")
}
}

View File

@@ -363,6 +363,20 @@ async function checkForSharedImageAndNavigate() {
if (Capacitor.getPlatform() === "ios") {
try {
const diagnostics = await SharedImage.getShareExtensionDiagnostics();
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info(
"[ShareTarget] Cold-start state",
JSON.stringify({
pendingShareExists: diagnostics.pendingShareExists,
shareExtensionLastStart: diagnostics.shareExtensionLastStart,
sharedPhotoShareId: diagnostics.sharedPhotoShareId,
sharedPhotoFilePath: diagnostics.sharedPhotoFilePath,
fileExists: diagnostics.fileExists,
currentRoute: router.currentRoute.value.fullPath,
appReady: true,
timestamp: new Date().toISOString(),
}),
);
logger.info(`[ShareTarget] Diagnostics ${JSON.stringify(diagnostics)}`);
} catch (diagnosticsError) {
logger.info(
@@ -374,6 +388,44 @@ async function checkForSharedImageAndNavigate() {
})}`,
);
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
const traceResult = await SharedImage.getShareExtensionTrace();
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info("[ShareTarget] TRACE FULL START");
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info(traceResult.trace);
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info("[ShareTarget] TRACE FULL END");
// TEMPORARY SHARE TARGET DIAGNOSTICS
if (Capacitor.getPlatform() === "ios") {
alert(traceResult.trace);
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info("[ShareTarget] Extension Trace\n" + traceResult.trace);
// TEMPORARY SHARE TARGET DIAGNOSTICS
const traceLength = traceResult.trace.length;
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info(`[ShareTarget] TRACE LENGTH=${traceLength}`);
// TEMPORARY SHARE TARGET DIAGNOSTICS
if (traceLength > 0) {
console.info(
"[ShareTarget] TRACE FIRST 500\n" + traceResult.trace.slice(0, 500),
);
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
const launchTraceResult = await SharedImage.getAppLaunchTrace();
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info("[ShareTarget] APP LAUNCH TRACE START");
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info(launchTraceResult.trace);
// TEMPORARY SHARE TARGET DIAGNOSTICS
console.info("[ShareTarget] APP LAUNCH TRACE END");
}
logger.debug("[Main] 🔍 Checking for shared image on app activation");
@@ -422,22 +474,22 @@ logger.info(`[Main] ✅ App mounted successfully`);
// Check for shared image on initial load (in case app was launched from share sheet)
// On Android, share intents are processed in MainActivity.onCreate, so we need to check
// after a delay to ensure the native code has finished processing
if (
Capacitor.isNativePlatform() &&
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
) {
// Use multiple checks with increasing delays to handle timing issues
// Android share intent processing happens in onCreate, which may complete after JS loads
const checkDelays =
Capacitor.getPlatform() === "android"
? [500, 1500, 3000] // Android needs more time for share intent processing
: [1000]; // iOS is faster
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android") {
// Android behavior unchanged: multiple checks with increasing delays because
// share intent processing happens in onCreate, which may complete after JS loads.
const checkDelays = [500, 1500, 3000]; // Android needs more time for share intent processing
checkDelays.forEach((delay) => {
setTimeout(async () => {
await checkForSharedImageAndNavigate();
}, delay);
});
} else if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === "ios") {
// Phase 2B-3: the SharedImage plugin is now registered deterministically from
// AppBridgeViewController.capacitorDidLoad() before the web layer loads, so it
// is guaranteed to exist here. Perform the initial shared-image check
// immediately without waiting/polling for plugin readiness.
void checkForSharedImageAndNavigate();
}
// Listen for app state changes to detect when app becomes active

View File

@@ -8,6 +8,10 @@ import type {
SharedImagePlugin,
SharedImageResult,
ShareExtensionDiagnostics,
// TEMPORARY SHARE TARGET DIAGNOSTICS
ShareExtensionTrace,
// TEMPORARY SHARE TARGET DIAGNOSTICS
AppLaunchTrace,
} from "./definitions";
export class SharedImagePluginWeb
@@ -29,6 +33,28 @@ export class SharedImagePluginWeb
sharedPhotoShareId: null,
sharedPhotoFilePath: null,
fileExists: false,
// TEMPORARY SHARE TARGET DIAGNOSTICS
pendingShareExists: false,
};
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
async getShareExtensionTrace(): Promise<ShareExtensionTrace> {
return { trace: "" };
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
async clearShareExtensionTrace(): Promise<void> {
// Web platform doesn't support native sharing - no-op
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
async getAppLaunchTrace(): Promise<AppLaunchTrace> {
return { trace: "" };
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
async clearAppLaunchTrace(): Promise<void> {
// Web platform doesn't support native launch tracing - no-op
}
}

View File

@@ -12,6 +12,18 @@ export interface ShareExtensionDiagnostics {
sharedPhotoShareId: string | null;
sharedPhotoFilePath: string | null;
fileExists: boolean;
// TEMPORARY SHARE TARGET DIAGNOSTICS
pendingShareExists: boolean;
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
export interface ShareExtensionTrace {
trace: string;
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
export interface AppLaunchTrace {
trace: string;
}
export interface SharedImagePlugin {
@@ -32,4 +44,28 @@ export interface SharedImagePlugin {
* Diagnostic snapshot of Share Extension startup and pending share state (iOS)
*/
getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics>;
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Read the raw Share Extension execution trace log (iOS)
*/
getShareExtensionTrace(): Promise<ShareExtensionTrace>;
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Delete the Share Extension execution trace log if present (iOS)
*/
clearShareExtensionTrace(): Promise<void>;
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Read the raw app launch lifecycle trace log (iOS)
*/
getAppLaunchTrace(): Promise<AppLaunchTrace>;
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Delete the app launch lifecycle trace log if present (iOS)
*/
clearAppLaunchTrace(): Promise<void>;
}

View File

@@ -290,6 +290,12 @@ const routes: Array<RouteRecordRaw> = [
name: "test",
component: () => import("../views/TestView.vue"),
},
// TEMPORARY SHARE TARGET DIAGNOSTICS
{
path: "/share-target-debug",
name: "share-target-debug",
component: () => import("../views/ShareTargetDebugView.vue"),
},
{
path: "/user-profile/:id?",
name: "user-profile",

View File

@@ -0,0 +1,187 @@
<!-- TEMPORARY SHARE TARGET DIAGNOSTICS -->
<!--
ShareTargetDebugView
Temporary, standalone debug panel for the iOS Share Target investigation.
Lets a tester dump and clear the native trace logs (share-extension-trace.log
and app-launch-trace.log) from the App Group container without attaching Xcode.
This entire view is temporary and intended to be deleted once the Share Target
investigation is complete. It does not change any share-target behavior.
-->
<template>
<QuickNav />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Share Target Debug
</h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="$router.go(-1)"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
</div>
<p class="text-sm text-gray-600 mb-4">
Temporary diagnostics for the iOS Share Target investigation. Dumps the
native trace logs to the console and to the read-only fields below.
</p>
<!-- Action buttons -->
<div class="flex flex-wrap gap-2 mb-4">
<button :class="primaryButtonClasses" @click="dumpNativeTraces()">
Dump Native Traces
</button>
<button :class="warningButtonClasses" @click="clearExtensionTrace()">
Clear Share Extension Trace
</button>
<button :class="warningButtonClasses" @click="clearAppLaunchTrace()">
Clear App Launch Trace
</button>
</div>
<!-- Status message -->
<div
v-if="statusMessage"
class="mb-4 p-2 text-sm rounded-md bg-emerald-50 text-emerald-800 border border-emerald-200"
>
{{ statusMessage }}
</div>
<!-- Extension trace -->
<div class="mb-6">
<h2 class="text-lg font-semibold mb-2">Share Extension Trace</h2>
<textarea
:value="extensionTrace"
readonly
class="w-full h-64 p-2 border border-gray-300 rounded-md font-mono text-xs whitespace-pre overflow-auto"
placeholder="No extension trace loaded. Tap 'Dump Native Traces'."
></textarea>
</div>
<!-- App launch trace -->
<div class="mb-6">
<h2 class="text-lg font-semibold mb-2">App Launch Trace</h2>
<textarea
:value="appLaunchTrace"
readonly
class="w-full h-64 p-2 border border-gray-300 rounded-md font-mono text-xs whitespace-pre overflow-auto"
placeholder="No app launch trace loaded. Tap 'Dump Native Traces'."
></textarea>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { SharedImage } from "../plugins/SharedImagePlugin";
import { logger } from "../utils/logger";
/**
* TEMPORARY SHARE TARGET DIAGNOSTICS
*
* Dedicated debug panel for inspecting the iOS Share Target native traces.
* Temporary; remove once the Share Target investigation is complete.
*/
@Component({
components: {
QuickNav,
},
})
export default class ShareTargetDebugView extends Vue {
$router!: Router;
// TEMPORARY SHARE TARGET DIAGNOSTICS
extensionTrace = "";
// TEMPORARY SHARE TARGET DIAGNOSTICS
appLaunchTrace = "";
// TEMPORARY SHARE TARGET DIAGNOSTICS
statusMessage = "";
// TEMPORARY SHARE TARGET DIAGNOSTICS
get primaryButtonClasses(): string {
return "font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md";
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
get warningButtonClasses(): string {
return "font-bold capitalize bg-amber-600 text-white px-3 py-2 rounded-md";
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Retrieve both native traces, log them in full to the console, and display
* them in the read-only fields. Does not truncate either trace.
*/
async dumpNativeTraces(): Promise<void> {
try {
const extensionResult = await SharedImage.getShareExtensionTrace();
const launchResult = await SharedImage.getAppLaunchTrace();
this.extensionTrace = extensionResult.trace;
this.appLaunchTrace = launchResult.trace;
// Log full (untruncated) traces to the console.
console.info("[ShareTarget] EXTENSION TRACE START");
console.info(extensionResult.trace);
console.info("[ShareTarget] EXTENSION TRACE END");
console.info("");
console.info("[ShareTarget] APP LAUNCH TRACE START");
console.info(launchResult.trace);
console.info("[ShareTarget] APP LAUNCH TRACE END");
this.statusMessage =
"Dumped native traces (see console and fields below).";
} catch (error) {
logger.error("[ShareTarget] Failed to dump native traces:", error);
this.statusMessage = `Failed to dump native traces: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Clear the native Share Extension trace log and the displayed value.
*/
async clearExtensionTrace(): Promise<void> {
try {
await SharedImage.clearShareExtensionTrace();
this.extensionTrace = "";
this.statusMessage = "Share Extension trace cleared.";
} catch (error) {
logger.error("[ShareTarget] Failed to clear extension trace:", error);
this.statusMessage = `Failed to clear Share Extension trace: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
// TEMPORARY SHARE TARGET DIAGNOSTICS
/**
* Clear the native app launch trace log and the displayed value.
*/
async clearAppLaunchTrace(): Promise<void> {
try {
await SharedImage.clearAppLaunchTrace();
this.appLaunchTrace = "";
this.statusMessage = "App Launch trace cleared.";
} catch (error) {
logger.error("[ShareTarget] Failed to clear app launch trace:", error);
this.statusMessage = `Failed to clear App Launch trace: ${
error instanceof Error ? error.message : String(error)
}`;
}
}
}
</script>

View File

@@ -112,6 +112,21 @@
</router-link>
</div>
<!-- TEMPORARY SHARE TARGET DIAGNOSTICS -->
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Share Target Diagnostics</h2>
<p class="text-sm text-gray-600 mb-3">
Temporary debug panel for the iOS Share Target investigation (dump/clear
native traces).
</p>
<router-link
:to="{ name: 'share-target-debug' }"
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
>
Open Share Target Debug Panel
</router-link>
</div>
<!-- URL Flow Testing Section -->
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">URL Flow Testing</h2>