Compare commits
51 Commits
edit-proj-
...
02e6e3427d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02e6e3427d | ||
|
|
337a8f7536 | ||
|
|
4978e93711 | ||
|
|
9941264022 | ||
|
|
256018d30d | ||
|
|
c1a5bae5c8 | ||
|
|
c9061e669e | ||
|
|
7b1fec779b | ||
|
|
d1106d9aec | ||
|
|
6f7be2e3b2 | ||
|
|
4fc30562fb | ||
|
|
6afe40bc23 | ||
|
|
402bd2681f | ||
|
|
498a4926bf | ||
|
|
f0ca49b5dc | ||
|
|
07463246f0 | ||
|
|
79ceebbd1d | ||
|
|
ddbd07f315 | ||
|
|
35a6a6bfb3 | ||
|
|
08a55202f5 | ||
| ec41dd52d5 | |||
| 463db39a6b | |||
| fe97dff752 | |||
|
|
903047f13b | ||
|
|
48be234af4 | ||
| 6c0907d905 | |||
|
|
8d8bcf2a7e | ||
| a4b47904c8 | |||
|
|
bb890baacf | ||
| dae23300fe | |||
| 9e401febea | |||
| cd4b279703 | |||
| a3a2d97b9a | |||
| 802050259c | |||
| efd7d50a84 | |||
| 39c389cda8 | |||
| 93fdcaf7ff | |||
| ad419efa0d | |||
| ce45ddb2bd | |||
| 7d306bd204 | |||
| 9713313a40 | |||
|
|
ffa7bac319 | ||
| e0e0a0a183 | |||
| ea662f4430 | |||
| 81647e1f3c | |||
| bf1ee78025 | |||
|
|
66b7d0f46e | ||
|
|
63dcf44125 | ||
| cf1ecdfb4c | |||
| e9ad61b780 | |||
| ad8df3eb93 |
@@ -1 +1 @@
|
||||
18.19.0
|
||||
20.18.1
|
||||
|
||||
14
BUILDING.md
14
BUILDING.md
@@ -333,11 +333,11 @@ The `serve` functionality provides a local HTTP server for testing production bu
|
||||
- If there are DB changes: before updating the test server, open browser(s) with
|
||||
current version to test DB migrations.
|
||||
|
||||
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run
|
||||
- Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run:
|
||||
`npm install`.
|
||||
|
||||
- Run a build to make sure package-lock version is updated, linting works, etc:
|
||||
`npm install && npm run build:web`
|
||||
- Run a build to make sure linting works, etc:
|
||||
`npm run build:web`
|
||||
|
||||
- Commit everything (since the commit hash is used the app).
|
||||
|
||||
@@ -346,7 +346,7 @@ current version to test DB migrations.
|
||||
|
||||
- Tag with the new version,
|
||||
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
|
||||
`git tag 1.0.2 && git push origin 1.0.2`.
|
||||
`git tag 1.3.13 && git push origin 1.3.13`.
|
||||
|
||||
- For test, build the app:
|
||||
|
||||
@@ -1140,7 +1140,7 @@ export GEM_PATH=$shortened_path
|
||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||
|
||||
```bash
|
||||
cd ios/App && xcrun agvtool new-version 67 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.12;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
cd ios/App && xcrun agvtool new-version 69 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.3;/g" App.xcodeproj/project.pbxproj && cd -
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
```
|
||||
@@ -1419,8 +1419,8 @@ The recommended way to build for Android is using the automated build script:
|
||||
##### 1. Bump the version in package.json, then update these versions & run:
|
||||
|
||||
```bash
|
||||
perl -p -i -e 's/versionCode .*/versionCode 67/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.3.12"/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionCode .*/versionCode 69/g' android/app/build.gradle
|
||||
perl -p -i -e 's/versionName .*/versionName "1.4.3"/g' android/app/build.gradle
|
||||
```
|
||||
|
||||
##### 2. Build
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -6,6 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
|
||||
## [1.4.3] - 2026.06.19
|
||||
### Removed
|
||||
- Automatic "Check your starred projects" daily notification
|
||||
### Changed
|
||||
- Positioning for 'Thank' button and entity-type toggle link
|
||||
|
||||
|
||||
## [1.4.2] - 2026.05.24
|
||||
### Changed
|
||||
- Support 16 KB page sizes
|
||||
|
||||
|
||||
## [1.3.13] - 2026.04.05
|
||||
### Added
|
||||
- Ability to select project that the current one fulfills
|
||||
- Separate Terms & Conditions page (required for SMS campaigns)
|
||||
### Fixed
|
||||
- Edits to a 'give' would delete the image
|
||||
|
||||
|
||||
## [1.3.12] - 2026.03.21
|
||||
### Added
|
||||
- Device wake-up for notifications
|
||||
|
||||
23
README.md
23
README.md
@@ -15,10 +15,31 @@ Quick start:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Web
|
||||
|
||||
```bash
|
||||
npm run build:web:dev
|
||||
```
|
||||
|
||||
To be able to take action on the platform: go to [the test page](http://localhost:8080/test) and click "Become User 0".
|
||||
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
|
||||
|
||||
### Android
|
||||
|
||||
```bash
|
||||
npm run build:android:test:run
|
||||
```
|
||||
|
||||
Assumes ADB is installed; see [Android Build](BUILDING.md#android-build) for SDK, emulator, and `PATH` setup.
|
||||
|
||||
### iOS
|
||||
|
||||
```bash
|
||||
npm run build:ios:studio
|
||||
```
|
||||
|
||||
Assumes Xcode and Xcode Command Line Tools are installed.
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||
|
||||
|
||||
@@ -29,16 +29,16 @@ android {
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "app.timesafari.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 67
|
||||
versionName "1.3.12"
|
||||
versionCode 69
|
||||
versionName "1.4.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -72,13 +72,14 @@ android {
|
||||
}
|
||||
packagingOptions {
|
||||
jniLibs {
|
||||
// Required for 16 KB page-size support: keep native libs uncompressed and
|
||||
// page-aligned inside the APK (default on AGP 8.x with minSdk 23+, set
|
||||
// explicitly so it does not regress).
|
||||
useLegacyPackaging = false
|
||||
pickFirsts += ['**/lib/x86_64/libbarhopper_v3.so', '**/lib/x86_64/libimage_processing_util_jni.so', '**/lib/x86_64/libsqlcipher.so']
|
||||
}
|
||||
}
|
||||
|
||||
// Configure for 16 KB page size compatibility
|
||||
|
||||
|
||||
// Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease)
|
||||
bundle {
|
||||
language {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"appName": "Giftopia",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"cleartext": true
|
||||
@@ -34,12 +34,12 @@
|
||||
"iosIsEncryption": false,
|
||||
"iosBiometric": {
|
||||
"biometricAuth": false,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
"biometricTitle": "Biometric login for Giftopia"
|
||||
},
|
||||
"androidIsEncryption": false,
|
||||
"androidBiometric": {
|
||||
"biometricAuth": false,
|
||||
"biometricTitle": "Biometric login for TimeSafari"
|
||||
"biometricTitle": "Biometric login for Giftopia"
|
||||
},
|
||||
"electronIsEncryption": false
|
||||
},
|
||||
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"buildOptions": {
|
||||
"appId": "app.timesafari",
|
||||
"productName": "TimeSafari",
|
||||
"productName": "Giftopia",
|
||||
"directories": {
|
||||
"output": "dist-electron-packages"
|
||||
},
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.timesafari.dailynotification.FetchContext;
|
||||
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||
import org.timesafari.dailynotification.NotificationContent;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -47,22 +46,11 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
|
||||
// This should query the TimeSafari API for notification content
|
||||
// using the configured apiBaseUrl, activeDid, and jwtToken
|
||||
|
||||
// For now, return a placeholder notification
|
||||
long scheduledTime = fetchContext.scheduledTime != null
|
||||
? fetchContext.scheduledTime
|
||||
: System.currentTimeMillis() + 60000; // 1 minute from now
|
||||
|
||||
NotificationContent content = new NotificationContent(
|
||||
"TimeSafari Update",
|
||||
"Check your starred projects for updates!",
|
||||
scheduledTime
|
||||
);
|
||||
|
||||
List<NotificationContent> results = new ArrayList<>();
|
||||
results.add(content);
|
||||
|
||||
Log.d(TAG, "Returning " + results.size() + " notification(s)");
|
||||
return results;
|
||||
// Not implemented yet: return no content rather than fabricating a
|
||||
// placeholder notification (previously hardcoded "Check your starred
|
||||
// projects for updates!", which showed on every app startup).
|
||||
Log.d(TAG, "Content fetching not yet implemented; returning no notifications");
|
||||
return Collections.<NotificationContent>emptyList();
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Fetch failed", e);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
|
||||
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
|
||||
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||
classpath 'com.android.tools.build:gradle:8.7.2'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ apply plugin: 'com.android.library'
|
||||
|
||||
android {
|
||||
namespace "capacitor.cordova.android.plugins"
|
||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
|
||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
|
||||
defaultConfig {
|
||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
|
||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
|
||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
}
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
abortOnError false
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
ext {
|
||||
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
|
||||
cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
||||
// Plugin gradle extensions can append to this to have code run at the end.
|
||||
cdvPluginPostBuildExtras = []
|
||||
cordovaConfig = [:]
|
||||
|
||||
@@ -13,4 +13,11 @@ ext {
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
|
||||
// Pin CameraX to 1.4.2: first stable line shipping a 16 KB page-size-aligned
|
||||
// libimage_processing_util_jni.so. The barcode-scanning plugin still defaults to 1.1.0.
|
||||
androidxCameraCamera2Version = '1.4.2'
|
||||
androidxCameraCoreVersion = '1.4.2'
|
||||
androidxCameraLifecycleVersion = '1.4.2'
|
||||
androidxCameraViewVersion = '1.4.2'
|
||||
}
|
||||
155
doc/share-extension-app-group-audit.md
Normal file
155
doc/share-extension-app-group-audit.md
Normal 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.
|
||||
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.
|
||||
524
doc/share-target-ios-audit.md
Normal file
524
doc/share-target-ios-audit.md
Normal file
@@ -0,0 +1,524 @@
|
||||
# iOS Share Target Implementation Audit
|
||||
|
||||
**Generated:** 2026-06-23 17:07:21 PST
|
||||
|
||||
## Overview
|
||||
|
||||
The iOS share target uses a **Share Extension** (`TimeSafariShareExtension`) that writes a shared image to an **App Group** container (`group.app.timesafari.share`), then opens the main app via `timesafari://`. The main app reads the image through a native **Capacitor plugin** (`SharedImagePlugin`) and stores it in the JS temp database before routing to `/shared-photo`.
|
||||
|
||||
### App Group Storage Model
|
||||
|
||||
| Key | Storage | Written by | Purpose |
|
||||
|-----|---------|------------|---------|
|
||||
| `sharedPhotoFilePath` | UserDefaults (suite) | Share Extension | On-disk filename in container (`<shareId>.<ext>`) |
|
||||
| `sharedPhotoFileName` | UserDefaults (suite) | Share Extension | Original display filename from source app |
|
||||
| `sharedPhotoShareId` | UserDefaults (suite) | Share Extension | Unique UUID per incoming share (Phase 1A) |
|
||||
| `sharedPhotoReady` | UserDefaults (suite) | Share Extension | Boolean signal that a new share is available |
|
||||
| `sharedPhotoBase64` | UserDefaults (suite) | *(legacy, not written)* | Removed on write for cleanup |
|
||||
| Image file | App Group filesystem | Share Extension | Raw image bytes at `{container}/{sharedPhotoFilePath}` (`<shareId>.<ext>`) |
|
||||
|
||||
---
|
||||
|
||||
## End-to-End Flow
|
||||
|
||||
```
|
||||
External App (Photos, Safari, etc.)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ TimeSafariShareExtension │
|
||||
│ ShareViewController │
|
||||
│ 1. viewDidLoad → processAndOpenApp │
|
||||
│ 2. processSharedImage (async) │
|
||||
│ 3. storeImageData → file + metadata │
|
||||
│ 4. setSharedPhotoReadyFlag │
|
||||
│ 5. openMainApp (timesafari://) │
|
||||
│ 6. completeRequest │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼ App Group: group.app.timesafari.share
|
||||
│ (UserDefaults keys + image file)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Main App (app.timesafari) │
|
||||
│ │
|
||||
│ Native detection: │
|
||||
│ • AppDelegate.applicationDidBecome │
|
||||
│ Active → checkForSharedImageOn │
|
||||
│ Activation (flag only) │
|
||||
│ • AppDelegate.application(open:) │
|
||||
│ → Capacitor URL handling │
|
||||
│ │
|
||||
│ JS detection (main.capacitor.ts): │
|
||||
│ • setTimeout 1000ms startup check │
|
||||
│ • appStateChange (isActive) │
|
||||
│ • appUrlOpen → timesafari:// │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ SharedImagePlugin.getSharedImage() │
|
||||
│ → SharedImageUtility │
|
||||
│ .getSharedImageData() │
|
||||
│ (read-only; leaves native data intact) │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ main.capacitor.ts │
|
||||
│ storeSharedImageInTempDB() │
|
||||
│ → SQLite temp table │
|
||||
│ → router.push/replace /shared-photo│
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ SharedPhotoView.vue │
|
||||
│ loadSharedImage() │
|
||||
│ → reads temp DB, deletes temp row │
|
||||
│ → displays image, user action │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Share Extension (Writer)
|
||||
|
||||
| File | Method | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | `viewDidLoad()` | Entry point; triggers share processing on load |
|
||||
| | `processAndOpenApp()` | Orchestrates image extraction, flag set, app open, extension completion |
|
||||
| | `processSharedImage(from:completion:)` | Iterates `NSExtensionItem` attachments; loads first `UTType.image` via `loadItem` |
|
||||
| | `storeImageData(_:fileName:)` | Writes image file to App Group container; writes metadata to UserDefaults |
|
||||
| | `setSharedPhotoReadyFlag()` | Sets `sharedPhotoReady = true` in App Group UserDefaults |
|
||||
| | `openMainApp()` | Opens `timesafari://` via responder chain or `extensionContext.open` |
|
||||
| | `getFileNameWithExtension(_:newExtension:)` | Helper for PNG fallback filename |
|
||||
| `ios/App/TimeSafariShareExtension/Info.plist` | — | Declares share-services extension; accepts 1 image max |
|
||||
| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | — | Grants App Group `group.app.timesafari.share` |
|
||||
|
||||
### Main App Native Layer (Reader)
|
||||
|
||||
| File | Method | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `ios/App/App/SharedImageUtility.swift` | `getSharedImageData()` | Read-only: reads file from App Group, returns base64 + fileName; leaves metadata and file intact (Phase 1C) |
|
||||
| | `hasSharedImage()` | Non-destructive existence check (metadata + file on disk) |
|
||||
| | `isSharedPhotoReady()` | Reads `sharedPhotoReady` flag |
|
||||
| | `clearSharedPhotoReadyFlag()` | Removes `sharedPhotoReady` key |
|
||||
| `ios/App/App/SharedImagePlugin.swift` | `getSharedImage(_:)` | Capacitor bridge to `getSharedImageData()` |
|
||||
| | `hasSharedImage(_:)` | Capacitor bridge to `hasSharedImage()` |
|
||||
| `ios/App/App/AppDelegate.swift` | `application(_:didFinishLaunchingWithOptions:)` | Registers `SharedImagePlugin` with retry loop |
|
||||
| | `registerSharedImagePlugin()` | Manually registers plugin instance on Capacitor bridge |
|
||||
| | `applicationDidBecomeActive(_:)` | Calls `checkForSharedImageOnActivation()` |
|
||||
| | `checkForSharedImageOnActivation()` | Checks ready flag, clears it, posts `SharedPhotoReady` NSNotification |
|
||||
| | `application(_:open:options:)` | Forwards URL opens (including `timesafari://`) to Capacitor |
|
||||
| `ios/App/App/App.entitlements` | — | Grants App Group `group.app.timesafari.share` |
|
||||
|
||||
### JavaScript Layer (Consumer)
|
||||
|
||||
| File | Method | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `src/plugins/SharedImagePlugin.ts` | — | Registers Capacitor plugin name `SharedImage` |
|
||||
| `src/plugins/definitions.ts` | — | TypeScript interface for `getSharedImage` / `hasSharedImage` |
|
||||
| `src/main.capacitor.ts` | `checkAndStoreNativeSharedImage()` | Calls `SharedImage.getSharedImage()`, stores in temp DB; guarded by `isProcessingSharedImage` lock |
|
||||
| | `storeSharedImageInTempDB()` | Clears old temp row, inserts base64 data URL into SQLite `temp` table |
|
||||
| | `checkForSharedImageAndNavigate()` | Checks native share, navigates to `/shared-photo` on success |
|
||||
| | `handleDeepLink()` | Handles `timesafari://` empty-path URLs from share extension on iOS |
|
||||
| | `registerDeepLinkListener()` | Registers Capacitor `appUrlOpen` listener |
|
||||
| `src/views/SharedPhotoView.vue` | `mounted()` / `onRouteQueryChange()` | Loads image from temp DB for display |
|
||||
| | `loadSharedImage()` | Reads `SHARED_PHOTO_BASE64_KEY` from temp DB; **deletes temp row** after load |
|
||||
| `src/router/index.ts` | — | Defines `/shared-photo` route |
|
||||
| `src/libs/util.ts` | `SHARED_PHOTO_BASE64_KEY` | Temp DB key constant (`"shared-photo-base64"`) |
|
||||
|
||||
---
|
||||
|
||||
## Read / Write / Delete Inventory
|
||||
|
||||
### Writes — Shared Image Metadata (App Group UserDefaults)
|
||||
|
||||
| File | Method | Keys Written |
|
||||
|------|--------|--------------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
|
||||
| `ShareViewController.swift` | `setSharedPhotoReadyFlag()` | `sharedPhotoReady` (= `true`) |
|
||||
|
||||
### Writes — Shared Image Files (App Group Container)
|
||||
|
||||
| File | Method | Details |
|
||||
|------|--------|---------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `imageData.write(to:)` at `{containerURL}/{actualFileName}` |
|
||||
|
||||
### Reads — Shared Image Metadata (App Group UserDefaults)
|
||||
|
||||
| File | Method | Keys Read |
|
||||
|------|--------|-----------|
|
||||
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoFilePath`, `sharedPhotoFileName` |
|
||||
| `SharedImageUtility.swift` | `hasSharedImage()` | `sharedPhotoFilePath` |
|
||||
| `SharedImageUtility.swift` | `isSharedPhotoReady()` | `sharedPhotoReady` |
|
||||
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `isSharedPhotoReady()`) |
|
||||
|
||||
### Reads — Shared Image Files (App Group Container)
|
||||
|
||||
| File | Method | Details |
|
||||
|------|--------|---------|
|
||||
| `ShareViewController.swift` | `processSharedImage` (URL path) | Reads source image via `Data(contentsOf: url)` from security-scoped URL |
|
||||
| `SharedImageUtility.swift` | `getSharedImageData()` | `Data(contentsOf: fileURL)` from App Group container |
|
||||
| `SharedImageUtility.swift` | `hasSharedImage()` | `FileManager.fileExists(atPath:)` only (no data read) |
|
||||
|
||||
### Deletes — Shared Image Metadata (App Group UserDefaults)
|
||||
|
||||
| File | Method | Keys Removed |
|
||||
|------|--------|--------------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:)` | `sharedPhotoBase64` (legacy cleanup) |
|
||||
| `SharedImageUtility.swift` | `clearSharedPhotoReadyFlag()` | `sharedPhotoReady` |
|
||||
| `AppDelegate.swift` | `checkForSharedImageOnActivation()` | `sharedPhotoReady` (via `clearSharedPhotoReadyFlag()`) |
|
||||
|
||||
**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`):
|
||||
|
||||
| Keys / files | Mechanism |
|
||||
|--------------|-----------|
|
||||
| `sharedPhotoFilePath` | `userDefaults.removeObject(forKey:)` |
|
||||
| `sharedPhotoFileName` | `userDefaults.removeObject(forKey:)` |
|
||||
| Image file at `sharedPhotoFilePath` | `FileManager.removeItem(at:)` |
|
||||
| `userDefaults.synchronize()` after deletion | Called after metadata/file removal |
|
||||
|
||||
### Deletes — Shared Image Files (App Group Container)
|
||||
|
||||
| File | Method | Details |
|
||||
|------|--------|---------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | Removes previous pending share file at prior `sharedPhotoFilePath` before write |
|
||||
|
||||
**Removed in Phase 1C** (previously in `SharedImageUtility.getSharedImageData()`):
|
||||
|
||||
| Operation | Mechanism |
|
||||
|-----------|-----------|
|
||||
| Delete image file after successful read | `FileManager.removeItem(at: fileURL)` |
|
||||
|
||||
### Secondary Storage (Post-Native Consumption)
|
||||
|
||||
After native read, image data lives in SQLite `temp` table under key `shared-photo-base64`:
|
||||
|
||||
| File | Method | Operation |
|
||||
|------|--------|-----------|
|
||||
| `main.capacitor.ts` | `storeSharedImageInTempDB()` | **DELETE** old row, then **INSERT OR REPLACE** |
|
||||
| `SharedPhotoView.vue` | `loadSharedImage()` | **READ** then **DELETE** temp row |
|
||||
|
||||
---
|
||||
|
||||
## Timing, Delays, Retries, and Polling
|
||||
|
||||
| Location | Mechanism | Values | Purpose |
|
||||
|----------|-----------|--------|---------|
|
||||
| `AppDelegate.swift` `didFinishLaunching` | Plugin registration retry loop | Initial delay **0.5s**; up to **5** attempts; backoff **0.5s × attempt** | Ensure Capacitor bridge exists before registering `SharedImagePlugin` |
|
||||
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** (iOS only) | Deferred check for shared image on cold launch |
|
||||
| `main.capacitor.ts` startup | `setTimeout` | **2000ms** | Deferred registration of `appUrlOpen` deep-link listener |
|
||||
| `main.capacitor.ts` startup | `setTimeout` | **1000ms** | Log app initialization status |
|
||||
| `main.capacitor.ts` | `CapacitorApp.addListener("appStateChange")` | On every `isActive === true` | Re-check shared image when app foregrounds |
|
||||
| `main.capacitor.ts` | `isProcessingSharedImage` flag | Synchronous JS lock | Prevents concurrent `checkAndStoreNativeSharedImage()` calls |
|
||||
| `main.capacitor.ts` | Comment at line 214 | References polling | **Stale comment** — `checkAndStoreNativeSharedImage()` does **not** poll or retry |
|
||||
|
||||
**No native polling or retry** exists for reading App Group data. `hasSharedImage()` is exposed but **never called** from application JS code.
|
||||
|
||||
---
|
||||
|
||||
## Current Startup Detection Points
|
||||
|
||||
| # | Layer | Trigger | File | Method | Action |
|
||||
|---|-------|---------|------|--------|--------|
|
||||
| 1 | Native | App becomes active (cold start + resume) | `AppDelegate.swift` | `applicationDidBecomeActive` → `checkForSharedImageOnActivation` | Reads `sharedPhotoReady` flag; clears flag; posts `SharedPhotoReady` NSNotification *(no JS listener)* |
|
||||
| 2 | JS | Module load + 1000ms delay | `main.capacitor.ts` | `setTimeout` → `checkForSharedImageAndNavigate` | Calls `SharedImage.getSharedImage()`, stores in temp DB, navigates to `/shared-photo` |
|
||||
| 3 | JS | App foreground | `main.capacitor.ts` | `appStateChange` listener → `checkForSharedImageAndNavigate` | Same as above |
|
||||
| 4 | JS | Deep link `timesafari://` | `main.capacitor.ts` | `appUrlOpen` → `handleDeepLink` → `checkAndStoreNativeSharedImage` | iOS-only empty-path URL handling; navigates to `/shared-photo` |
|
||||
| 5 | Native | URL open | `AppDelegate.swift` | `application(_:open:options:)` | Forwards to Capacitor `ApplicationDelegateProxy` (enables #4) |
|
||||
| 6 | Native | Cold launch plugin setup | `AppDelegate.swift` | `didFinishLaunching` → `tryRegister` | Registers `SharedImagePlugin` (not a share check, but required for JS reads) |
|
||||
|
||||
**Note:** Detection points 1–4 can all fire for a single share event. Only the JS paths (#2–4) actually read and consume the image.
|
||||
|
||||
---
|
||||
|
||||
## Current Deletion Points
|
||||
|
||||
### App Group (Native)
|
||||
|
||||
| When | File | Method | What is deleted |
|
||||
|------|------|--------|-----------------|
|
||||
| Before new share write | `ShareViewController.swift` | `storeImageData` | Previous pending share file at prior `sharedPhotoFilePath` |
|
||||
| Legacy cleanup on write | `ShareViewController.swift` | `storeImageData` | `sharedPhotoBase64` UserDefaults key |
|
||||
| On app activation (flag only) | `AppDelegate.swift` | `checkForSharedImageOnActivation` | `sharedPhotoReady` flag |
|
||||
|
||||
**Removed in Phase 1C** (no longer deleted on retrieve):
|
||||
|
||||
| When | File | Method | What was deleted |
|
||||
|------|------|--------|------------------|
|
||||
| On successful read | `SharedImageUtility.swift` | `getSharedImageData` | `sharedPhotoFilePath`, `sharedPhotoFileName`, image file |
|
||||
|
||||
### Temp Database (JS)
|
||||
|
||||
| When | File | Method | What is deleted |
|
||||
|------|------|--------|-----------------|
|
||||
| Before storing new share | `main.capacitor.ts` | `storeSharedImageInTempDB` | Prior `shared-photo-base64` temp row |
|
||||
| After view loads image | `SharedPhotoView.vue` | `loadSharedImage` | `shared-photo-base64` temp row |
|
||||
|
||||
**Important:** Native image file and metadata persist after `getSharedImageData()` (Phase 1C). Cleanup is deferred to a later phase. The `sharedPhotoReady` flag is still cleared independently by `AppDelegate` on activation.
|
||||
|
||||
---
|
||||
|
||||
## Potential Race Conditions
|
||||
|
||||
1. **Multiple JS detection paths, repeatable native read.** `applicationDidBecomeActive`, the 1000ms startup timer, `appStateChange`, and `appUrlOpen` can all invoke `checkAndStoreNativeSharedImage()` close together. Since Phase 1C, `getSharedImageData()` is read-only and returns the same data on every call until a new share overwrites metadata or explicit cleanup is added. The `isProcessingSharedImage` JS lock still reduces duplicate temp-DB writes and navigations.
|
||||
|
||||
2. **Deep-link listener registered 2 seconds after mount.** The share extension opens `timesafari://` immediately. If Capacitor does not buffer the launch URL until the `appUrlOpen` listener is registered (at T+2000ms), the deep-link path may be missed on cold start. The 1000ms startup check and `appStateChange` paths partially compensate.
|
||||
|
||||
3. **Plugin registration vs. first `getSharedImage()` call.** `SharedImagePlugin` is registered with up to 5 retries starting at T+500ms. A `getSharedImage()` call before registration completes will fail. The 1000ms startup delay usually avoids this, but `appStateChange` can fire earlier.
|
||||
|
||||
4. **`sharedPhotoReady` flag cleared before JS reads image.** `AppDelegate.checkForSharedImageOnActivation` clears the flag and posts `SharedPhotoReady`, but no JavaScript code listens for that NSNotification. The flag is therefore a redundant signal; reliance is entirely on file/metadata presence. If file write failed but flag were set, the flag would be cleared with no image available (current code sets flag only after successful `storeImageData`).
|
||||
|
||||
5. **`SharedPhotoReady` NSNotification is a dead signal.** Posted in `AppDelegate` but not bridged to Capacitor/JS. All actual consumption happens through JS-initiated `getSharedImage()` calls.
|
||||
|
||||
6. **Concurrent share while app is open.** A second share overwrites the App Group file and metadata. If the first share has already been read into temp DB but the user has not yet reached `SharedPhotoView`, the second share can replace native data; navigation refresh via `_refresh` query param handles re-navigation but temp DB overwrite in `storeSharedImageInTempDB` can clobber an in-flight first image.
|
||||
|
||||
7. **Extension `completeRequest` timing.** `completeRequest` runs in the `processSharedImage` completion handler after `storeImageData`, flag set, and `openMainApp` — so the file should exist before the extension exits. However, `loadItem` is asynchronous; if the extension process is terminated aggressively by iOS after `completeRequest`, this is generally safe because all writes complete in the callback before completion.
|
||||
|
||||
8. **Stale comment implies polling that does not exist.** `handleDeepLink` comments reference internal polling in `checkAndStoreNativeSharedImage`, but no retry loop exists. A single failed read at the wrong moment is not retried on iOS (unlike Android's multi-delay startup checks).
|
||||
|
||||
9. **`hasSharedImage()` unused.** A non-destructive pre-check is available natively but JS always calls `getSharedImage()` directly. Since Phase 1C both methods are non-destructive on the native layer.
|
||||
|
||||
---
|
||||
|
||||
## Share ID Tracking
|
||||
|
||||
**Implemented:** 2026-06-23 (Phase 1A)
|
||||
|
||||
Phase 1A adds a unique share identifier to the iOS share flow for observability and future reliability work. Existing retrieval and deletion behavior is unchanged.
|
||||
|
||||
### Identifier
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| UserDefaults key | `sharedPhotoShareId` |
|
||||
| Format | `UUID().uuidString` (e.g. `A1B2C3D4-E5F6-7890-ABCD-EF1234567890`) |
|
||||
| Generated in | `ShareViewController.processSharedImage` when the first image attachment is found |
|
||||
| Persisted in | `ShareViewController.storeImageData` alongside `sharedPhotoFilePath` and `sharedPhotoFileName` |
|
||||
|
||||
### Logging
|
||||
|
||||
All log lines use the prefix `[ShareTarget]` and include `shareId=<id>`:
|
||||
|
||||
| Event | File | Method | When |
|
||||
|-------|------|--------|------|
|
||||
| share received | `ShareViewController.swift` | `processSharedImage` | UUID generated before `loadItem` |
|
||||
| file stored | `ShareViewController.swift` | `storeImageData` | After successful `imageData.write(to:)` |
|
||||
| metadata stored | `ShareViewController.swift` | `storeImageData` | After UserDefaults `synchronize()` |
|
||||
| share retrieved | `SharedImageUtility.swift` | `getSharedImageData` | After successful file read (Phase 1C log format) |
|
||||
|
||||
Example log sequence for a single share:
|
||||
|
||||
```
|
||||
[ShareTarget] share received shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890
|
||||
[ShareTarget] file stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg
|
||||
[ShareTarget] metadata stored shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 originalFilename=vacation.jpg storedFilename=A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg
|
||||
[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 retrieved
|
||||
[ShareTarget] shareId=A1B2C3D4-E5F6-7890-ABCD-EF1234567890 left intact after retrieval
|
||||
```
|
||||
|
||||
### Phase 1A Scope (Intentionally Unchanged)
|
||||
|
||||
- `getSharedImageData()` still returns only `base64` and `fileName` to JavaScript
|
||||
- `sharedPhotoShareId` is not deleted on retrieve (cleanup deferred to a later phase)
|
||||
- `hasSharedImage()`, `isSharedPhotoReady()`, and JS consumption paths are unchanged
|
||||
- Android code is unchanged
|
||||
|
||||
### Write Inventory Addition
|
||||
|
||||
| File | Method | Key Written |
|
||||
|------|--------|-------------|
|
||||
| `ShareViewController.swift` | `storeImageData(_:fileName:shareId:)` | `sharedPhotoShareId` |
|
||||
|
||||
### Read Inventory Addition
|
||||
|
||||
| File | Method | Key Read |
|
||||
|------|--------|----------|
|
||||
| `SharedImageUtility.swift` | `getSharedImageData()` | `sharedPhotoShareId` (logging only) |
|
||||
|
||||
---
|
||||
|
||||
## Unique Stored Filenames
|
||||
|
||||
**Implemented:** 2026-06-23 (Phase 1B)
|
||||
|
||||
Phase 1B eliminates on-disk filename collisions by storing each shared image under a UUID-based filename while preserving the original filename as metadata for consumers.
|
||||
|
||||
### On-Disk vs Metadata
|
||||
|
||||
| Field | UserDefaults key | Example | Purpose |
|
||||
|-------|------------------|---------|---------|
|
||||
| Stored filename | `sharedPhotoFilePath` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890.jpg` | Unique file in App Group container |
|
||||
| Original filename | `sharedPhotoFileName` | `vacation-photo.jpg` | Returned to JS as `fileName` |
|
||||
| Share ID | `sharedPhotoShareId` | `A1B2C3D4-E5F6-7890-ABCD-EF1234567890` | Correlates logs across extension and main app |
|
||||
|
||||
Stored filename format: `<shareId>.<extension>`, where extension is taken from the original filename (defaults to `jpg` when absent).
|
||||
|
||||
### Implementation
|
||||
|
||||
| File | Method | Change |
|
||||
|------|--------|--------|
|
||||
| `ShareViewController.swift` | `fileExtension(from:)` | Extracts extension from original filename |
|
||||
| `ShareViewController.swift` | `storedFileName(shareId:originalFileName:)` | Builds `<shareId>.<ext>` |
|
||||
| `ShareViewController.swift` | `storeImageData` | Writes to stored filename; saves original in `sharedPhotoFileName` |
|
||||
| `SharedImageUtility.swift` | `getSharedImageData` | Reads file via `sharedPhotoFilePath`; returns original `sharedPhotoFileName` |
|
||||
|
||||
When a new share arrives before the previous one is retrieved, `storeImageData` removes the file at the previous `sharedPhotoFilePath` before writing, preserving single-pending-share semantics.
|
||||
|
||||
### Logging (Phase 1B)
|
||||
|
||||
Store and retrieve events include all three identifiers:
|
||||
|
||||
```
|
||||
[ShareTarget] file stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
|
||||
[ShareTarget] metadata stored shareId=<id> originalFilename=<name> storedFilename=<shareId>.<ext>
|
||||
[ShareTarget] shareId=<id> retrieved
|
||||
[ShareTarget] shareId=<id> left intact after retrieval
|
||||
```
|
||||
|
||||
### Phase 1B Scope (Intentionally Unchanged)
|
||||
|
||||
- `getSharedImageData()` still returns only `base64` and original `fileName` to JavaScript
|
||||
- Android code is unchanged
|
||||
|
||||
---
|
||||
|
||||
## Non-Destructive Retrieval
|
||||
|
||||
**Implemented:** 2026-06-24 (Phase 1C)
|
||||
|
||||
Phase 1C makes native shared-content retrieval read-only. `getSharedImageData()` and `SharedImagePlugin.getSharedImage()` no longer delete App Group metadata or image files after a successful read. Explicit cleanup is deferred to a later phase.
|
||||
|
||||
### Behavior Change
|
||||
|
||||
| Aspect | Before Phase 1C | After Phase 1C |
|
||||
|--------|-----------------|----------------|
|
||||
| `sharedPhotoFilePath` after retrieve | Removed | Retained |
|
||||
| `sharedPhotoFileName` after retrieve | Removed | Retained |
|
||||
| `sharedPhotoShareId` after retrieve | Retained (since Phase 1A) | Retained |
|
||||
| Image file after retrieve | Deleted | Retained |
|
||||
| Return value to JS | `{ base64, fileName }` | Unchanged |
|
||||
| Repeat `getSharedImage()` calls | Return `null` after first success | Return same data until overwritten or cleaned up |
|
||||
|
||||
### Logging
|
||||
|
||||
After a successful read:
|
||||
|
||||
```
|
||||
[ShareTarget] shareId=<id> retrieved
|
||||
[ShareTarget] shareId=<id> left intact after retrieval
|
||||
```
|
||||
|
||||
### Removed Deletion Paths
|
||||
|
||||
All removal logic was in `SharedImageUtility.getSharedImageData()`:
|
||||
|
||||
| # | What was deleted | Code removed |
|
||||
|---|------------------|--------------|
|
||||
| 1 | `sharedPhotoFilePath` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFilePathKey)` |
|
||||
| 2 | `sharedPhotoFileName` UserDefaults key | `userDefaults.removeObject(forKey: sharedPhotoFileNameKey)` |
|
||||
| 3 | Image file at `{container}/{sharedPhotoFilePath}` | `FileManager.default.removeItem(at: fileURL)` |
|
||||
| 4 | Post-deletion UserDefaults flush | `userDefaults.synchronize()` after removals |
|
||||
|
||||
`SharedImagePlugin.getSharedImage(_:)` delegated to `getSharedImageData()` and had no independent deletion logic. Comment updated to reflect read-only behavior.
|
||||
|
||||
### Phase 1C Scope (Intentionally Unchanged)
|
||||
|
||||
- No new cleanup or purge APIs added
|
||||
- `clearSharedPhotoReadyFlag()` and share-extension write-side file removal unchanged
|
||||
- JS temp DB deletion in `main.capacitor.ts` and `SharedPhotoView.vue` unchanged
|
||||
- Android code unchanged
|
||||
|
||||
---
|
||||
|
||||
## Deterministic Startup Plugin Readiness
|
||||
|
||||
**Implemented:** 2026-06-26 (Phase 2A)
|
||||
|
||||
Phase 2A removes the iOS startup race between native `SharedImage` plugin
|
||||
registration and the first JS shared-image check. All changes are confined to
|
||||
`src/main.capacitor.ts`; no Swift code changed.
|
||||
|
||||
### Race condition removed
|
||||
|
||||
Previously the initial iOS shared-image check ran on a fixed timer:
|
||||
|
||||
```ts
|
||||
const checkDelays = ... : [1000]; // iOS
|
||||
checkDelays.forEach((delay) => setTimeout(() => checkForSharedImageAndNavigate(), delay));
|
||||
```
|
||||
|
||||
The native `SharedImagePlugin` is registered asynchronously by
|
||||
`AppDelegate.didFinishLaunchingWithOptions` with up to 5 retries starting at
|
||||
T+500ms (`AppDelegate.swift:21–40`). The fixed 1000ms JS delay only *assumed*
|
||||
registration had completed by then. When registration was slow (or the WebView
|
||||
booted unusually fast), the first `checkForSharedImageAndNavigate()` could call
|
||||
`SharedImage.getSharedImage()` before the native plugin existed, the call would
|
||||
throw, and the cold-start share could be missed until a later `appStateChange`.
|
||||
This corresponds to race condition #3 in *Potential Race Conditions* above.
|
||||
|
||||
### How plugin readiness is now determined
|
||||
|
||||
The fixed 1000ms iOS delay is replaced with an explicit, deterministic wait
|
||||
(`waitForSharedImagePluginReady()` in `main.capacitor.ts`):
|
||||
|
||||
- The plugin is probed with a lightweight, read-only `SharedImage.hasSharedImage()`
|
||||
call. A successful resolution proves the native plugin instance is registered
|
||||
and reachable from JS. `hasSharedImage()` does not consume or mutate the pending
|
||||
share (non-destructive since Phase 1C), so probing is side-effect free.
|
||||
- If the probe throws (plugin not yet registered), it retries within a bounded
|
||||
budget: `STARTUP_PLUGIN_MAX_ATTEMPTS = 10` attempts spaced
|
||||
`STARTUP_PLUGIN_RETRY_DELAY_MS = 300` ms apart (~3s ceiling, covering the
|
||||
native registration window). No arbitrary sleep is used to *assume* readiness;
|
||||
the delay is only the inter-retry backoff while polling for actual availability.
|
||||
- The very first `checkForSharedImageAndNavigate()` runs only after the probe
|
||||
succeeds. If the budget is exhausted (should not happen in practice), the check
|
||||
is still attempted once as a best-effort fallback so behavior is never worse
|
||||
than the previous fixed-delay path, and `appStateChange` retries on the next
|
||||
activation.
|
||||
|
||||
### Temporary diagnostics
|
||||
|
||||
The retry sequence emits `[ShareTarget]` console diagnostics, consistent with the
|
||||
existing TEMPORARY SHARE TARGET DIAGNOSTICS convention:
|
||||
|
||||
```
|
||||
[ShareTarget] Startup shared-image check waiting for SharedImage plugin
|
||||
[ShareTarget] SharedImage plugin ready after N attempt(s)
|
||||
[ShareTarget] Startup shared-image check giving up after N attempt(s)
|
||||
```
|
||||
|
||||
### Phase 2A Scope (Intentionally Unchanged)
|
||||
|
||||
The retry/readiness logic applies **only** to the initial startup shared-image
|
||||
check. The following are deliberately untouched:
|
||||
|
||||
- `appStateChange` handling (`CapacitorApp.addListener("appStateChange", ...)`)
|
||||
- `appUrlOpen` handling (`handleDeepLink`, `registerDeepLinkListener`)
|
||||
- Router navigation to `/shared-photo`
|
||||
- Share processing (`checkAndStoreNativeSharedImage`, `storeSharedImageInTempDB`)
|
||||
- Android startup behavior (still `[500, 1500, 3000]` ms multi-delay checks)
|
||||
- All native Swift code, including the `AppDelegate` plugin-registration retry
|
||||
|
||||
Because readiness is now confirmed by an actual plugin response rather than a
|
||||
timer, the startup check no longer depends on registration timing, while every
|
||||
other detection path keeps its previous semantics as redundant backstops.
|
||||
|
||||
---
|
||||
|
||||
## Configuration References
|
||||
|
||||
| Resource | Value |
|
||||
|----------|-------|
|
||||
| App Group ID | `group.app.timesafari.share` |
|
||||
| URL scheme | `timesafari://` |
|
||||
| Extension bundle ID | `app.timesafari.TimeSafariShareExtension` |
|
||||
| Main app bundle ID | `app.timesafari` |
|
||||
| Capacitor plugin name | `SharedImage` |
|
||||
| Temp DB key | `shared-photo-base64` (`SHARED_PHOTO_BASE64_KEY`) |
|
||||
| Route | `/shared-photo` |
|
||||
515
doc/share-target-ios-launch-flow-audit.md
Normal file
515
doc/share-target-ios-launch-flow-audit.md
Normal 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")` (`:340–347`).
|
||||
|
||||
5. **`setSharedPhotoReadyFlag()`** — `ShareViewController.swift:129` (called at `:108`)
|
||||
Sets `sharedPhotoReady = true` in the App Group UserDefaults (`:140`).
|
||||
|
||||
6. **`openMainApp()`** — `ShareViewController.swift:356` (called at `:110`)
|
||||
Builds `URL(string: "timesafari://")` (`:362`), walks the responder chain to
|
||||
find a `UIApplication`, and calls `application.open(url, ...)` (`:373`).
|
||||
Fallback: `extensionContext?.open(url, ...)` (`:383`).
|
||||
|
||||
7. **`context.completeRequest(...)`** — `ShareViewController.swift:119`
|
||||
Extension finishes; iOS hands the URL to the main app, launching the process.
|
||||
|
||||
8. **Main app process launches → `AppDelegate.application(_:didFinishLaunchingWithOptions:)`**
|
||||
`AppDelegate.swift:11`.
|
||||
- Sets `UNUserNotificationCenter.delegate` (`:13`).
|
||||
- Schedules `SharedImagePlugin` registration on the main queue starting at
|
||||
**T+0.5s**, with up to 5 retries at increasing delays (`:21–40`,
|
||||
`registerSharedImagePlugin()` at `:46`). Registration requires the
|
||||
Capacitor bridge to exist (`:48–51`).
|
||||
- **Note:** `launchOptions` is received but is **never inspected** for a launch
|
||||
URL. AppDelegate does not read `launchOptions[.url]`.
|
||||
|
||||
9. **Capacitor native bridge boots** (`CAPBridgeViewController` as `window.rootViewController`).
|
||||
The bridge loads the WKWebView and the JS bundle.
|
||||
|
||||
10. **`AppDelegate.application(_:open:options:)`** — `AppDelegate.swift:133`
|
||||
When iOS delivers `timesafari://`, this proxies straight to
|
||||
`ApplicationDelegateProxy.shared.application(app, open: url, options:)` (`:138`).
|
||||
Capacitor's proxy is responsible for emitting the `appUrlOpen` event to JS —
|
||||
**but only to listeners that are already registered** (see §5).
|
||||
|
||||
11. **JS startup — `src/main.capacitor.ts` executes top-to-bottom on bundle load:**
|
||||
- Logging banner (`:47–48`).
|
||||
- `const app = initializeApp();` (`:50`) → `src/main.common.ts:33`
|
||||
builds the Vue app, Pinia, axios, and **`app.use(router)`** (`main.common.ts:39`).
|
||||
The router itself is created at module import time in
|
||||
`src/router/index.ts:320` (`createRouter` with `createWebHistory("/")`).
|
||||
- `new DeepLinkHandler(router)` (`main.capacitor.ts:59`).
|
||||
- `app.mount("#app")` (`main.capacitor.ts:462`).
|
||||
- **Startup shared-image timer scheduled:** iOS uses `[1000]`ms delay
|
||||
(`:474–483`) → `checkForSharedImageAndNavigate()` at **T+1000ms**.
|
||||
- **`appStateChange` listener registered** (`:491–496`).
|
||||
- **Deep-link listener registration scheduled** via `setTimeout(..., 2000)`
|
||||
(`:500–510`) → `registerDeepLinkListener()` at **T+2000ms**.
|
||||
|
||||
12. **`registerDeepLinkListener()`** — `main.capacitor.ts:298`
|
||||
Awaits `router.isReady()` (`:322`), then
|
||||
`CapacitorApp.addListener("appUrlOpen", handleDeepLink)` (`:329`).
|
||||
This is the **only** `appUrlOpen` registration in the codebase, and it occurs
|
||||
~2 seconds after mount.
|
||||
|
||||
### Cold-start order summary
|
||||
|
||||
```
|
||||
ShareViewController.viewDidLoad
|
||||
→ processAndOpenApp → processSharedImage → storeImageData (App Group file + metadata)
|
||||
→ setSharedPhotoReadyFlag (sharedPhotoReady = true)
|
||||
→ openMainApp → application.open("timesafari://")
|
||||
→ completeRequest
|
||||
AppDelegate.didFinishLaunchingWithOptions (launchOptions URL ignored)
|
||||
→ schedules SharedImagePlugin registration (T+0.5s, ≤5 retries)
|
||||
Capacitor bridge + WKWebView boot → JS bundle loads
|
||||
main.capacitor.ts (top-level):
|
||||
initializeApp() → router created/used → DeepLinkHandler → app.mount("#app")
|
||||
→ schedule startup check (T+1000ms)
|
||||
→ register appStateChange listener
|
||||
→ schedule appUrlOpen registration (T+2000ms)
|
||||
AppDelegate.application(_:open:) → ApplicationDelegateProxy (appUrlOpen emitted)
|
||||
applicationDidBecomeActive → checkForSharedImageOnActivation (native flag path)
|
||||
```
|
||||
|
||||
**Key cold-start fact:** because `application.open("timesafari://")` is delivered
|
||||
during/just after process launch, the Capacitor `appUrlOpen` event fires **before**
|
||||
the JS `appUrlOpen` listener is registered (registration is at T+2000ms). On cold
|
||||
start, the successful navigation is therefore driven by the **T+1000ms startup
|
||||
timer** (and/or `appStateChange`), not by `appUrlOpen`. See §6 and §7.
|
||||
|
||||
---
|
||||
|
||||
## 2. Warm-start launch path
|
||||
|
||||
"Warm start" = the main app process is already running (foreground or background)
|
||||
when the user taps Share.
|
||||
|
||||
1. **Share Extension** runs the identical sequence as §1 steps 1–7
|
||||
(`storeImageData` → `setSharedPhotoReadyFlag` → `openMainApp` →
|
||||
`application.open("timesafari://")` → `completeRequest`).
|
||||
|
||||
2. **iOS resumes the existing app process** (no new launch, no
|
||||
`didFinishLaunchingWithOptions`).
|
||||
|
||||
3. **`AppDelegate.application(_:open:options:)`** — `AppDelegate.swift:133`
|
||||
Proxies to `ApplicationDelegateProxy.shared.application(...)` (`:138`).
|
||||
Because the JS `appUrlOpen` listener was registered during the earlier launch
|
||||
(T+2000ms after the first mount), Capacitor delivers the event to JS.
|
||||
|
||||
4. **`AppDelegate.applicationDidBecomeActive`** — `AppDelegate.swift:77`
|
||||
Also fires on resume:
|
||||
- re-sets the notification delegate (`:81`),
|
||||
- calls `checkForSharedImageOnActivation()` (`:84`).
|
||||
`checkForSharedImageOnActivation()` (`:117`) reads `isSharedPhotoReady()`
|
||||
(`SharedImageUtility.swift:183`), clears the flag (`:121`), and posts the
|
||||
`SharedPhotoReady` NSNotification (`:125`). **No JS code listens for that
|
||||
NSNotification** (see §4 / §7) — it is a dead signal.
|
||||
|
||||
5. **JS `appUrlOpen` → `handleDeepLink`** — `main.capacitor.ts:194`
|
||||
- `url === "timesafari://"` matches the empty-path branch (`:201`).
|
||||
- On iOS native (`:203–207`), calls `checkAndStoreNativeSharedImage()` (`:216`).
|
||||
|
||||
6. **`checkAndStoreNativeSharedImage()`** — `main.capacitor.ts:131`
|
||||
- Guards against re-entrancy via `isProcessingSharedImage` (`:136`, `:143`).
|
||||
- Calls `SharedImage.getSharedImage()` (`:159`) → native
|
||||
`SharedImagePlugin.getSharedImage` (`SharedImagePlugin.swift:43`) →
|
||||
`SharedImageUtility.getSharedImageData()` (`SharedImageUtility.swift:51`)
|
||||
which reads the App Group file and returns `{ base64, fileName }`.
|
||||
- On success, `storeSharedImageInTempDB(...)` (`main.capacitor.ts:72`) writes
|
||||
the data URL into the SQLite `temp` table under `SHARED_PHOTO_BASE64_KEY`.
|
||||
|
||||
7. **Navigation to `/shared-photo`** — `main.capacitor.ts:218–248`
|
||||
- `await router.isReady()` (`:224`).
|
||||
- If already on `/shared-photo`, `router.replace(...)` with a `_refresh`
|
||||
timestamp (`:234`); otherwise `router.push({ path: "/shared-photo", query: { fileName } })`
|
||||
(`:239`).
|
||||
|
||||
### Warm-start callback chain
|
||||
|
||||
```
|
||||
ShareViewController.openMainApp → application.open("timesafari://") → completeRequest
|
||||
iOS resumes running process
|
||||
AppDelegate.application(_:open:) → ApplicationDelegateProxy ── emits appUrlOpen ──┐
|
||||
AppDelegate.applicationDidBecomeActive → checkForSharedImageOnActivation │
|
||||
(reads + clears sharedPhotoReady, posts SharedPhotoReady NSNotification = no JS listener)
|
||||
│
|
||||
JS appUrlOpen listener (registered earlier) ←───────────────────────────────────────┘
|
||||
→ handleDeepLink (empty-path branch)
|
||||
→ checkAndStoreNativeSharedImage
|
||||
→ SharedImage.getSharedImage → SharedImageUtility.getSharedImageData (App Group file)
|
||||
→ storeSharedImageInTempDB (SQLite temp table)
|
||||
→ router.isReady → router.push/replace("/shared-photo")
|
||||
```
|
||||
|
||||
In parallel, the `appStateChange` listener (`main.capacitor.ts:491`) also fires
|
||||
on `isActive` and independently calls `checkForSharedImageAndNavigate()`. Both
|
||||
paths converge on the same `isProcessingSharedImage` lock and the same
|
||||
`/shared-photo` navigation.
|
||||
|
||||
---
|
||||
|
||||
## 3. appUrlOpen audit
|
||||
|
||||
### Registrations
|
||||
|
||||
There is exactly **one** runtime registration of `appUrlOpen` in the codebase:
|
||||
|
||||
- **`main.capacitor.ts:329`** inside `registerDeepLinkListener()`:
|
||||
```ts
|
||||
const listenerHandle = await CapacitorApp.addListener("appUrlOpen", handleDeepLink);
|
||||
```
|
||||
(`CapacitorApp` = `@capacitor/app`, imported at `main.capacitor.ts:32`.)
|
||||
|
||||
Supporting / non-runtime references:
|
||||
- `capacitor.config.ts:11–19` declares the `App.appUrlOpen` handler for
|
||||
`timesafari://*` with `autoVerify: true` (config, not a JS listener).
|
||||
- `src/libs/capacitor/app.ts:32–35, 42–59` is a typed wrapper exposing
|
||||
`addListener("appUrlOpen", ...)`, but `main.capacitor.ts` calls
|
||||
`@capacitor/app` directly, **not** this wrapper.
|
||||
|
||||
### When it is registered relative to startup
|
||||
|
||||
- Scheduled by `setTimeout(..., 2000)` at `main.capacitor.ts:500`, i.e.
|
||||
**~2000 ms after `app.mount("#app")`** (`:462`).
|
||||
- Inside `registerDeepLinkListener()`, registration additionally waits for
|
||||
`await router.isReady()` (`:322`) before calling `addListener` (`:329`).
|
||||
|
||||
### Handlers that execute because of it
|
||||
|
||||
- **`handleDeepLink(data)`** — `main.capacitor.ts:194` is the only handler.
|
||||
|
||||
### Calls made by the handler
|
||||
|
||||
- **`handleDeepLink`** (`:194`)
|
||||
- **`checkAndStoreNativeSharedImage()`** (`:216`) — for empty-path
|
||||
`timesafari://` / `timesafari:///` on iOS native (`:201–207`).
|
||||
- **`router.isReady()`** (`:224`), then **`router.replace`** (`:234`) or
|
||||
**`router.push`** (`:239`) → navigation to `/shared-photo`.
|
||||
- For non-empty deep links: `router.isReady()` (`:264`) then
|
||||
`deepLinkHandler.handleDeepLink(url)` (`:269`) — the `DeepLinkHandler` class
|
||||
instance (`:59`). (Empty-path share URLs never reach this branch.)
|
||||
|
||||
`handleDeepLink` is **not** the same function as `deepLinkHandler.handleDeepLink`
|
||||
(`src/services/deepLinks.ts`); the module-level function wraps the class method.
|
||||
|
||||
### Call graph (appUrlOpen)
|
||||
|
||||
```
|
||||
CapacitorApp.addListener("appUrlOpen", handleDeepLink) [main.capacitor.ts:329]
|
||||
│ (fires on timesafari:// open, warm start only in practice — see §6)
|
||||
▼
|
||||
handleDeepLink(data) [:194]
|
||||
├─ if url == "timesafari://" / "timesafari:///" and iOS native [:201–207]
|
||||
│ └─ checkAndStoreNativeSharedImage() [:216 → :131]
|
||||
│ └─ SharedImage.getSharedImage() [:159]
|
||||
│ └─ (native) getSharedImageData() [SharedImageUtility.swift:51]
|
||||
│ └─ storeSharedImageInTempDB() [:177 → :72]
|
||||
│ └─ router.isReady() [:224]
|
||||
│ └─ router.replace / router.push → /shared-photo [:234 / :239]
|
||||
│
|
||||
└─ else (non-empty deep link)
|
||||
└─ router.isReady() [:264]
|
||||
└─ deepLinkHandler.handleDeepLink(url) [:269]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Startup / shared-image detection audit
|
||||
|
||||
Every place the app checks for a shared image, and when each runs:
|
||||
|
||||
| # | Mechanism | Location | When it executes | Cold start? | Warm start? |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A | **Startup timer** `setTimeout(..., 1000)` → `checkForSharedImageAndNavigate()` | `main.capacitor.ts:474–483` (iOS delays `[1000]`) | ~1000 ms after JS bundle loads / mount | **Yes** (primary cold-start path) | Only if bundle reloaded (normally no) |
|
||||
| B | **`appStateChange` listener** → `checkForSharedImageAndNavigate()` | `main.capacitor.ts:491–496` | Every time app becomes active (`isActive === true`) | Yes (initial activation can fire) | **Yes** (primary on resume) |
|
||||
| C | **`appUrlOpen` listener** → `handleDeepLink` → `checkAndStoreNativeSharedImage()` | `main.capacitor.ts:329`, handler `:194/:216` | On `timesafari://` open, **only if listener already registered** (T+2000ms) | Usually **No** (URL arrives before listener) | **Yes** |
|
||||
| D | **Native `applicationDidBecomeActive`** → `checkForSharedImageOnActivation()` | `AppDelegate.swift:77, 117` | Every activation (launch + resume) | Yes | Yes |
|
||||
| E | **Native ready-flag read** `isSharedPhotoReady()` / `clearSharedPhotoReadyFlag()` | `SharedImageUtility.swift:183, 195` (called from D) | Inside D | Yes | Yes |
|
||||
| F | **`SharedPhotoReady` NSNotification** posted | `AppDelegate.swift:125` | Inside D, when flag was set | Yes | Yes |
|
||||
|
||||
### Detail per mechanism
|
||||
|
||||
- **A — JS startup timer.** `main.capacitor.ts:474–483`. iOS uses a single
|
||||
`[1000]` ms delay (Android uses `[500, 1500, 3000]`). Calls
|
||||
`checkForSharedImageAndNavigate()` (`:353`), which calls
|
||||
`checkAndStoreNativeSharedImage()` (`:423`) and then pushes/replaces
|
||||
`/shared-photo` (`:439–449`). This is the mechanism that actually carries
|
||||
cold-start shares to `/shared-photo`.
|
||||
|
||||
- **B — `appStateChange`.** `main.capacitor.ts:491`. Fires on every transition to
|
||||
active. Calls the same `checkForSharedImageAndNavigate()`. Primary warm-start /
|
||||
resume detector and a backstop for cold start.
|
||||
|
||||
- **C — `appUrlOpen`.** Registered at `main.capacitor.ts:329` (T+2000ms after
|
||||
mount). Handler `handleDeepLink` (`:194`). Effective only when the listener is
|
||||
already registered when the URL arrives — i.e. warm starts.
|
||||
|
||||
- **D — Native `applicationDidBecomeActive`.** `AppDelegate.swift:77`. Calls
|
||||
`checkForSharedImageOnActivation()` (`:117`).
|
||||
|
||||
- **E — ready flag.** `checkForSharedImageOnActivation()` reads
|
||||
`SharedImageUtility.isSharedPhotoReady()` (`:119` → `SharedImageUtility.swift:183`)
|
||||
and clears it via `clearSharedPhotoReadyFlag()` (`:121` →
|
||||
`SharedImageUtility.swift:195`). The flag is **set** by the extension at
|
||||
`ShareViewController.swift:140`.
|
||||
|
||||
- **F — `SharedPhotoReady` NSNotification.** Posted at `AppDelegate.swift:125`.
|
||||
**No JavaScript or Capacitor bridge code observes this notification anywhere in
|
||||
the repo** (only doc references and the post site exist). It is therefore a
|
||||
dead/no-op signal as far as JS navigation is concerned.
|
||||
|
||||
### Polling / retry logic
|
||||
|
||||
- **JS retry:** No active polling loop inside `checkAndStoreNativeSharedImage()`.
|
||||
The code comments at `main.capacitor.ts:213–214` mention "polling internally,"
|
||||
but the implementation (`:131–192`) makes a **single** `getSharedImage()` call.
|
||||
The only "retry-like" behavior on the JS side is the **multiple invocation
|
||||
surfaces** (A startup timer, B appStateChange, C appUrlOpen), all gated by the
|
||||
`isProcessingSharedImage` lock (`:62, :136, :143`).
|
||||
- **Native retry:** `AppDelegate.swift:21–40` retries **plugin registration**
|
||||
(not image detection) up to 5 times starting at T+0.5s.
|
||||
|
||||
---
|
||||
|
||||
## 5. Native launch information
|
||||
|
||||
### Does AppDelegate receive the launch URL before JavaScript is initialized?
|
||||
|
||||
**No — the AppDelegate does not capture the launch URL at all.**
|
||||
|
||||
- `AppDelegate.application(_:didFinishLaunchingWithOptions:)`
|
||||
(`AppDelegate.swift:11`) receives `launchOptions`, but the body
|
||||
(`:13–43`) **never reads `launchOptions[.url]`** or otherwise extracts a launch
|
||||
URL. It only configures notifications and schedules plugin registration.
|
||||
|
||||
- The only URL entry point is `AppDelegate.application(_:open:options:)`
|
||||
(`AppDelegate.swift:133`), which immediately forwards to
|
||||
`ApplicationDelegateProxy.shared.application(app, open: url, options:)` (`:138`)
|
||||
with **no local storage** of the URL. Capacitor's proxy owns the URL from here.
|
||||
|
||||
### Where is it stored?
|
||||
|
||||
- It is **not** stored in the app's own native code. Whatever buffering exists is
|
||||
internal to Capacitor's `ApplicationDelegateProxy` / `@capacitor/app` plugin.
|
||||
The app code does not call `App.getLaunchUrl()` anywhere (only referenced in
|
||||
docs at `doc/native-share-target-implementation.md:436`).
|
||||
|
||||
- The **share payload** (not the URL) is durably stored by the extension in the
|
||||
App Group container: the image file plus metadata keys at
|
||||
`ShareViewController.swift:340–347`, and the `sharedPhotoReady` boolean at
|
||||
`:140`. This payload is what the JS side later reads via
|
||||
`SharedImage.getSharedImage()`.
|
||||
|
||||
### Is the URL forwarded to JS?
|
||||
|
||||
- Only through Capacitor's `appUrlOpen` event, and only to listeners present at
|
||||
emit time. The single JS listener is registered at
|
||||
`main.capacitor.ts:329`, **~2000 ms after mount**.
|
||||
|
||||
### Can the launch URL be lost before listeners are registered?
|
||||
|
||||
- **Yes, the `appUrlOpen` event can be lost on cold start.** Because the URL is
|
||||
delivered through `application(_:open:)` during/just after launch, and the JS
|
||||
listener is registered at T+2000ms (`:500`), an event emitted before that point
|
||||
will have no JS listener — unless Capacitor buffers the launch URL until a
|
||||
listener attaches. The app code does not rely on (or verify) such buffering;
|
||||
there is no `getLaunchUrl()` call to recover a missed event.
|
||||
|
||||
- **The share payload itself is NOT lost.** Because the image and metadata persist
|
||||
in the App Group container, the cold-start startup timer (mechanism A,
|
||||
`main.capacitor.ts:474`) and `appStateChange` (mechanism B, `:491`) can still
|
||||
retrieve it via `getSharedImage()` independent of whether the `appUrlOpen`
|
||||
event was delivered. The only thing at risk is the **URL event/signal**, not the
|
||||
data.
|
||||
|
||||
### Complete native-launch flow
|
||||
|
||||
```
|
||||
Extension writes App Group file + metadata + sharedPhotoReady=true [ShareViewController.swift:340,140]
|
||||
Extension: application.open("timesafari://") [ShareViewController.swift:373]
|
||||
iOS launches/resumes app
|
||||
├─ didFinishLaunchingWithOptions(launchOptions) [AppDelegate.swift:11] ← launchOptions URL NOT read
|
||||
├─ application(_:open:options:) [AppDelegate.swift:133] → ApplicationDelegateProxy (Capacitor buffers/emits appUrlOpen)
|
||||
└─ applicationDidBecomeActive [AppDelegate.swift:77] → checkForSharedImageOnActivation [:117]
|
||||
→ isSharedPhotoReady [:119] → clear flag [:121] → post SharedPhotoReady [:125] (no JS listener)
|
||||
JS bundle loads later → appUrlOpen listener attaches at T+2000ms [main.capacitor.ts:329]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Timing analysis
|
||||
|
||||
`T0` = moment the JS bundle begins executing / `app.mount("#app")`
|
||||
(`main.capacitor.ts:462`). Native launch precedes T0.
|
||||
|
||||
### Cold start timeline
|
||||
|
||||
| Time | Actor | Event | Reference |
|
||||
| --- | --- | --- | --- |
|
||||
| pre-launch | Extension | write file + metadata + `sharedPhotoReady=true`; `open("timesafari://")`; `completeRequest` | `ShareViewController.swift:340,140,373,119` |
|
||||
| launch | Native | `didFinishLaunchingWithOptions` (launch URL ignored) | `AppDelegate.swift:11` |
|
||||
| ~launch | Native | `application(_:open:)` → ApplicationDelegateProxy → (Capacitor) `appUrlOpen` emitted | `AppDelegate.swift:133` |
|
||||
| launch +0.5s..2.5s | Native | `SharedImagePlugin` registration (≤5 retries) | `AppDelegate.swift:21–40` |
|
||||
| ~launch | Native | `applicationDidBecomeActive` → `checkForSharedImageOnActivation` → clear flag + post `SharedPhotoReady` (dead) | `AppDelegate.swift:77,117,125` |
|
||||
| T0 | JS | bundle executes: `initializeApp`, router created/used, `DeepLinkHandler`, `app.mount` | `main.capacitor.ts:50,59,462`; `main.common.ts:39`; `router/index.ts:320` |
|
||||
| T0 | JS | register `appStateChange` listener | `main.capacitor.ts:491` |
|
||||
| T0 + ~1000ms | JS | **startup timer** → `checkForSharedImageAndNavigate` → `getSharedImage` → push `/shared-photo` | `main.capacitor.ts:474–483,353,423,449` |
|
||||
| T0 + ~2000ms | JS | `registerDeepLinkListener` → `await router.isReady` → `addListener("appUrlOpen")` | `main.capacitor.ts:500,322,329` |
|
||||
|
||||
```
|
||||
Extension → openMainApp → AppDelegate(launch) → Capacitor bridge → JS bootstrap (T0)
|
||||
→ router ready → [appUrlOpen registered @ T0+2000ms]
|
||||
→ startup shared-image check @ T0+1000ms ──► getSharedImage ──► /shared-photo
|
||||
→ applicationDidBecomeActive (native flag path, no JS effect)
|
||||
```
|
||||
|
||||
**Cold-start race conditions / ordering dependencies:**
|
||||
|
||||
1. **`appUrlOpen` arrives before its listener exists.** The URL is delivered at
|
||||
launch, but the listener attaches at T0+2000ms (`:500`/`:329`). Unless
|
||||
Capacitor buffers the launch URL, the `appUrlOpen` path does not fire on cold
|
||||
start. Cold-start success relies on the **T0+1000ms startup timer** instead.
|
||||
|
||||
2. **Plugin registration vs. first `getSharedImage()`.** Plugin registration runs
|
||||
T+0.5s..~2.5s (`AppDelegate.swift:21–40`); the startup `getSharedImage()` call
|
||||
is at ~T0+1000ms. If the bridge/plugin is not yet registered when JS calls
|
||||
`getSharedImage()`, the call throws and is caught
|
||||
(`main.capacitor.ts:160–167`), returning `{ success: false }`. Recovery then
|
||||
depends on a later `appStateChange` firing.
|
||||
|
||||
3. **Native flag cleared with no JS consumer.** `applicationDidBecomeActive`
|
||||
clears `sharedPhotoReady` and posts `SharedPhotoReady` (`AppDelegate.swift:121,125`),
|
||||
but no JS listens. Clearing the flag has no effect on JS navigation because JS
|
||||
reads the **file/metadata**, not the flag — so this does not cause loss, but
|
||||
the posted notification is inert.
|
||||
|
||||
### Warm start timeline
|
||||
|
||||
| Time | Actor | Event | Reference |
|
||||
| --- | --- | --- | --- |
|
||||
| t | Extension | write file + metadata + flag; `open("timesafari://")`; `completeRequest` | `ShareViewController.swift:340,140,373,119` |
|
||||
| t | Native | `application(_:open:)` → ApplicationDelegateProxy → `appUrlOpen` (listener already attached) | `AppDelegate.swift:133`; `main.capacitor.ts:329` |
|
||||
| t | Native | `applicationDidBecomeActive` → clear flag + post `SharedPhotoReady` (dead) | `AppDelegate.swift:77,121,125` |
|
||||
| t (≈same) | JS | `appStateChange(isActive)` → `checkForSharedImageAndNavigate` | `main.capacitor.ts:491` |
|
||||
| t (≈same) | JS | `appUrlOpen` → `handleDeepLink` → `checkAndStoreNativeSharedImage` | `main.capacitor.ts:194,216` |
|
||||
| t+ε | JS | `getSharedImage` → store temp DB → `router.push/replace("/shared-photo")` | `main.capacitor.ts:159,177,234/239` |
|
||||
|
||||
```
|
||||
Extension → appUrlOpen (listener present) ─┐
|
||||
├─► handleDeepLink / checkForSharedImageAndNavigate
|
||||
appStateChange(isActive) ──────────────────┘ → getSharedImage → /shared-photo
|
||||
```
|
||||
|
||||
**Warm-start race conditions / ordering dependencies:**
|
||||
|
||||
1. **Duplicate triggers.** `appUrlOpen` (C) and `appStateChange` (B) fire at
|
||||
nearly the same time, both calling `checkAndStoreNativeSharedImage()`. The
|
||||
`isProcessingSharedImage` boolean lock (`main.capacitor.ts:62,136,143`)
|
||||
prevents concurrent processing, but it is a simple non-reentrant flag: the
|
||||
second caller returns `{ success: false }` immediately and does not navigate,
|
||||
so navigation is driven by whichever caller wins. Because the lock is released
|
||||
synchronously at the end of each path, ordering determines which trigger
|
||||
performs the navigation.
|
||||
|
||||
2. **Read-only native retrieval.** `getSharedImageData()`
|
||||
(`SharedImageUtility.swift:51`) leaves the file/metadata intact after reading
|
||||
(`:75–76`), so repeated reads from B and C return the same data rather than one
|
||||
"consuming" the other.
|
||||
|
||||
---
|
||||
|
||||
## 7. Final summary
|
||||
|
||||
(No code changes recommended — answers only.)
|
||||
|
||||
1. **What mechanism actually causes successful warm-start shares to navigate to
|
||||
`/shared-photo`?**
|
||||
The **JS `appUrlOpen` listener** (`main.capacitor.ts:329`) firing
|
||||
`handleDeepLink` (`:194`), and/or the **`appStateChange` listener** (`:491`),
|
||||
each calling `checkAndStoreNativeSharedImage()` → `SharedImage.getSharedImage()`
|
||||
→ `router.push/replace("/shared-photo")`. On a warm start the `appUrlOpen`
|
||||
listener already exists, so the deep-link path is available; `appStateChange`
|
||||
is a redundant parallel trigger. Both converge through the
|
||||
`isProcessingSharedImage` lock onto the same navigation.
|
||||
|
||||
2. **What mechanism is supposed to cause successful cold-start shares to navigate
|
||||
to `/shared-photo`?**
|
||||
The **JS startup timer** at `main.capacitor.ts:474–483` (iOS `[1000]` ms) →
|
||||
`checkForSharedImageAndNavigate()` (`:353`) →
|
||||
`checkAndStoreNativeSharedImage()` (`:423`) → `getSharedImage()` (reads the App
|
||||
Group file/metadata persisted by the extension) → `router.push("/shared-photo")`
|
||||
(`:449`). `appStateChange` (`:491`) acts as a backstop. This path does **not**
|
||||
depend on the `appUrlOpen` event, because the `appUrlOpen` listener is not
|
||||
registered until ~T0+2000ms (`:500`), after the launch URL has already been
|
||||
delivered.
|
||||
|
||||
3. **Are those mechanisms the same or different?**
|
||||
**Different.**
|
||||
- Warm start: driven primarily by the **`appUrlOpen` deep-link event**
|
||||
(`handleDeepLink`, `:194`/`:216`), with `appStateChange` as parallel backup.
|
||||
- Cold start: driven by the **startup `setTimeout` poll** (`:474`) /
|
||||
`appStateChange` (`:491`) calling `checkForSharedImageAndNavigate()`.
|
||||
They share the same downstream code (`checkAndStoreNativeSharedImage` →
|
||||
`getSharedImage` → router navigation) but are entered through **different
|
||||
triggers**, because the `appUrlOpen` event is unavailable during cold start.
|
||||
|
||||
4. **Is there any point where a launch URL or launch signal could be lost before
|
||||
JavaScript is ready?**
|
||||
**Yes — the `appUrlOpen` URL event can be lost on cold start.** The launch URL
|
||||
is delivered to `AppDelegate.application(_:open:)` (`AppDelegate.swift:133`)
|
||||
and proxied into Capacitor at process launch, but the JS `appUrlOpen` listener
|
||||
is not registered until ~2000 ms after mount (`main.capacitor.ts:500,329`).
|
||||
If Capacitor does not buffer the launch URL until that listener attaches, the
|
||||
`appUrlOpen` event is dropped. Additionally, the native
|
||||
`SharedPhotoReady` NSNotification (`AppDelegate.swift:125`) is posted with **no
|
||||
JS/bridge listener**, so that signal is always lost.
|
||||
**However, the share payload (image + metadata in the App Group container,
|
||||
`ShareViewController.swift:340–347`) is durable and is not lost**; it is
|
||||
recovered by the startup timer / `appStateChange` calling
|
||||
`getSharedImage()`, which is why cold-start shares can still reach
|
||||
`/shared-photo` despite the `appUrlOpen` event being unavailable.
|
||||
3
ios/.gitignore
vendored
3
ios/.gitignore
vendored
@@ -17,6 +17,7 @@ App/App/config.xml
|
||||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
||||
App/App.xcodeproj/*.xcuserstate
|
||||
|
||||
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
|
||||
# Generated by capacitor-assets at build time (not in repo). Fresh clones lack these
|
||||
# folders; scripts/common.sh ensure_ios_capacitor_asset_directories creates them before generate.
|
||||
App/App/Assets.xcassets/AppIcon.appiconset
|
||||
App/App/Assets.xcassets/Splash.imageset
|
||||
|
||||
@@ -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 */,
|
||||
);
|
||||
@@ -452,7 +445,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -508,7 +501,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
@@ -524,18 +517,18 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.12;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -553,18 +546,18 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.3.12;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -582,7 +575,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -596,7 +589,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.3.12;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
@@ -620,7 +613,7 @@
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 67;
|
||||
CURRENT_PROJECT_VERSION = 69;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -634,7 +627,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.3.12;
|
||||
MARKETING_VERSION = 1.4.3;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
30
ios/App/App/AppBridgeViewController.swift
Normal file
30
ios/App/App/AppBridgeViewController.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,7 +24,16 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public var pluginMethods: [CAPPluginMethod] {
|
||||
return [
|
||||
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
|
||||
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise)
|
||||
CAPPluginMethod(#selector(hasSharedImage(_:)), 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)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -33,7 +42,7 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
/**
|
||||
* Get shared image data from App Group UserDefaults
|
||||
* Returns base64 string and fileName, or null if no image exists
|
||||
* Clears the data after reading to prevent re-reading
|
||||
* Read-only: native metadata and file are left intact after retrieval (Phase 1C)
|
||||
*/
|
||||
@objc public func getSharedImage(_ call: CAPPluginCall) {
|
||||
guard let sharedData = SharedImageUtility.getSharedImageData() else {
|
||||
@@ -62,5 +71,50 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
"hasImage": hasImage
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic snapshot of Share Extension startup and pending share state
|
||||
*/
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,22 +13,48 @@ public class SharedImageUtility {
|
||||
private static let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
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? {
|
||||
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||
}
|
||||
|
||||
|
||||
private static func logShareDiagnostic(method: String, userDefaults: UserDefaults?) {
|
||||
let shareId = userDefaults?.string(forKey: sharedPhotoShareIdKey)
|
||||
let filePath = userDefaults?.string(forKey: sharedPhotoFilePathKey)
|
||||
let metadataExists = filePath != nil
|
||||
let fileExists: Bool
|
||||
if let filePath = filePath, let containerURL = appGroupContainerURL {
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
fileExists = FileManager.default.fileExists(atPath: fileURL.path)
|
||||
} else {
|
||||
fileExists = false
|
||||
}
|
||||
|
||||
let shareIdLog = shareId ?? "nil"
|
||||
let filePathLog = filePath ?? "nil"
|
||||
print("[ShareTarget] \(method) shareId=\(shareIdLog) sharedPhotoFilePath=\(filePathLog) metadataExists=\(metadataExists) fileExists=\(fileExists)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared image data from App Group container file
|
||||
* All images are stored as files for consistency and to avoid UserDefaults size limits
|
||||
* Clears the data after reading to prevent re-reading
|
||||
* Read-only: metadata and file are left intact after retrieval (Phase 1C)
|
||||
*
|
||||
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
|
||||
*/
|
||||
static func getSharedImageData() -> [String: String]? {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
logShareDiagnostic(method: "getSharedImageData", userDefaults: userDefaults)
|
||||
|
||||
guard let userDefaults = userDefaults else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -39,25 +65,21 @@ public class SharedImageUtility {
|
||||
}
|
||||
|
||||
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
|
||||
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
|
||||
|
||||
// Read image data from file
|
||||
guard let imageData = try? Data(contentsOf: fileURL) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
let resolvedShareId = shareId ?? "unknown"
|
||||
print("[ShareTarget] shareId=\(resolvedShareId) retrieved")
|
||||
print("[ShareTarget] shareId=\(resolvedShareId) left intact after retrieval")
|
||||
|
||||
// Convert file data to base64 for JavaScript consumption
|
||||
let base64String = imageData.base64EncodedString()
|
||||
|
||||
// Clear the shared data after reading
|
||||
userDefaults.removeObject(forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
|
||||
|
||||
// Remove the file
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
userDefaults.synchronize()
|
||||
|
||||
|
||||
return ["base64": base64String, "fileName": fileName]
|
||||
}
|
||||
|
||||
@@ -67,7 +89,10 @@ public class SharedImageUtility {
|
||||
* @returns true if shared image file exists, false otherwise
|
||||
*/
|
||||
static func hasSharedImage() -> Bool {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||
let userDefaults = UserDefaults(suiteName: appGroupIdentifier)
|
||||
logShareDiagnostic(method: "hasSharedImage", userDefaults: userDefaults)
|
||||
|
||||
guard let userDefaults = userDefaults,
|
||||
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
|
||||
let containerURL = appGroupContainerURL else {
|
||||
return false
|
||||
@@ -76,7 +101,129 @@ public class SharedImageUtility {
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
return FileManager.default.fileExists(atPath: fileURL.path)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Diagnostic snapshot of Share Extension startup and pending share state
|
||||
* Read-only: does not modify App Group storage
|
||||
*/
|
||||
static func getShareExtensionDiagnostics() -> [String: Any] {
|
||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||
return [
|
||||
"shareExtensionLastStart": NSNull(),
|
||||
"sharedPhotoShareId": NSNull(),
|
||||
"sharedPhotoFilePath": NSNull(),
|
||||
"fileExists": false,
|
||||
// TEMPORARY SHARE TARGET DIAGNOSTICS
|
||||
"pendingShareExists": false
|
||||
]
|
||||
}
|
||||
|
||||
let shareExtensionLastStart = userDefaults.string(forKey: shareExtensionLastStartKey)
|
||||
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
|
||||
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey)
|
||||
let fileExists: Bool
|
||||
if let filePath = filePath, let containerURL = appGroupContainerURL {
|
||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||
fileExists = FileManager.default.fileExists(atPath: fileURL.path)
|
||||
} else {
|
||||
fileExists = false
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||
|
||||
platform :ios, '13.0'
|
||||
platform :ios, '15.5'
|
||||
use_frameworks!
|
||||
|
||||
# workaround to avoid Xcode caching of Pods that requires
|
||||
@@ -30,9 +30,4 @@ end
|
||||
|
||||
post_install do |installer|
|
||||
assertDeploymentTarget(installer)
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,94 +1,84 @@
|
||||
PODS:
|
||||
- Capacitor (6.2.1):
|
||||
- Capacitor (7.6.4):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (6.0.2):
|
||||
- CapacitorApp (7.1.2):
|
||||
- Capacitor
|
||||
- CapacitorCamera (6.1.2):
|
||||
- CapacitorCamera (7.0.5):
|
||||
- Capacitor
|
||||
- CapacitorClipboard (6.0.2):
|
||||
- CapacitorClipboard (7.0.4):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySqlite (6.0.2):
|
||||
- CapacitorCommunitySqlite (7.0.3):
|
||||
- Capacitor
|
||||
- SQLCipher
|
||||
- ZIPFoundation
|
||||
- CapacitorCordova (6.2.1)
|
||||
- CapacitorFilesystem (6.0.3):
|
||||
- CapacitorCordova (7.6.4)
|
||||
- CapacitorFilesystem (7.1.8):
|
||||
- Capacitor
|
||||
- CapacitorMlkitBarcodeScanning (6.2.0):
|
||||
- IONFilesystemLib (~> 1.1.1)
|
||||
- CapacitorMlkitBarcodeScanning (7.5.0):
|
||||
- Capacitor
|
||||
- GoogleMLKit/BarcodeScanning (= 5.0.0)
|
||||
- CapacitorShare (6.0.3):
|
||||
- GoogleMLKit/BarcodeScanning (= 7.0.0)
|
||||
- CapacitorShare (7.0.4):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (6.0.2):
|
||||
- CapacitorStatusBar (7.0.6):
|
||||
- Capacitor
|
||||
- CapawesomeCapacitorFilePicker (6.2.0):
|
||||
- CapawesomeCapacitorFilePicker (7.2.0):
|
||||
- Capacitor
|
||||
- GoogleDataTransport (9.4.1):
|
||||
- GoogleUtilities/Environment (~> 7.7)
|
||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleMLKit/BarcodeScanning (5.0.0):
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 4.0.0)
|
||||
- GoogleMLKit/MLKitCore (5.0.0):
|
||||
- MLKitCommon (~> 10.0.0)
|
||||
- GoogleToolboxForMac/DebugUtils (2.3.2):
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- GoogleToolboxForMac/Defines (2.3.2)
|
||||
- GoogleToolboxForMac/Logger (2.3.2):
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- "GoogleToolboxForMac/NSData+zlib (2.3.2)":
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)":
|
||||
- GoogleToolboxForMac/DebugUtils (= 2.3.2)
|
||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
||||
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)"
|
||||
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)"
|
||||
- GoogleUtilities/Environment (7.13.3):
|
||||
- MLKitBarcodeScanning (~> 6.0.0)
|
||||
- GoogleMLKit/MLKitCore (7.0.0):
|
||||
- MLKitCommon (~> 12.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- PromisesObjC (< 3.0, >= 1.2)
|
||||
- GoogleUtilities/Logger (7.13.3):
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (7.13.3)
|
||||
- GoogleUtilities/UserDefaults (7.13.3):
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilitiesComponents (1.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- MLImage (1.0.0-beta5)
|
||||
- MLKitBarcodeScanning (4.0.0):
|
||||
- MLKitCommon (~> 10.0)
|
||||
- MLKitVision (~> 6.0)
|
||||
- MLKitCommon (10.0.0):
|
||||
- GoogleDataTransport (~> 9.0)
|
||||
- GoogleToolboxForMac/Logger (~> 2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
|
||||
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
|
||||
- GoogleUtilities/UserDefaults (~> 7.0)
|
||||
- GoogleUtilitiesComponents (~> 1.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
|
||||
- MLKitVision (6.0.0):
|
||||
- GoogleToolboxForMac/Logger (~> 2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
|
||||
- MLImage (= 1.0.0-beta5)
|
||||
- MLKitCommon (~> 10.0)
|
||||
- nanopb (2.30910.0):
|
||||
- nanopb/decode (= 2.30910.0)
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- IONFilesystemLib (1.1.2)
|
||||
- MLImage (1.0.0-beta6)
|
||||
- MLKitBarcodeScanning (6.0.0):
|
||||
- MLKitCommon (~> 12.0)
|
||||
- MLKitVision (~> 8.0)
|
||||
- MLKitCommon (12.0.0):
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (8.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta6)
|
||||
- MLKitCommon (~> 12.0)
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- PromisesObjC (2.4.0)
|
||||
- SQLCipher (4.9.0):
|
||||
- SQLCipher/standard (= 4.9.0)
|
||||
- SQLCipher/common (4.9.0)
|
||||
- SQLCipher/standard (4.9.0):
|
||||
- SQLCipher (4.10.0):
|
||||
- SQLCipher/standard (= 4.10.0)
|
||||
- SQLCipher/common (4.10.0)
|
||||
- SQLCipher/standard (4.10.0):
|
||||
- SQLCipher/common
|
||||
- TimesafariDailyNotificationPlugin (2.0.0):
|
||||
- TimesafariDailyNotificationPlugin (4.0.1):
|
||||
- Capacitor
|
||||
- ZIPFoundation (0.9.19)
|
||||
- ZIPFoundation (0.9.20)
|
||||
|
||||
DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
@@ -110,8 +100,8 @@ SPEC REPOS:
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GoogleUtilitiesComponents
|
||||
- GTMSessionFetcher
|
||||
- IONFilesystemLib
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
@@ -148,33 +138,33 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@timesafari/daily-notification-plugin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
|
||||
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
|
||||
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
|
||||
CapacitorClipboard: 4443c3cdb7c77b1533dfe3ff0f9f7756aa8579df
|
||||
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b
|
||||
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
|
||||
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
|
||||
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
|
||||
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
|
||||
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
|
||||
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
|
||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
|
||||
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
|
||||
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
||||
Capacitor: 69dc07ebc6bd064747c5e76922f97e4862d9cc23
|
||||
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
|
||||
CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
|
||||
CapacitorClipboard: d1f123674cf413125db816a45e8f70e8770972fc
|
||||
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
|
||||
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
|
||||
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
|
||||
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
|
||||
CapacitorShare: 25f7fc5dd0e4edbde5d6801c6de5d14a8b450a41
|
||||
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
|
||||
CapawesomeCapacitorFilePicker: 0f4a913a00e39dd77213449f0d917e92f35a5ca9
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
|
||||
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e
|
||||
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2
|
||||
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
||||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
|
||||
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
|
||||
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
|
||||
PODFILE CHECKSUM: 87c07d03f36ef38ab0c873802aee1ce9b5d34448
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -9,135 +9,264 @@ import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
|
||||
private let appGroupIdentifier = "group.app.timesafari.share"
|
||||
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||
private let sharedPhotoShareIdKey = "sharedPhotoShareId"
|
||||
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)
|
||||
userDefaults.synchronize()
|
||||
print("[ShareTarget] shareExtensionLastStart=\(timestamp)")
|
||||
}
|
||||
|
||||
print("[ShareTarget] viewDidLoad started")
|
||||
super.viewDidLoad()
|
||||
|
||||
|
||||
// Set a minimal background (transparent or loading indicator)
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
let attachmentCount = inputItems.reduce(0) { count, item in
|
||||
count + (item.attachments?.count ?? 0)
|
||||
}
|
||||
print("[ShareTarget] processAndOpenApp inputItems=\(inputItems.count) attachmentCount=\(attachmentCount)")
|
||||
|
||||
processSharedImage(from: inputItems) { [weak self] success in
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
if success {
|
||||
// Set flag that shared photo is ready
|
||||
self.setSharedPhotoReadyFlag()
|
||||
// Open the main app (using minimal URL - app will detect shared data on activation)
|
||||
self.openMainApp()
|
||||
} else {
|
||||
print("[ShareTarget] processAndOpenApp failed: processSharedImage returned false")
|
||||
}
|
||||
|
||||
|
||||
// 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) {
|
||||
let attachmentCount = items.reduce(0) { count, item in
|
||||
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 {
|
||||
guard let attachments = item.attachments else {
|
||||
print("[ShareTarget] processSharedImage skipping item with no attachments")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
for attachment in attachments {
|
||||
// Skip non-image attachments
|
||||
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
|
||||
print("[ShareTarget] processSharedImage skipping non-image attachment")
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
|
||||
guard let self = self else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
if error != nil {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image"
|
||||
|
||||
if let url = data as? URL {
|
||||
// Most common case: Image provided as file URL - read raw data to preserve format
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw data directly to preserve original format
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
|
||||
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
|
||||
imageData = image.pngData()
|
||||
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
|
||||
}
|
||||
} else if let data = data as? Data {
|
||||
// Less common: Image provided as raw Data - use directly to preserve format
|
||||
imageData = data
|
||||
fileName = attachment.suggestedName ?? "shared-image"
|
||||
}
|
||||
|
||||
guard let finalImageData = imageData else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Store image as file in App Group container
|
||||
if self.storeImageData(finalImageData, fileName: fileName) {
|
||||
completion(true)
|
||||
} else {
|
||||
completion(false)
|
||||
}
|
||||
guard let self = self else {
|
||||
print("[ShareTarget] processSharedImage failed: self unavailable in loadItem callback shareId=\(shareId)")
|
||||
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
return // Process only the first image
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Handle different image data types
|
||||
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
|
||||
var imageData: Data?
|
||||
var fileName: String = "shared-image"
|
||||
|
||||
if let url = data as? URL {
|
||||
print("[ShareTarget] processSharedImage loadItem returned URL shareId=\(shareId) url=\(url.lastPathComponent)")
|
||||
// Most common case: Image provided as file URL - read raw data to preserve format
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
// Read raw data directly to preserve original format
|
||||
imageData = try? Data(contentsOf: url)
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
|
||||
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
|
||||
print("[ShareTarget] processSharedImage using UIImage PNG fallback shareId=\(shareId)")
|
||||
imageData = image.pngData()
|
||||
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
|
||||
} else if imageData == nil {
|
||||
print("[ShareTarget] processSharedImage failed: could not read image data from URL shareId=\(shareId)")
|
||||
}
|
||||
} else if let data = data as? Data {
|
||||
print("[ShareTarget] processSharedImage loadItem returned Data shareId=\(shareId)")
|
||||
// Less common: Image provided as raw Data - use directly to preserve format
|
||||
imageData = data
|
||||
fileName = attachment.suggestedName ?? "shared-image"
|
||||
} else {
|
||||
print("[ShareTarget] processSharedImage failed: loadItem returned unexpected type shareId=\(shareId) type=\(String(describing: type(of: data)))")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
return // Process only the first image
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
/// Helper to get filename with a new extension, preserving base name
|
||||
private func getFileNameWithExtension(_ originalName: String, newExtension: String) -> String {
|
||||
if let nameWithoutExt = originalName.components(separatedBy: ".").first, !nameWithoutExt.isEmpty {
|
||||
@@ -145,63 +274,116 @@ class ShareViewController: UIViewController {
|
||||
}
|
||||
return "shared-image.\(newExtension)"
|
||||
}
|
||||
|
||||
|
||||
/// Extract file extension from original filename, defaulting to jpg when absent
|
||||
private func fileExtension(from fileName: String) -> String {
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
return ext.isEmpty ? "jpg" : ext.lowercased()
|
||||
}
|
||||
|
||||
/// Build unique on-disk filename: <shareId>.<extension>
|
||||
private func storedFileName(shareId: String, originalFileName: String) -> String {
|
||||
return "\(shareId).\(fileExtension(from: originalFileName))"
|
||||
}
|
||||
|
||||
/// Store image data as a file in the App Group container
|
||||
/// All images are stored as files regardless of size for consistency and simplicity
|
||||
/// Returns true if successful, false otherwise
|
||||
private func storeImageData(_ imageData: Data, fileName: String) -> Bool {
|
||||
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
|
||||
}
|
||||
|
||||
// Create file URL in the container using the actual filename
|
||||
// Extract extension from fileName if present, otherwise use sharedImageFileName
|
||||
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName
|
||||
let fileURL = containerURL.appendingPathComponent(actualFileName)
|
||||
|
||||
// Remove old file if it exists
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
|
||||
|
||||
let originalFileName = fileName.isEmpty ? "\(sharedImageFileName).jpg" : fileName
|
||||
let storedFileName = storedFileName(shareId: shareId, originalFileName: originalFileName)
|
||||
let fileURL = containerURL.appendingPathComponent(storedFileName)
|
||||
|
||||
// Remove previously pending share file (metadata tracks one share at a time)
|
||||
if let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
|
||||
let previousPath = userDefaults.string(forKey: sharedPhotoFilePathKey) {
|
||||
let previousURL = containerURL.appendingPathComponent(previousPath)
|
||||
if previousURL != fileURL {
|
||||
try? FileManager.default.removeItem(at: previousURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Write image data to file
|
||||
do {
|
||||
try imageData.write(to: fileURL)
|
||||
} 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)")
|
||||
|
||||
// Store file path and filename in UserDefaults (small data, safe to store)
|
||||
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
|
||||
}
|
||||
|
||||
// Store relative path and filename
|
||||
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey)
|
||||
|
||||
|
||||
// sharedPhotoFilePath = on-disk name; sharedPhotoFileName = original display name
|
||||
userDefaults.set(storedFileName, forKey: sharedPhotoFilePathKey)
|
||||
userDefaults.set(originalFileName, forKey: sharedPhotoFileNameKey)
|
||||
userDefaults.set(shareId, forKey: sharedPhotoShareIdKey)
|
||||
|
||||
// Clean up any old base64 data that might exist
|
||||
userDefaults.removeObject(forKey: "sharedPhotoBase64")
|
||||
|
||||
|
||||
userDefaults.synchronize()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
// Fallback: use extension context
|
||||
extensionContext?.open(url, completionHandler: nil)
|
||||
print("[ShareTarget] openMainApp completed via extensionContext fallback")
|
||||
// TEMPORARY SHARE TARGET DIAGNOSTICS
|
||||
appendTrace("openMainApp SUCCESS")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
8694
package-lock.json
generated
8694
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,6 +1,7 @@
|
||||
|
||||
{
|
||||
"name": "giftopia",
|
||||
"version": "1.3.13-beta",
|
||||
"version": "1.4.3",
|
||||
"description": "Giftopia App",
|
||||
"author": {
|
||||
"name": "Gift Economies Team"
|
||||
@@ -13,6 +14,7 @@
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
|
||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||
"check:dependencies": "./scripts/check-dependencies.sh",
|
||||
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
||||
"test:all": "npm run lint && tsc && npm run test:web && npm run test:mobile && ./scripts/test-safety-check.sh && echo '\n\n\nGotta add the performance tests'",
|
||||
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
"test:mobile": "./scripts/test-mobile.sh",
|
||||
@@ -28,7 +30,7 @@
|
||||
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
|
||||
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"build:capacitor:sync": "npm run build:capacitor && npx cap sync && node scripts/restore-local-plugins.js",
|
||||
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && npx capacitor-assets generate",
|
||||
"build:native": "vite build && npx cap sync && node scripts/restore-local-plugins.js && bash -c 'source scripts/common.sh && ensure_ios_capacitor_asset_directories' && npx capacitor-assets generate",
|
||||
"assets:config": "npx tsx scripts/assets-config.ts",
|
||||
"assets:validate": "npx tsx scripts/assets-validator.ts",
|
||||
"assets:validate:android": "./scripts/build-android.sh --assets-only",
|
||||
@@ -138,19 +140,19 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/electron": "^5.0.1",
|
||||
"@capacitor-community/sqlite": "6.0.2",
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
"@capacitor/camera": "^6.0.0",
|
||||
"@capacitor/cli": "^6.2.0",
|
||||
"@capacitor/clipboard": "^6.0.2",
|
||||
"@capacitor/core": "^6.2.0",
|
||||
"@capacitor/filesystem": "^6.0.0",
|
||||
"@capacitor/ios": "^6.2.0",
|
||||
"@capacitor/share": "^6.0.3",
|
||||
"@capacitor/status-bar": "^6.0.2",
|
||||
"@capawesome/capacitor-file-picker": "^6.2.0",
|
||||
"@capacitor-community/sqlite": "^7.0.3",
|
||||
"@capacitor-mlkit/barcode-scanning": "^7.5.0",
|
||||
"@capacitor/android": "^7.6.4",
|
||||
"@capacitor/app": "^7.1.0",
|
||||
"@capacitor/camera": "^7.0.5",
|
||||
"@capacitor/cli": "^7.6.4",
|
||||
"@capacitor/clipboard": "^7.0.4",
|
||||
"@capacitor/core": "^7.6.4",
|
||||
"@capacitor/filesystem": "^7.1.8",
|
||||
"@capacitor/ios": "^7.6.4",
|
||||
"@capacitor/share": "^7.0.4",
|
||||
"@capacitor/status-bar": "^7.0.6",
|
||||
"@capawesome/capacitor-file-picker": "^7.2.0",
|
||||
"@dicebear/collection": "^5.4.1",
|
||||
"@dicebear/core": "^5.4.1",
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
|
||||
@@ -222,7 +222,9 @@ build_ios_app() {
|
||||
|
||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||
build_config="Debug"
|
||||
destination="platform=iOS Simulator,name=iPhone 15 Pro"
|
||||
# Use device SDK — prebuilt MLKit frameworks (MLImage, MLKitBarcodeScanning) ship
|
||||
# iOS device slices only and cannot link against the iOS Simulator SDK.
|
||||
destination="generic/platform=iOS"
|
||||
else
|
||||
build_config="Release"
|
||||
destination="platform=iOS,id=auto"
|
||||
@@ -232,18 +234,34 @@ build_ios_app() {
|
||||
|
||||
cd ios/App
|
||||
|
||||
# Build the app
|
||||
xcodebuild -workspace App.xcworkspace \
|
||||
# Prevent pkgx-managed libs (e.g. zlib) from leaking into the iOS SDK linker.
|
||||
unset LIBRARY_PATH
|
||||
unset DYLD_LIBRARY_PATH
|
||||
unset DYLD_FALLBACK_LIBRARY_PATH
|
||||
|
||||
# Build the app:
|
||||
# -quiet: skip the huge export VAR dump (compiler warnings still show unless suppressed below).
|
||||
# SWIFT_SUPPRESS_WARNINGS / GCC_WARN_INHIBIT_ALL_WARNINGS: quiet CLI output from Pods + plugins;
|
||||
# build in Xcode for full diagnostics. Real errors still fail the build.
|
||||
local build_exit=0
|
||||
xcodebuild -quiet \
|
||||
-workspace App.xcworkspace \
|
||||
-scheme "$scheme" \
|
||||
-configuration "$build_config" \
|
||||
-destination "$destination" \
|
||||
build \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
SWIFT_SUPPRESS_WARNINGS=YES \
|
||||
GCC_WARN_INHIBIT_ALL_WARNINGS=YES || build_exit=$?
|
||||
|
||||
cd ../..
|
||||
|
||||
|
||||
if [ $build_exit -ne 0 ]; then
|
||||
return $build_exit
|
||||
fi
|
||||
|
||||
log_success "iOS app built successfully"
|
||||
}
|
||||
|
||||
@@ -406,6 +424,7 @@ fi
|
||||
# Handle assets-only mode
|
||||
if [ "$ASSETS_ONLY" = true ]; then
|
||||
log_info "Assets-only mode: generating assets"
|
||||
ensure_ios_capacitor_asset_directories
|
||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||
log_success "Assets generation completed successfully!"
|
||||
exit 0
|
||||
@@ -555,6 +574,7 @@ safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaroun
|
||||
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
|
||||
|
||||
# Step 7: Generate assets
|
||||
ensure_ios_capacitor_asset_directories
|
||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||
|
||||
# Step 8: Build iOS app
|
||||
@@ -564,16 +584,19 @@ safe_execute "Building iOS app" "build_ios_app" || exit 5
|
||||
if [ "$BUILD_IPA" = true ]; then
|
||||
log_info "Building IPA package..."
|
||||
cd ios/App
|
||||
xcodebuild -workspace App.xcworkspace \
|
||||
xcodebuild -quiet \
|
||||
-workspace App.xcworkspace \
|
||||
-scheme App \
|
||||
-configuration Release \
|
||||
-archivePath build/App.xcarchive \
|
||||
archive \
|
||||
CODE_SIGN_IDENTITY="" \
|
||||
CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
SWIFT_SUPPRESS_WARNINGS=YES \
|
||||
GCC_WARN_INHIBIT_ALL_WARNINGS=YES
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
xcodebuild -quiet -exportArchive \
|
||||
-archivePath build/App.xcarchive \
|
||||
-exportPath build/ \
|
||||
-exportOptionsPlist exportOptions.plist
|
||||
|
||||
@@ -337,6 +337,27 @@ parse_args() {
|
||||
fi
|
||||
}
|
||||
|
||||
# iOS: capacitor-assets writes into AppIcon.appiconset and Splash.imageset under
|
||||
# Assets.xcassets. Those paths are gitignored (generated). On a fresh clone the
|
||||
# folders and Contents.json are missing; the tool opens Contents.json before writing
|
||||
# PNGs, so we create minimal asset-catalog stubs when absent.
|
||||
ensure_ios_capacitor_asset_directories() {
|
||||
local base="ios/App/App/Assets.xcassets"
|
||||
if [ ! -d "$base" ]; then
|
||||
log_warn "Missing $base — cannot prepare iOS asset directories"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$base/AppIcon.appiconset" "$base/Splash.imageset"
|
||||
local minimal_contents='{"images":[],"info":{"author":"xcode","version":1}}'
|
||||
if [ ! -f "$base/AppIcon.appiconset/Contents.json" ]; then
|
||||
printf '%s\n' "$minimal_contents" > "$base/AppIcon.appiconset/Contents.json"
|
||||
fi
|
||||
if [ ! -f "$base/Splash.imageset/Contents.json" ]; then
|
||||
printf '%s\n' "$minimal_contents" > "$base/Splash.imageset/Contents.json"
|
||||
fi
|
||||
log_debug "Ensured iOS capacitor-assets output directories exist"
|
||||
}
|
||||
|
||||
# Export functions for use in child scripts
|
||||
export -f log_info log_success log_warn log_error log_debug log_step
|
||||
export -f measure_time print_header print_footer
|
||||
@@ -344,4 +365,5 @@ export -f check_command check_directory check_file
|
||||
export -f safe_execute check_venv get_git_hash
|
||||
export -f clean_build_artifacts validate_env_vars
|
||||
export -f setup_build_env setup_app_directories load_env_file print_env_vars
|
||||
export -f print_usage parse_args
|
||||
export -f print_usage parse_args
|
||||
export -f ensure_ios_capacitor_asset_directories
|
||||
@@ -8,14 +8,14 @@ notifications for conflicted entities * - Template streamlined with computed CSS
|
||||
properties * * @author Matthew Raymer */
|
||||
<template>
|
||||
<div id="sectionGiftedGiver">
|
||||
<label class="block font-bold mb-1">
|
||||
<label class="block font-semibold text-lg capitalize text-center">
|
||||
{{ stepLabel }}
|
||||
</label>
|
||||
<!-- Toggle link for entity type selection -->
|
||||
<div class="text-right mb-4">
|
||||
<div class="text-center mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
|
||||
class="text-xs text-blue-600 hover:underline uppercase"
|
||||
@click="handleToggleEntityType"
|
||||
>
|
||||
{{ toggleLinkText }}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
@click="disabled ? notifyLocked() : addGroup()"
|
||||
>
|
||||
<font-awesome icon="plus" class="text-sm" />
|
||||
New Group
|
||||
New Do-Not-Pair Group
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1175,11 +1175,6 @@ export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM = {
|
||||
message: "",
|
||||
};
|
||||
|
||||
export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR = {
|
||||
title: "Error",
|
||||
message: "There was a problem deleting the image.",
|
||||
};
|
||||
|
||||
export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
|
||||
title: "Missing Identifier",
|
||||
message: "You must select an identifier before you can record a give.",
|
||||
|
||||
@@ -359,6 +359,75 @@ async function checkForSharedImageAndNavigate() {
|
||||
}
|
||||
|
||||
try {
|
||||
// TEMPORARY SHARE TARGET DIAGNOSTICS
|
||||
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(
|
||||
`[ShareTarget] Diagnostics ${JSON.stringify({
|
||||
error:
|
||||
diagnosticsError instanceof Error
|
||||
? diagnosticsError.message
|
||||
: String(diagnosticsError),
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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");
|
||||
const imageResult = await checkAndStoreNativeSharedImage();
|
||||
|
||||
@@ -405,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
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
*/
|
||||
|
||||
import { WebPlugin } from "@capacitor/core";
|
||||
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
|
||||
import type {
|
||||
SharedImagePlugin,
|
||||
SharedImageResult,
|
||||
ShareExtensionDiagnostics,
|
||||
// TEMPORARY SHARE TARGET DIAGNOSTICS
|
||||
ShareExtensionTrace,
|
||||
// TEMPORARY SHARE TARGET DIAGNOSTICS
|
||||
AppLaunchTrace,
|
||||
} from "./definitions";
|
||||
|
||||
export class SharedImagePluginWeb
|
||||
extends WebPlugin
|
||||
@@ -18,4 +26,35 @@ export class SharedImagePluginWeb
|
||||
async hasSharedImage(): Promise<{ hasImage: boolean }> {
|
||||
return { hasImage: false };
|
||||
}
|
||||
|
||||
async getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics> {
|
||||
return {
|
||||
shareExtensionLastStart: null,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,30 @@ export interface SharedImageResult {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export interface ShareExtensionDiagnostics {
|
||||
shareExtensionLastStart: string | null;
|
||||
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 {
|
||||
/**
|
||||
* Get shared image data from native layer
|
||||
* Returns base64 string and fileName, or null if no image exists
|
||||
* Clears the data after reading to prevent re-reading
|
||||
* Read-only on iOS: native metadata and file are left intact after retrieval (Phase 1C)
|
||||
*/
|
||||
getSharedImage(): Promise<SharedImageResult | null>;
|
||||
|
||||
@@ -20,4 +39,33 @@ export interface SharedImagePlugin {
|
||||
* Useful for quick checks before calling getSharedImage()
|
||||
*/
|
||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
|
||||
@@ -136,6 +136,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "help-onboarding",
|
||||
component: () => import("../views/HelpOnboardingView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/help-terms",
|
||||
name: "help-terms",
|
||||
component: () => import("../views/HelpTermsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
@@ -285,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",
|
||||
|
||||
@@ -123,10 +123,10 @@ export class CapacitorQRScanner implements QRScannerService {
|
||||
|
||||
// Add listener for barcode scans
|
||||
const handle = await BarcodeScanner.addListener(
|
||||
"barcodeScanned",
|
||||
"barcodesScanned",
|
||||
(result) => {
|
||||
if (this.scanListener && result.barcode?.rawValue) {
|
||||
this.scanListener.onScan(result.barcode.rawValue);
|
||||
if (this.scanListener && result.barcodes?.[0]?.rawValue) {
|
||||
this.scanListener.onScan(result.barcodes[0].rawValue);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -62,10 +62,6 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions from the OS
|
||||
* Shows native permission dialog on first call
|
||||
*/
|
||||
/**
|
||||
* Request notification permissions from the OS
|
||||
* Shows native permission dialog on first call
|
||||
|
||||
@@ -1402,54 +1402,6 @@ export default class AccountViewView extends Vue {
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dev-only 10-minute rollover for daily reminder. Saves the setting and,
|
||||
* if reminder is already on, reschedules so the plugin uses the new interval.
|
||||
*/
|
||||
async toggleReminderFastRollover(): Promise<void> {
|
||||
const next = !this.reminderFastRolloverForTesting;
|
||||
await this.$saveSettings({
|
||||
reminderFastRolloverForTesting: next,
|
||||
});
|
||||
this.reminderFastRolloverForTesting = next;
|
||||
|
||||
if (this.notifyingReminder) {
|
||||
try {
|
||||
const service = NotificationService.getInstance();
|
||||
if (Capacitor.getPlatform() !== "android") {
|
||||
await service.cancelDailyNotification();
|
||||
}
|
||||
const time24h = this.parseTimeTo24Hour(this.notifyingReminderTime);
|
||||
const title = "Daily Reminder";
|
||||
const body =
|
||||
this.notifyingReminderMessage ||
|
||||
"Click to share some gratitude with the world -- even if they're unnamed.";
|
||||
await service.scheduleDailyNotification({
|
||||
time: time24h,
|
||||
title,
|
||||
body,
|
||||
priority: "normal",
|
||||
...(next ? { rolloverIntervalMinutes: 10 } : {}),
|
||||
});
|
||||
this.notify.success(
|
||||
next
|
||||
? "Reminder will repeat every 10 minutes (testing)."
|
||||
: "Reminder will repeat daily (24h).",
|
||||
TIMEOUTS.STANDARD,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
"[AccountViewView] Reschedule after fast-rollover toggle failed:",
|
||||
err,
|
||||
);
|
||||
this.notify.error(
|
||||
"Failed to update reminder interval. Please try again.",
|
||||
TIMEOUTS.STANDARD,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
|
||||
*/
|
||||
@@ -2116,6 +2068,10 @@ export default class AccountViewView extends Vue {
|
||||
hasLocation: result.includeLocation,
|
||||
});
|
||||
|
||||
if (response.data.userMessage) {
|
||||
this.notify.info(response.data.userMessage);
|
||||
}
|
||||
|
||||
return result;
|
||||
} else {
|
||||
logger.debug("[AccountViewView] No profile data found in response:", {
|
||||
@@ -2123,6 +2079,10 @@ export default class AccountViewView extends Vue {
|
||||
hasData: !!response.data,
|
||||
hasDataData: !!(response.data && response.data.data),
|
||||
});
|
||||
|
||||
if (response.data?.userMessage) {
|
||||
this.notify.info(response.data.userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -2229,6 +2189,10 @@ export default class AccountViewView extends Vue {
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
if (response.data?.userMessage) {
|
||||
this.notify.info(response.data.userMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
// Handle specific HTTP status codes cleanly to suppress console spam
|
||||
@@ -2345,6 +2309,10 @@ export default class AccountViewView extends Vue {
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
if (response.data?.userMessage) {
|
||||
this.notify.info(response.data.userMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
// Handle specific HTTP status codes cleanly to suppress console spam
|
||||
|
||||
@@ -118,11 +118,13 @@ import {
|
||||
CONTACT_CSV_HEADER,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
generateEndorserJwtUrlForAccount,
|
||||
register,
|
||||
setVisibilityUtil,
|
||||
} from "../libs/endorserServer";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import { retrieveAccountMetadata } from "../libs/util";
|
||||
|
||||
import { AxiosError } from "axios";
|
||||
import { Account } from "@/db/tables/accounts";
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import {
|
||||
@@ -139,7 +141,10 @@ import {
|
||||
NOTIFY_QR_URL_COPIED,
|
||||
NOTIFY_QR_CODE_HELP,
|
||||
NOTIFY_QR_DID_COPIED,
|
||||
NOTIFY_QR_REGISTRATION_SUBMITTED,
|
||||
NOTIFY_QR_REGISTRATION_ERROR,
|
||||
createQRContactAddedMessage,
|
||||
createQRRegistrationSuccessMessage,
|
||||
QR_TIMEOUT_MEDIUM,
|
||||
QR_TIMEOUT_STANDARD,
|
||||
QR_TIMEOUT_LONG,
|
||||
@@ -204,6 +209,7 @@ export default class ContactQRScanFull extends Vue {
|
||||
activeDid = "";
|
||||
apiServer = "";
|
||||
givenName = "";
|
||||
hideRegisterPromptOnNewContact = false;
|
||||
isRegistered = false;
|
||||
profileImageUrl = "";
|
||||
qrValue = "";
|
||||
@@ -278,6 +284,8 @@ export default class ContactQRScanFull extends Vue {
|
||||
|
||||
this.apiServer = settings.apiServer || "";
|
||||
this.givenName = settings.firstName || "";
|
||||
this.hideRegisterPromptOnNewContact =
|
||||
!!settings.hideRegisterPromptOnNewContact;
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
this.profileImageUrl = settings.profileImageUrl || "";
|
||||
|
||||
@@ -575,6 +583,34 @@ export default class ContactQRScanFull extends Vue {
|
||||
createQRContactAddedMessage(!!this.activeDid),
|
||||
QR_TIMEOUT_STANDARD,
|
||||
);
|
||||
|
||||
if (
|
||||
this.isRegistered &&
|
||||
!this.hideRegisterPromptOnNewContact &&
|
||||
!contact.registered
|
||||
) {
|
||||
setTimeout(() => {
|
||||
this.$notify(
|
||||
{
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Register",
|
||||
text: "Do you want to register them?",
|
||||
onCancel: async (stopAsking?: boolean) => {
|
||||
await this.handleRegistrationPromptResponse(stopAsking);
|
||||
},
|
||||
onNo: async (stopAsking?: boolean) => {
|
||||
await this.handleRegistrationPromptResponse(stopAsking);
|
||||
},
|
||||
onYes: async () => {
|
||||
await this.register(contact);
|
||||
},
|
||||
promptToStopAsking: true,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error saving contact to database:", {
|
||||
did: contact.did,
|
||||
@@ -585,6 +621,74 @@ export default class ContactQRScanFull extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async register(contact: Contact) {
|
||||
logger.debug("Submitting contact registration", {
|
||||
did: contact.did,
|
||||
name: contact.name,
|
||||
});
|
||||
|
||||
try {
|
||||
const regResult = await register(
|
||||
this.activeDid,
|
||||
this.apiServer,
|
||||
this.axios,
|
||||
contact,
|
||||
);
|
||||
this.notify.toast("Submitted", NOTIFY_QR_REGISTRATION_SUBMITTED.message);
|
||||
if (regResult.success) {
|
||||
contact.registered = true;
|
||||
await this.$updateContact(contact.did, { registered: true });
|
||||
logger.debug("Contact registration successful", { did: contact.did });
|
||||
|
||||
this.notify.success(
|
||||
createQRRegistrationSuccessMessage(contact.name || ""),
|
||||
QR_TIMEOUT_LONG,
|
||||
);
|
||||
} else {
|
||||
this.notify.error(
|
||||
(regResult.error as string) || NOTIFY_QR_REGISTRATION_ERROR.message,
|
||||
QR_TIMEOUT_LONG,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error registering contact:", {
|
||||
did: contact.did,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
let userMessage = "There was an error.";
|
||||
const serverError = error as AxiosError;
|
||||
if (serverError) {
|
||||
if (
|
||||
serverError.response?.data &&
|
||||
typeof serverError.response.data === "object" &&
|
||||
"message" in serverError.response.data
|
||||
) {
|
||||
userMessage = (serverError.response.data as { message: string })
|
||||
.message;
|
||||
} else if (serverError.message) {
|
||||
userMessage = serverError.message;
|
||||
} else {
|
||||
userMessage = JSON.stringify(serverError.toJSON());
|
||||
}
|
||||
} else {
|
||||
userMessage = error as string;
|
||||
}
|
||||
this.notify.error(userMessage, QR_TIMEOUT_LONG);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRegistrationPromptResponse(
|
||||
stopAsking?: boolean,
|
||||
): Promise<void> {
|
||||
if (stopAsking) {
|
||||
await this.$saveSettings({
|
||||
hideRegisterPromptOnNewContact: stopAsking,
|
||||
});
|
||||
this.hideRegisterPromptOnNewContact = stopAsking;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue lifecycle hook - component mounted
|
||||
* Sets up event listeners and starts scanning automatically
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<template>
|
||||
<QuickNav />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<TopMessage />
|
||||
@@ -263,7 +265,6 @@ import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||
import {
|
||||
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
|
||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
|
||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR,
|
||||
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
|
||||
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
|
||||
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
|
||||
@@ -302,6 +303,7 @@ export default class GiftedDetails extends Vue {
|
||||
giverName = "";
|
||||
hideBackButton = false;
|
||||
imageUrl = "";
|
||||
imageUrlToDelete = "";
|
||||
message = "";
|
||||
offerId = "";
|
||||
prevCredToEdit?: GenericCredWrapper<GiveActionClaim>;
|
||||
@@ -517,7 +519,10 @@ export default class GiftedDetails extends Vue {
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.deleteImage(); // not awaiting, so they'll go back immediately
|
||||
// Only delete freshly uploaded images, not ones from an existing claim
|
||||
if (this.imageUrl && this.imageUrl !== this.prevCredToEdit?.claim?.image) {
|
||||
this.deleteImage(this.imageUrl); // not awaiting, so they'll go back immediately
|
||||
}
|
||||
if (this.destinationPathAfter) {
|
||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||
} else {
|
||||
@@ -526,7 +531,10 @@ export default class GiftedDetails extends Vue {
|
||||
}
|
||||
|
||||
cancelBack() {
|
||||
this.deleteImage(); // not awaiting, so they'll go back immediately
|
||||
// Only delete freshly uploaded images, not ones from an existing claim
|
||||
if (this.imageUrl && this.imageUrl !== this.prevCredToEdit?.claim?.image) {
|
||||
this.deleteImage(this.imageUrl); // not awaiting, so they'll go back immediately
|
||||
}
|
||||
(this.$router as Router).back();
|
||||
}
|
||||
|
||||
@@ -539,13 +547,18 @@ export default class GiftedDetails extends Vue {
|
||||
confirmDeleteImage() {
|
||||
this.notify.confirm(
|
||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM.message,
|
||||
this.deleteImage,
|
||||
() => {
|
||||
// Stage the image for deletion on submit rather than deleting immediately,
|
||||
// so that canceling the edit doesn't destroy the referenced image.
|
||||
this.imageUrlToDelete = this.imageUrl;
|
||||
this.imageUrl = "";
|
||||
},
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteImage() {
|
||||
if (!this.imageUrl) {
|
||||
async deleteImage(imageUrl: string) {
|
||||
if (!imageUrl) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -559,38 +572,21 @@ export default class GiftedDetails extends Vue {
|
||||
);
|
||||
}
|
||||
const response = await this.axios.delete(
|
||||
DEFAULT_IMAGE_API_SERVER +
|
||||
"/image/" +
|
||||
encodeURIComponent(this.imageUrl),
|
||||
DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(imageUrl),
|
||||
{ headers },
|
||||
);
|
||||
if (response.status === 204) {
|
||||
// don't bother with a notification
|
||||
// (either they'll simply continue or they're canceling and going back)
|
||||
} else {
|
||||
logger.error("Problem deleting image:", response);
|
||||
this.notify.error(
|
||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.imageUrl = "";
|
||||
} catch (error) {
|
||||
logger.error("Error deleting image:", error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any)?.response?.status === 404) {
|
||||
logger.log("Weird: the image was already deleted.", error);
|
||||
|
||||
this.imageUrl = "";
|
||||
|
||||
// it already doesn't exist so we won't say anything to the user
|
||||
logger.log("Image was already deleted:", error);
|
||||
} else {
|
||||
this.notify.error(
|
||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
logger.error("Failed to delete image from server:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -733,6 +729,12 @@ export default class GiftedDetails extends Vue {
|
||||
TIMEOUTS.LONG,
|
||||
);
|
||||
} else {
|
||||
// Delete the old image from storage now that the edit is saved
|
||||
if (this.imageUrlToDelete) {
|
||||
this.deleteImage(this.imageUrlToDelete); // not awaiting
|
||||
this.imageUrlToDelete = "";
|
||||
}
|
||||
|
||||
this.notify.success(
|
||||
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
|
||||
TIMEOUTS.SHORT,
|
||||
|
||||
144
src/views/HelpTermsView.vue
Normal file
144
src/views/HelpTermsView.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<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">
|
||||
Terms & Conditions and Privacy Policies
|
||||
</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>
|
||||
|
||||
<!-- Help button -->
|
||||
<router-link
|
||||
:to="{ name: 'help' }"
|
||||
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
||||
>
|
||||
<font-awesome icon="question" class="block text-center w-[1em]" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- eslint-disable prettier/prettier -->
|
||||
<div>
|
||||
<p style="display:inline; align-items: center">
|
||||
This work is public domain. (If you like rules, reference
|
||||
<a
|
||||
href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1"
|
||||
target="_blank"
|
||||
rel="license noopener noreferrer"
|
||||
>
|
||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
||||
<img
|
||||
src="../assets/help/creative-commons-circle.svg"
|
||||
alt="CC circle"
|
||||
width="20"
|
||||
class="display: inline"
|
||||
/>
|
||||
<img
|
||||
src="../assets/help/creative-commons-zero.svg"
|
||||
alt="CC zero"
|
||||
width="20"
|
||||
style="display: inline"
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
This is offered freely, with the hope that it helps but without any
|
||||
warranty or guarantee. When you share data or even look at information here,
|
||||
you accept the risk that goes with those activities. In other words,
|
||||
if you expect some functionality or you expect some protection, and you
|
||||
feel it is appropriate to force those expectations on the system or its
|
||||
operators or creators, then you are not allowed to use it.
|
||||
</p>
|
||||
<p class="mt-4">
|
||||
Here is how your data is used:
|
||||
</p>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
If sending images, a server stores them. They can be removed by editing
|
||||
each claim and deleting the image.
|
||||
</li>
|
||||
<li>
|
||||
If sending other partner system data (eg. to Trustroots) a public key
|
||||
and message data are stored on a server. Those can be removed via
|
||||
direct personal request (email
|
||||
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500">
|
||||
{{ SUPPORT_EMAIL }}
|
||||
</a>).
|
||||
</li>
|
||||
<li>
|
||||
For all other claim data,
|
||||
<a
|
||||
href="https://endorser.ch/privacy-policy"
|
||||
target="_blank"
|
||||
class="text-blue-500"
|
||||
>
|
||||
the Endorser Service has this Privacy Policy.
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4">
|
||||
<!--
|
||||
This section is for Twilio's A2P Campaign requirements.
|
||||
They say: Ensure it includes the program name, description, message/data rates, message frequency, support contact info, and opt-out instructions (HELP and STOP in bold).
|
||||
They link here for a sample: https://help.twilio.com/articles/223134847-Industry-standards-for-US-Short-Code-Terms-of-Service
|
||||
-->
|
||||
Here are the details for SMS notifications:
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>You may opt to receive SMS messages for two purposes:
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>A daily reminder message</li>
|
||||
<li>A notification of new activity for items that you are watching</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Before enabling these notifications, you must register your phone number and give permission to use it for searches.
|
||||
</li>
|
||||
<li>
|
||||
Once your phone number is registered and linked to your DID, you can enable or disable either kind of SMS message.
|
||||
You can disable these any time with the same toggle.
|
||||
</li>
|
||||
<li>
|
||||
If you lose your credentials, you can register your phone with a different DID.
|
||||
Then you can enable and disable notifications for your phone.
|
||||
</li>
|
||||
<li>
|
||||
Carriers are not liable for delayed or undelivered messages.
|
||||
</li>
|
||||
<li>
|
||||
As always, message and data rates may apply for any messages sent to you from us and to us from you.
|
||||
You will receive at most one of each kind of message per day.
|
||||
If you have any questions about your text plan or data plan, it is best to contact your wireless provider.
|
||||
</li>
|
||||
<li>
|
||||
Our servers will only store your phone number and the type of notifications you have enabled,
|
||||
along with the explicit signed permission to use it for searches.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint-enable -->
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import { SUPPORT_EMAIL } from "../constants/app";
|
||||
|
||||
@Component({ components: { QuickNav } })
|
||||
export default class HelpTermsView extends Vue {
|
||||
SUPPORT_EMAIL = SUPPORT_EMAIL;
|
||||
}
|
||||
</script>
|
||||
@@ -480,46 +480,14 @@
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
||||
<p style="display:inline; align-items: center">
|
||||
This work is public domain. (If you like rules, reference
|
||||
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
|
||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
||||
<img
|
||||
src="../assets/help/creative-commons-circle.svg"
|
||||
alt="CC circle"
|
||||
width="20"
|
||||
class="display: inline"
|
||||
/>
|
||||
<img
|
||||
src="../assets/help/creative-commons-zero.svg"
|
||||
alt="CC zero"
|
||||
width="20"
|
||||
style="display: inline"
|
||||
/>
|
||||
</a>
|
||||
.) This is offered freely, with the hope that it helps but without any warranty or guarantee;
|
||||
if it helps you then enjoy using it,
|
||||
but if you may try to forcibly collect damages for things you think it should do (or not do)
|
||||
then don't use it.
|
||||
<br />
|
||||
As for data & privacy:
|
||||
<p>
|
||||
<router-link
|
||||
class="text-blue-500"
|
||||
:to="{ name: 'help-terms' }"
|
||||
>
|
||||
Read them here.
|
||||
</router-link>
|
||||
</p>
|
||||
<ul class="list-disc list-outside ml-4">
|
||||
<li>
|
||||
If sending images, a server stores them. They can be removed by editing each claim
|
||||
and deleting the image.
|
||||
</li>
|
||||
<li>
|
||||
If sending other partner system data (eg. to Trustroots) a public key and message
|
||||
data are stored on a server. Those can be removed via direct personal request (via contact below).
|
||||
</li>
|
||||
<li>
|
||||
For all other claim data,
|
||||
<a href="https://endorser.ch/privacy-policy" target="_blank" class="text-blue-500">
|
||||
the Endorser Service has this Privacy Policy.
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold">How can I contribute?</h2>
|
||||
<p>
|
||||
|
||||
@@ -109,12 +109,14 @@ Raymer * @version 1.0.0 */
|
||||
|
||||
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
|
||||
<!-- Record Quick-Action -->
|
||||
<div class="mb-6">
|
||||
<div class="flex gap-2 items-center mb-2">
|
||||
<div
|
||||
class="bg-slate-100 border border-slate-300 rounded-md px-4 py-2.5 mb-4"
|
||||
>
|
||||
<div class="flex gap-2 justify-between items-center ps-8">
|
||||
<!-- Thank button - always visible and unchanged -->
|
||||
<button
|
||||
type="button"
|
||||
class="text-center text-base uppercase bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 px-4 py-3 rounded-full"
|
||||
class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg"
|
||||
@click="openPersonDialog()"
|
||||
>
|
||||
<font-awesome icon="plus" />
|
||||
@@ -122,25 +124,29 @@ Raymer * @version 1.0.0 */
|
||||
</button>
|
||||
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
|
||||
<transition
|
||||
enter-active-class="transition-all duration-1000 ease-out"
|
||||
leave-active-class="transition-all duration-1000 ease-in"
|
||||
enter-from-class="scale-0"
|
||||
enter-to-class="scale-100"
|
||||
leave-from-class="scale-100"
|
||||
leave-to-class="scale-0"
|
||||
enter-active-class="transition-all duration-500 ease-out"
|
||||
leave-active-class="transition-all duration-500 ease-in"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<button
|
||||
<div
|
||||
v-if="isScrolled"
|
||||
type="button"
|
||||
class="fixed bottom-10 p-4 w-14 h-14 z-50 text-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-full flex items-center justify-center"
|
||||
:style="getButtonPosition()"
|
||||
@click="openPersonDialog()"
|
||||
class="bg-gradient-to-t from-white to-transparent fixed inset-x-0 bottom-[calc(4.75rem+max(env(safe-area-inset-bottom),var(--safe-area-inset-bottom,0px)))] px-4 pb-3 w-full z-[49]"
|
||||
>
|
||||
<font-awesome icon="plus" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-center text-xl uppercase font-bold bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 mx-auto px-6 py-2 rounded-lg drop-shadow-[0_0_10px_rgba(255,255,255,1)]"
|
||||
@click="openPersonDialog()"
|
||||
>
|
||||
<font-awesome icon="plus" />
|
||||
<span>Thank</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
<button
|
||||
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
||||
class="block text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
||||
@click="openGiftedPrompts()"
|
||||
>
|
||||
<font-awesome
|
||||
@@ -559,7 +565,7 @@ export default class HomeView extends Vue {
|
||||
const scrollTop = appElement
|
||||
? appElement.scrollTop
|
||||
: window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||
const shouldBeScrolled = scrollTop > 100;
|
||||
const shouldBeScrolled = scrollTop > 120;
|
||||
if (this.isScrolled !== shouldBeScrolled) {
|
||||
this.isScrolled = shouldBeScrolled;
|
||||
}
|
||||
|
||||
@@ -366,7 +366,8 @@
|
||||
Do Not Pair Together
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 mb-2">
|
||||
People in the same group will not be matched with each other.
|
||||
People in the same do-not-pair group will not be matched with each
|
||||
other.
|
||||
</p>
|
||||
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
|
||||
Erase matches to change restrictions.
|
||||
|
||||
@@ -223,7 +223,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
);
|
||||
this.notify.error(
|
||||
timeResult?.error || NOTIFY_BVC_TIME_ERROR.message,
|
||||
TIMEOUTS.LONG,
|
||||
timeResult?.error ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
);
|
||||
this.notify.error(
|
||||
attendResult?.error || NOTIFY_BVC_ATTENDANCE_ERROR.message,
|
||||
TIMEOUTS.LONG,
|
||||
attendResult?.error ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
||||
logger.error("[QuickActionBvcBeginView] Error sending claims:", error);
|
||||
this.notify.error(
|
||||
error.userMessage || NOTIFY_BVC_SUBMISSION_ERROR.message,
|
||||
TIMEOUTS.LONG,
|
||||
error.userMessage ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
187
src/views/ShareTargetDebugView.vue
Normal file
187
src/views/ShareTargetDebugView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -130,11 +130,13 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
|
||||
|
||||
// Refresh home view and check gift
|
||||
await page.goto('./');
|
||||
// Wait for async data (e.g. "new offers" banner) to finish loading so the layout
|
||||
// is stable before clicking — otherwise a layout shift can redirect the click.
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Firefox complains on load the initial feed here when we use the test server.
|
||||
// It may be similar to the CORS problem below.
|
||||
const item = await page.locator('li:first-child').filter({ hasText: finalTitle });
|
||||
await item.locator('[data-testid="circle-info-link"]').click();
|
||||
const item = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
|
||||
await expect(item).toBeVisible();
|
||||
await item.click();
|
||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user