Compare commits

...

2 Commits

Author SHA1 Message Date
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
2 changed files with 343 additions and 0 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.