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.
This commit is contained in:
188
doc/share-extension-configuration-audit.md
Normal file
188
doc/share-extension-configuration-audit.md
Normal 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.
|
||||
Reference in New Issue
Block a user