Compare commits
50 Commits
notify-api
...
4fc30562fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
| 05d346edce | |||
| e259e60fa7 | |||
| 821de3f006 | |||
| 43f83031d4 | |||
| 688a48a332 | |||
| 8938c242ee | |||
| 358af42afd | |||
| 59c00241b8 | |||
| 33ec90e571 |
@@ -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
|
- If there are DB changes: before updating the test server, open browser(s) with
|
||||||
current version to test DB migrations.
|
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`.
|
`npm install`.
|
||||||
|
|
||||||
- Run a build to make sure package-lock version is updated, linting works, etc:
|
- Run a build to make sure linting works, etc:
|
||||||
`npm install && npm run build:web`
|
`npm run build:web`
|
||||||
|
|
||||||
- Commit everything (since the commit hash is used the app).
|
- 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,
|
- Tag with the new version,
|
||||||
[online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or
|
[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:
|
- 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:
|
##### 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
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 65 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.3.8;/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.
|
# Unfortunately this edits Info.plist directly.
|
||||||
#xcrun agvtool new-marketing-version 0.4.5
|
#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:
|
##### 1. Bump the version in package.json, then update these versions & run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 65/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.3.8"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.4.3"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -6,9 +6,31 @@ 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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
## [1.3.8] - 2026
|
## [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
|
### Added
|
||||||
- Device wake-up for notifications
|
- Device wake-up for notifications
|
||||||
|
### Changed
|
||||||
|
- Rename to "Gifties"
|
||||||
|
|
||||||
|
|
||||||
## [1.3.7]
|
## [1.3.7]
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -15,10 +15,31 @@ Quick start:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web
|
||||||
|
|
||||||
|
```bash
|
||||||
npm run build:web:dev
|
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).
|
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
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "app.timesafari.app"
|
applicationId "app.timesafari.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 65
|
versionCode 69
|
||||||
versionName "1.3.8"
|
versionName "1.4.3"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -72,13 +72,14 @@ android {
|
|||||||
}
|
}
|
||||||
packagingOptions {
|
packagingOptions {
|
||||||
jniLibs {
|
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']
|
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)
|
// Enable bundle builds (without which it doesn't work right for bundleDebug vs bundleRelease)
|
||||||
bundle {
|
bundle {
|
||||||
language {
|
language {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"appName": "TimeSafari",
|
"appName": "Giftopia",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"server": {
|
"server": {
|
||||||
"cleartext": true
|
"cleartext": true
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"androidIsEncryption": false,
|
"androidIsEncryption": false,
|
||||||
"androidBiometric": {
|
"androidBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"electronIsEncryption": false
|
"electronIsEncryption": false
|
||||||
},
|
},
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
},
|
},
|
||||||
"buildOptions": {
|
"buildOptions": {
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"productName": "TimeSafari",
|
"productName": "Giftopia",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron-packages"
|
"output": "dist-electron-packages"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.timesafari.dailynotification.FetchContext;
|
|||||||
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
import org.timesafari.dailynotification.NativeNotificationContentFetcher;
|
||||||
import org.timesafari.dailynotification.NotificationContent;
|
import org.timesafari.dailynotification.NotificationContent;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
@@ -47,22 +46,11 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
|
|||||||
// This should query the TimeSafari API for notification content
|
// This should query the TimeSafari API for notification content
|
||||||
// using the configured apiBaseUrl, activeDid, and jwtToken
|
// using the configured apiBaseUrl, activeDid, and jwtToken
|
||||||
|
|
||||||
// For now, return a placeholder notification
|
// Not implemented yet: return no content rather than fabricating a
|
||||||
long scheduledTime = fetchContext.scheduledTime != null
|
// placeholder notification (previously hardcoded "Check your starred
|
||||||
? fetchContext.scheduledTime
|
// projects for updates!", which showed on every app startup).
|
||||||
: System.currentTimeMillis() + 60000; // 1 minute from now
|
Log.d(TAG, "Content fetching not yet implemented; returning no notifications");
|
||||||
|
return Collections.<NotificationContent>emptyList();
|
||||||
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;
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Fetch failed", e);
|
Log.e(TAG, "Fetch failed", e);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">TimeSafari</string>
|
<string name="app_name">Giftopia</string>
|
||||||
<string name="title_activity_main">TimeSafari</string>
|
<string name="title_activity_main">Giftopia</string>
|
||||||
<string name="package_name">timesafari.app</string>
|
<string name="package_name">timesafari.app</string>
|
||||||
<string name="custom_url_scheme">timesafari.app</string>
|
<string name="custom_url_scheme">timesafari.app</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
ext {
|
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'
|
cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '10.1.1'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
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 {
|
android {
|
||||||
namespace "capacitor.cordova.android.plugins"
|
namespace "capacitor.cordova.android.plugins"
|
||||||
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
|
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 22
|
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
||||||
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
|
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
abortOnError false
|
abortOnError false
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
ext {
|
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.
|
// Plugin gradle extensions can append to this to have code run at the end.
|
||||||
cdvPluginPostBuildExtras = []
|
cdvPluginPostBuildExtras = []
|
||||||
cordovaConfig = [:]
|
cordovaConfig = [:]
|
||||||
|
|||||||
@@ -13,4 +13,11 @@ ext {
|
|||||||
androidxJunitVersion = '1.1.5'
|
androidxJunitVersion = '1.1.5'
|
||||||
androidxEspressoCoreVersion = '3.5.1'
|
androidxEspressoCoreVersion = '3.5.1'
|
||||||
cordovaAndroidVersion = '10.1.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'
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"appName": "TimeSafari",
|
"appName": "Giftopia",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"server": {
|
"server": {
|
||||||
"cleartext": true
|
"cleartext": true
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"androidIsEncryption": false,
|
"androidIsEncryption": false,
|
||||||
"androidBiometric": {
|
"androidBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"electronIsEncryption": false
|
"electronIsEncryption": false
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
},
|
},
|
||||||
"buildOptions": {
|
"buildOptions": {
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"productName": "TimeSafari",
|
"productName": "Giftopia",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron-packages"
|
"output": "dist-electron-packages"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { CapacitorConfig } from '@capacitor/cli';
|
|||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'app.timesafari',
|
appId: 'app.timesafari',
|
||||||
appName: 'TimeSafari',
|
appName: 'Giftopia',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
server: {
|
server: {
|
||||||
cleartext: true
|
cleartext: true
|
||||||
@@ -36,12 +36,12 @@ const config: CapacitorConfig = {
|
|||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
biometricTitle: 'Biometric login for TimeSafari'
|
biometricTitle: 'Biometric login for Giftopia'
|
||||||
},
|
},
|
||||||
androidIsEncryption: false,
|
androidIsEncryption: false,
|
||||||
androidBiometric: {
|
androidBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
biometricTitle: 'Biometric login for TimeSafari'
|
biometricTitle: 'Biometric login for Giftopia'
|
||||||
},
|
},
|
||||||
electronIsEncryption: false
|
electronIsEncryption: false
|
||||||
},
|
},
|
||||||
@@ -100,7 +100,7 @@ const config: CapacitorConfig = {
|
|||||||
},
|
},
|
||||||
buildOptions: {
|
buildOptions: {
|
||||||
appId: 'app.timesafari',
|
appId: 'app.timesafari',
|
||||||
productName: 'TimeSafari',
|
productName: 'Giftopia',
|
||||||
directories: {
|
directories: {
|
||||||
output: 'dist-electron-packages'
|
output: 'dist-electron-packages'
|
||||||
},
|
},
|
||||||
|
|||||||
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.
|
||||||
449
doc/share-target-ios-audit.md
Normal file
449
doc/share-target-ios-audit.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration References
|
||||||
|
|
||||||
|
| Resource | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| App Group ID | `group.app.timesafari.share` |
|
||||||
|
| URL scheme | `timesafari://` |
|
||||||
|
| Extension bundle ID | `app.timesafari.TimeSafariShareExtension` |
|
||||||
|
| Main app bundle ID | `app.timesafari` |
|
||||||
|
| Capacitor plugin name | `SharedImage` |
|
||||||
|
| Temp DB key | `shared-photo-base64` (`SHARED_PHOTO_BASE64_KEY`) |
|
||||||
|
| Route | `/shared-photo` |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"appName": "TimeSafari",
|
"appName": "Giftopia",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"server": {
|
"server": {
|
||||||
"cleartext": true
|
"cleartext": true
|
||||||
@@ -34,12 +34,12 @@
|
|||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"androidIsEncryption": false,
|
"androidIsEncryption": false,
|
||||||
"androidBiometric": {
|
"androidBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
"biometricTitle": "Biometric login for TimeSafari"
|
"biometricTitle": "Biometric login for Giftopia"
|
||||||
},
|
},
|
||||||
"electronIsEncryption": false
|
"electronIsEncryption": false
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"buildOptions": {
|
"buildOptions": {
|
||||||
"appId": "app.timesafari",
|
"appId": "app.timesafari",
|
||||||
"productName": "TimeSafari",
|
"productName": "Giftopia",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron-packages"
|
"output": "dist-electron-packages"
|
||||||
},
|
},
|
||||||
|
|||||||
3
ios/.gitignore
vendored
3
ios/.gitignore
vendored
@@ -17,6 +17,7 @@ App/App/config.xml
|
|||||||
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
|
||||||
App/App.xcodeproj/*.xcuserstate
|
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/AppIcon.appiconset
|
||||||
App/App/Assets.xcassets/Splash.imageset
|
App/App/Assets.xcassets/Splash.imageset
|
||||||
|
|||||||
@@ -452,7 +452,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
@@ -524,17 +524,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 65;
|
CURRENT_PROJECT_VERSION = 69;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.8;
|
MARKETING_VERSION = 1.4.3;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -552,17 +553,18 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 65;
|
CURRENT_PROJECT_VERSION = 69;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.5;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.3.8;
|
MARKETING_VERSION = 1.4.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -580,12 +582,12 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 65;
|
CURRENT_PROJECT_VERSION = 69;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -594,7 +596,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.3.8;
|
MARKETING_VERSION = 1.4.3;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
@@ -618,12 +620,12 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 65;
|
CURRENT_PROJECT_VERSION = 69;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
|
INFOPLIST_KEY_CFBundleDisplayName = Giftopia;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -632,7 +634,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.3.8;
|
MARKETING_VERSION = 1.4.3;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>TimeSafari</string>
|
<string>Giftopia</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
public var pluginMethods: [CAPPluginMethod] {
|
public var pluginMethods: [CAPPluginMethod] {
|
||||||
return [
|
return [
|
||||||
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
|
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
|
||||||
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise)
|
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise),
|
||||||
|
CAPPluginMethod(#selector(getShareExtensionDiagnostics(_:)), returnType: .promise)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
/**
|
/**
|
||||||
* Get shared image data from App Group UserDefaults
|
* Get shared image data from App Group UserDefaults
|
||||||
* Returns base64 string and fileName, or null if no image exists
|
* 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) {
|
@objc public func getSharedImage(_ call: CAPPluginCall) {
|
||||||
guard let sharedData = SharedImageUtility.getSharedImageData() else {
|
guard let sharedData = SharedImageUtility.getSharedImageData() else {
|
||||||
@@ -62,5 +63,12 @@ public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
"hasImage": hasImage
|
"hasImage": hasImage
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic snapshot of Share Extension startup and pending share state
|
||||||
|
*/
|
||||||
|
@objc public func getShareExtensionDiagnostics(_ call: CAPPluginCall) {
|
||||||
|
call.resolve(SharedImageUtility.getShareExtensionDiagnostics())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public class SharedImageUtility {
|
|||||||
private static let appGroupIdentifier = "group.app.timesafari.share"
|
private static let appGroupIdentifier = "group.app.timesafari.share"
|
||||||
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||||
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||||
|
private static let sharedPhotoShareIdKey = "sharedPhotoShareId"
|
||||||
|
private static let shareExtensionLastStartKey = "shareExtensionLastStart"
|
||||||
private static let sharedPhotoReadyKey = "sharedPhotoReady"
|
private static let sharedPhotoReadyKey = "sharedPhotoReady"
|
||||||
|
|
||||||
/// Get the App Group container URL for accessing shared files
|
/// Get the App Group container URL for accessing shared files
|
||||||
@@ -20,15 +22,35 @@ public class SharedImageUtility {
|
|||||||
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
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
|
* Get shared image data from App Group container file
|
||||||
* All images are stored as files for consistency and to avoid UserDefaults size limits
|
* 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
|
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
|
||||||
*/
|
*/
|
||||||
static func getSharedImageData() -> [String: String]? {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +61,7 @@ public class SharedImageUtility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
|
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
|
||||||
|
let shareId = userDefaults.string(forKey: sharedPhotoShareIdKey)
|
||||||
let fileURL = containerURL.appendingPathComponent(filePath)
|
let fileURL = containerURL.appendingPathComponent(filePath)
|
||||||
|
|
||||||
// Read image data from file
|
// Read image data from file
|
||||||
@@ -46,18 +69,13 @@ public class SharedImageUtility {
|
|||||||
return nil
|
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
|
// Convert file data to base64 for JavaScript consumption
|
||||||
let base64String = imageData.base64EncodedString()
|
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]
|
return ["base64": base64String, "fileName": fileName]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +85,10 @@ public class SharedImageUtility {
|
|||||||
* @returns true if shared image file exists, false otherwise
|
* @returns true if shared image file exists, false otherwise
|
||||||
*/
|
*/
|
||||||
static func hasSharedImage() -> Bool {
|
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 filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
|
||||||
let containerURL = appGroupContainerURL else {
|
let containerURL = appGroupContainerURL else {
|
||||||
return false
|
return false
|
||||||
@@ -77,6 +98,41 @@ public class SharedImageUtility {
|
|||||||
return FileManager.default.fileExists(atPath: fileURL.path)
|
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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ShareTarget] getShareExtensionDiagnostics shareExtensionLastStart=\(shareExtensionLastStart ?? "nil") sharedPhotoShareId=\(shareId ?? "nil") sharedPhotoFilePath=\(filePath ?? "nil") fileExists=\(fileExists)")
|
||||||
|
|
||||||
|
return [
|
||||||
|
"shareExtensionLastStart": shareExtensionLastStart ?? NSNull(),
|
||||||
|
"sharedPhotoShareId": shareId ?? NSNull(),
|
||||||
|
"sharedPhotoFilePath": filePath ?? NSNull(),
|
||||||
|
"fileExists": fileExists
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if shared photo ready flag is set
|
* Check if shared photo ready flag is set
|
||||||
* This flag is set by the Share Extension when image is ready
|
* 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'
|
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||||
|
|
||||||
platform :ios, '13.0'
|
platform :ios, '15.5'
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
|
|
||||||
# workaround to avoid Xcode caching of Pods that requires
|
# workaround to avoid Xcode caching of Pods that requires
|
||||||
@@ -30,9 +30,4 @@ end
|
|||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
assertDeploymentTarget(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
|
end
|
||||||
|
|||||||
@@ -1,94 +1,84 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Capacitor (6.2.1):
|
- Capacitor (7.6.4):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorApp (6.0.2):
|
- CapacitorApp (7.1.2):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorCamera (6.1.2):
|
- CapacitorCamera (7.0.5):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorClipboard (6.0.2):
|
- CapacitorClipboard (7.0.4):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorCommunitySqlite (6.0.2):
|
- CapacitorCommunitySqlite (7.0.3):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- SQLCipher
|
- SQLCipher
|
||||||
- ZIPFoundation
|
- ZIPFoundation
|
||||||
- CapacitorCordova (6.2.1)
|
- CapacitorCordova (7.6.4)
|
||||||
- CapacitorFilesystem (6.0.3):
|
- CapacitorFilesystem (7.1.8):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorMlkitBarcodeScanning (6.2.0):
|
- IONFilesystemLib (~> 1.1.1)
|
||||||
|
- CapacitorMlkitBarcodeScanning (7.5.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- GoogleMLKit/BarcodeScanning (= 5.0.0)
|
- GoogleMLKit/BarcodeScanning (= 7.0.0)
|
||||||
- CapacitorShare (6.0.3):
|
- CapacitorShare (7.0.4):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorStatusBar (6.0.2):
|
- CapacitorStatusBar (7.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapawesomeCapacitorFilePicker (6.2.0):
|
- CapawesomeCapacitorFilePicker (7.2.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- GoogleDataTransport (9.4.1):
|
- GoogleDataTransport (10.1.0):
|
||||||
- GoogleUtilities/Environment (~> 7.7)
|
- nanopb (~> 3.30910.0)
|
||||||
- nanopb (< 2.30911.0, >= 2.30908.0)
|
- PromisesObjC (~> 2.4)
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||||
- GoogleMLKit/BarcodeScanning (5.0.0):
|
|
||||||
- GoogleMLKit/MLKitCore
|
- GoogleMLKit/MLKitCore
|
||||||
- MLKitBarcodeScanning (~> 4.0.0)
|
- MLKitBarcodeScanning (~> 6.0.0)
|
||||||
- GoogleMLKit/MLKitCore (5.0.0):
|
- GoogleMLKit/MLKitCore (7.0.0):
|
||||||
- MLKitCommon (~> 10.0.0)
|
- MLKitCommon (~> 12.0.0)
|
||||||
- GoogleToolboxForMac/DebugUtils (2.3.2):
|
- GoogleToolboxForMac/Defines (4.2.1)
|
||||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
- GoogleToolboxForMac/Logger (4.2.1):
|
||||||
- GoogleToolboxForMac/Defines (2.3.2)
|
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||||
- GoogleToolboxForMac/Logger (2.3.2):
|
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||||
- GoogleToolboxForMac/Defines (= 2.3.2)
|
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||||
- "GoogleToolboxForMac/NSData+zlib (2.3.2)":
|
- GoogleUtilities/Environment (8.1.0):
|
||||||
- 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):
|
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- PromisesObjC (< 3.0, >= 1.2)
|
- GoogleUtilities/Logger (8.1.0):
|
||||||
- GoogleUtilities/Logger (7.13.3):
|
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Privacy (7.13.3)
|
- GoogleUtilities/Privacy (8.1.0)
|
||||||
- GoogleUtilities/UserDefaults (7.13.3):
|
- GoogleUtilities/UserDefaults (8.1.0):
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilitiesComponents (1.1.0):
|
|
||||||
- GoogleUtilities/Logger
|
|
||||||
- GTMSessionFetcher/Core (3.5.0)
|
- GTMSessionFetcher/Core (3.5.0)
|
||||||
- MLImage (1.0.0-beta5)
|
- IONFilesystemLib (1.1.2)
|
||||||
- MLKitBarcodeScanning (4.0.0):
|
- MLImage (1.0.0-beta6)
|
||||||
- MLKitCommon (~> 10.0)
|
- MLKitBarcodeScanning (6.0.0):
|
||||||
- MLKitVision (~> 6.0)
|
- MLKitCommon (~> 12.0)
|
||||||
- MLKitCommon (10.0.0):
|
- MLKitVision (~> 8.0)
|
||||||
- GoogleDataTransport (~> 9.0)
|
- MLKitCommon (12.0.0):
|
||||||
- GoogleToolboxForMac/Logger (~> 2.1)
|
- GoogleDataTransport (~> 10.0)
|
||||||
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
|
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||||
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
|
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||||
- GoogleUtilities/UserDefaults (~> 7.0)
|
- GoogleUtilities/Logger (~> 8.0)
|
||||||
- GoogleUtilitiesComponents (~> 1.0)
|
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||||
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
|
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||||
- MLKitVision (6.0.0):
|
- MLKitVision (8.0.0):
|
||||||
- GoogleToolboxForMac/Logger (~> 2.1)
|
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||||
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
|
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||||
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
|
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||||
- MLImage (= 1.0.0-beta5)
|
- MLImage (= 1.0.0-beta6)
|
||||||
- MLKitCommon (~> 10.0)
|
- MLKitCommon (~> 12.0)
|
||||||
- nanopb (2.30910.0):
|
- nanopb (3.30910.0):
|
||||||
- nanopb/decode (= 2.30910.0)
|
- nanopb/decode (= 3.30910.0)
|
||||||
- nanopb/encode (= 2.30910.0)
|
- nanopb/encode (= 3.30910.0)
|
||||||
- nanopb/decode (2.30910.0)
|
- nanopb/decode (3.30910.0)
|
||||||
- nanopb/encode (2.30910.0)
|
- nanopb/encode (3.30910.0)
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
- SQLCipher (4.9.0):
|
- SQLCipher (4.10.0):
|
||||||
- SQLCipher/standard (= 4.9.0)
|
- SQLCipher/standard (= 4.10.0)
|
||||||
- SQLCipher/common (4.9.0)
|
- SQLCipher/common (4.10.0)
|
||||||
- SQLCipher/standard (4.9.0):
|
- SQLCipher/standard (4.10.0):
|
||||||
- SQLCipher/common
|
- SQLCipher/common
|
||||||
- TimesafariDailyNotificationPlugin (2.0.0):
|
- TimesafariDailyNotificationPlugin (4.0.1):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- ZIPFoundation (0.9.19)
|
- ZIPFoundation (0.9.20)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||||
@@ -110,8 +100,8 @@ SPEC REPOS:
|
|||||||
- GoogleMLKit
|
- GoogleMLKit
|
||||||
- GoogleToolboxForMac
|
- GoogleToolboxForMac
|
||||||
- GoogleUtilities
|
- GoogleUtilities
|
||||||
- GoogleUtilitiesComponents
|
|
||||||
- GTMSessionFetcher
|
- GTMSessionFetcher
|
||||||
|
- IONFilesystemLib
|
||||||
- MLImage
|
- MLImage
|
||||||
- MLKitBarcodeScanning
|
- MLKitBarcodeScanning
|
||||||
- MLKitCommon
|
- MLKitCommon
|
||||||
@@ -148,33 +138,33 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../node_modules/@timesafari/daily-notification-plugin"
|
:path: "../../node_modules/@timesafari/daily-notification-plugin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
|
Capacitor: 69dc07ebc6bd064747c5e76922f97e4862d9cc23
|
||||||
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
|
CapacitorApp: f01a913211780e0718dae9750442c3e23f96e106
|
||||||
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
|
CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
|
||||||
CapacitorClipboard: 4443c3cdb7c77b1533dfe3ff0f9f7756aa8579df
|
CapacitorClipboard: d1f123674cf413125db816a45e8f70e8770972fc
|
||||||
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b
|
CapacitorCommunitySqlite: 4813d82ad33001e612a39d313cb5d28066cbafda
|
||||||
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
|
CapacitorCordova: e343e95a672ff73e21a77a80257b52fb609b47d5
|
||||||
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
|
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
|
||||||
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
|
CapacitorMlkitBarcodeScanning: afd6fc431b550026a2c052e11ab2b71c7ae30011
|
||||||
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
|
CapacitorShare: 25f7fc5dd0e4edbde5d6801c6de5d14a8b450a41
|
||||||
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
|
CapacitorStatusBar: 416e9e53fd6397e668d4a181cd2131617d949bd6
|
||||||
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
|
CapawesomeCapacitorFilePicker: 0f4a913a00e39dd77213449f0d917e92f35a5ca9
|
||||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||||
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
|
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
|
||||||
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
|
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||||
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
|
||||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||||
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
|
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
|
||||||
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e
|
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||||
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2
|
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||||
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
|
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||||
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
|
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||||
TimesafariDailyNotificationPlugin: 3c12e8c39fc27f689f56cf4e57230a8c28611fcc
|
TimesafariDailyNotificationPlugin: 69277c884380a9a620f671b68e0327eaa4b3d27d
|
||||||
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: 6d92bfa46c6c2d31d19b8c0c38f56a8ae9fd222f
|
PODFILE CHECKSUM: 87c07d03f36ef38ab0c873802aee1ce9b5d34448
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ class ShareViewController: UIViewController {
|
|||||||
private let appGroupIdentifier = "group.app.timesafari.share"
|
private let appGroupIdentifier = "group.app.timesafari.share"
|
||||||
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
|
||||||
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
|
||||||
|
private let sharedPhotoShareIdKey = "sharedPhotoShareId"
|
||||||
|
private let shareExtensionLastStartKey = "shareExtensionLastStart"
|
||||||
private let sharedImageFileName = "shared-image"
|
private let sharedImageFileName = "shared-image"
|
||||||
|
|
||||||
/// Get the App Group container URL for storing shared files
|
/// Get the App Group container URL for storing shared files
|
||||||
@@ -21,6 +23,14 @@ class ShareViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
|
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()
|
super.viewDidLoad()
|
||||||
|
|
||||||
// Set a minimal background (transparent or loading indicator)
|
// Set a minimal background (transparent or loading indicator)
|
||||||
@@ -28,18 +38,32 @@ class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
// Process image immediately without showing UI
|
// Process image immediately without showing UI
|
||||||
processAndOpenApp()
|
processAndOpenApp()
|
||||||
|
print("[ShareTarget] viewDidLoad completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processAndOpenApp() {
|
private func processAndOpenApp() {
|
||||||
|
print("[ShareTarget] processAndOpenApp started")
|
||||||
|
|
||||||
// extensionContext is automatically available on UIViewController when used as extension principal class
|
// extensionContext is automatically available on UIViewController when used as extension principal class
|
||||||
guard let context = extensionContext,
|
guard let context = extensionContext,
|
||||||
let inputItems = context.inputItems as? [NSExtensionItem] else {
|
let inputItems = context.inputItems as? [NSExtensionItem] else {
|
||||||
|
print("[ShareTarget] processAndOpenApp failed: missing extensionContext or inputItems")
|
||||||
|
print("[ShareTarget] completeRequest starting")
|
||||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
|
print("[ShareTarget] completeRequest completed")
|
||||||
|
print("[ShareTarget] processAndOpenApp completed")
|
||||||
return
|
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
|
processSharedImage(from: inputItems) { [weak self] success in
|
||||||
guard let self = self, let context = self.extensionContext else {
|
guard let self = self, let context = self.extensionContext else {
|
||||||
|
print("[ShareTarget] processAndOpenApp failed: self or extensionContext unavailable in completion")
|
||||||
|
print("[ShareTarget] processAndOpenApp completed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,93 +72,134 @@ class ShareViewController: UIViewController {
|
|||||||
self.setSharedPhotoReadyFlag()
|
self.setSharedPhotoReadyFlag()
|
||||||
// Open the main app (using minimal URL - app will detect shared data on activation)
|
// Open the main app (using minimal URL - app will detect shared data on activation)
|
||||||
self.openMainApp()
|
self.openMainApp()
|
||||||
|
} else {
|
||||||
|
print("[ShareTarget] processAndOpenApp failed: processSharedImage returned false")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete immediately - no UI shown
|
// Complete immediately - no UI shown
|
||||||
|
print("[ShareTarget] completeRequest starting")
|
||||||
context.completeRequest(returningItems: [], completionHandler: nil)
|
context.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
|
print("[ShareTarget] completeRequest completed")
|
||||||
|
print("[ShareTarget] processAndOpenApp completed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setSharedPhotoReadyFlag() {
|
private func setSharedPhotoReadyFlag() {
|
||||||
|
print("[ShareTarget] setSharedPhotoReadyFlag started")
|
||||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||||
|
print("[ShareTarget] setSharedPhotoReadyFlag failed: UserDefaults unavailable for app group")
|
||||||
|
print("[ShareTarget] setSharedPhotoReadyFlag completed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userDefaults.set(true, forKey: "sharedPhotoReady")
|
userDefaults.set(true, forKey: "sharedPhotoReady")
|
||||||
userDefaults.synchronize()
|
userDefaults.synchronize()
|
||||||
|
print("[ShareTarget] setSharedPhotoReadyFlag success")
|
||||||
|
print("[ShareTarget] setSharedPhotoReadyFlag completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
|
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)")
|
||||||
|
|
||||||
// Find the first image attachment
|
// Find the first image attachment
|
||||||
for item in items {
|
for item in items {
|
||||||
guard let attachments = item.attachments else {
|
guard let attachments = item.attachments else {
|
||||||
|
print("[ShareTarget] processSharedImage skipping item with no attachments")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for attachment in attachments {
|
for attachment in attachments {
|
||||||
// Skip non-image attachments
|
// Skip non-image attachments
|
||||||
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
|
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
|
||||||
|
print("[ShareTarget] processSharedImage skipping non-image attachment")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shareId = UUID().uuidString
|
||||||
|
print("[ShareTarget] processSharedImage found image attachment shareId=\(shareId) UTType=\(UTType.image.identifier)")
|
||||||
|
print("[ShareTarget] share received shareId=\(shareId)")
|
||||||
|
|
||||||
// Try to load raw data first to preserve original format
|
// Try to load raw data first to preserve original format
|
||||||
// This preserves the original image format without conversion
|
// This preserves the original image format without conversion
|
||||||
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
|
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
|
||||||
guard let self = self else {
|
guard let self = self else {
|
||||||
completion(false)
|
print("[ShareTarget] processSharedImage failed: self unavailable in loadItem callback shareId=\(shareId)")
|
||||||
return
|
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||||
}
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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")
|
||||||
|
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")
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[ShareTarget] image loaded bytes=\(finalImageData.count) filename=\(fileName) shareId=\(shareId)")
|
||||||
|
|
||||||
|
// Store image as file in App Group container
|
||||||
|
if self.storeImageData(finalImageData, fileName: fileName, shareId: shareId) {
|
||||||
|
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=true")
|
||||||
|
completion(true)
|
||||||
|
} else {
|
||||||
|
print("[ShareTarget] processSharedImage failed: storeImageData returned false shareId=\(shareId)")
|
||||||
|
print("[ShareTarget] processSharedImage completed shareId=\(shareId) success=false")
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return // Process only the first image
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No image found
|
// No image found
|
||||||
|
print("[ShareTarget] processSharedImage failed: no image attachment found attachmentCount=\(attachmentCount)")
|
||||||
|
print("[ShareTarget] processSharedImage completed success=false")
|
||||||
completion(false)
|
completion(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,48 +211,81 @@ class ShareViewController: UIViewController {
|
|||||||
return "shared-image.\(newExtension)"
|
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
|
/// Store image data as a file in the App Group container
|
||||||
/// All images are stored as files regardless of size for consistency and simplicity
|
/// All images are stored as files regardless of size for consistency and simplicity
|
||||||
/// Returns true if successful, false otherwise
|
/// 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)")
|
||||||
|
|
||||||
guard let containerURL = appGroupContainerURL else {
|
guard let containerURL = appGroupContainerURL else {
|
||||||
|
print("[ShareTarget] storeImageData failed: app group container unavailable shareId=\(shareId)")
|
||||||
|
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create file URL in the container using the actual filename
|
let originalFileName = fileName.isEmpty ? "\(sharedImageFileName).jpg" : fileName
|
||||||
// Extract extension from fileName if present, otherwise use sharedImageFileName
|
let storedFileName = storedFileName(shareId: shareId, originalFileName: originalFileName)
|
||||||
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName
|
let fileURL = containerURL.appendingPathComponent(storedFileName)
|
||||||
let fileURL = containerURL.appendingPathComponent(actualFileName)
|
|
||||||
|
|
||||||
// Remove old file if it exists
|
// Remove previously pending share file (metadata tracks one share at a time)
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
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
|
// Write image data to file
|
||||||
do {
|
do {
|
||||||
try imageData.write(to: fileURL)
|
try imageData.write(to: fileURL)
|
||||||
} catch {
|
} catch {
|
||||||
|
print("[ShareTarget] storeImageData failed: file write error shareId=\(shareId) error=\(error.localizedDescription)")
|
||||||
|
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
print("[ShareTarget] file stored shareId=\(shareId) originalFilename=\(originalFileName) storedFilename=\(storedFileName)")
|
||||||
|
|
||||||
// Store file path and filename in UserDefaults (small data, safe to store)
|
// Store file path and filename in UserDefaults (small data, safe to store)
|
||||||
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
|
||||||
|
print("[ShareTarget] storeImageData failed: UserDefaults unavailable shareId=\(shareId)")
|
||||||
|
print("[ShareTarget] storeImageData completed shareId=\(shareId) success=false")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store relative path and filename
|
// sharedPhotoFilePath = on-disk name; sharedPhotoFileName = original display name
|
||||||
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey)
|
userDefaults.set(storedFileName, forKey: sharedPhotoFilePathKey)
|
||||||
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey)
|
userDefaults.set(originalFileName, forKey: sharedPhotoFileNameKey)
|
||||||
|
userDefaults.set(shareId, forKey: sharedPhotoShareIdKey)
|
||||||
|
|
||||||
// Clean up any old base64 data that might exist
|
// Clean up any old base64 data that might exist
|
||||||
userDefaults.removeObject(forKey: "sharedPhotoBase64")
|
userDefaults.removeObject(forKey: "sharedPhotoBase64")
|
||||||
|
|
||||||
userDefaults.synchronize()
|
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")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func openMainApp() {
|
private func openMainApp() {
|
||||||
|
print("[ShareTarget] openMainApp starting")
|
||||||
|
|
||||||
// Open the main app with minimal URL - app will detect shared data on activation
|
// Open the main app with minimal URL - app will detect shared data on activation
|
||||||
guard let url = URL(string: "timesafari://") else {
|
guard let url = URL(string: "timesafari://") else {
|
||||||
|
print("[ShareTarget] openMainApp failed: could not create timesafari:// URL")
|
||||||
|
print("[ShareTarget] openMainApp completed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +293,7 @@ class ShareViewController: UIViewController {
|
|||||||
while responder != nil {
|
while responder != nil {
|
||||||
if let application = responder as? UIApplication {
|
if let application = responder as? UIApplication {
|
||||||
application.open(url, options: [:], completionHandler: nil)
|
application.open(url, options: [:], completionHandler: nil)
|
||||||
|
print("[ShareTarget] openMainApp completed via UIApplication")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
responder = responder?.next
|
responder = responder?.next
|
||||||
@@ -202,6 +301,7 @@ class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
// Fallback: use extension context
|
// Fallback: use extension context
|
||||||
extensionContext?.open(url, completionHandler: nil)
|
extensionContext?.open(url, completionHandler: nil)
|
||||||
|
print("[ShareTarget] openMainApp completed via extensionContext fallback")
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
8702
package-lock.json
generated
8702
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -1,7 +1,8 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"name": "timesafari",
|
"name": "giftopia",
|
||||||
"version": "1.3.8-beta",
|
"version": "1.4.3",
|
||||||
"description": "Gift Economies Application",
|
"description": "Giftopia App",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Gift Economies Team"
|
"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",
|
"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",
|
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||||
"check:dependencies": "./scripts/check-dependencies.sh",
|
"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: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:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||||
"test:mobile": "./scripts/test-mobile.sh",
|
"test:mobile": "./scripts/test-mobile.sh",
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
|
"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": "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: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:config": "npx tsx scripts/assets-config.ts",
|
||||||
"assets:validate": "npx tsx scripts/assets-validator.ts",
|
"assets:validate": "npx tsx scripts/assets-validator.ts",
|
||||||
"assets:validate:android": "./scripts/build-android.sh --assets-only",
|
"assets:validate:android": "./scripts/build-android.sh --assets-only",
|
||||||
@@ -138,19 +140,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/electron": "^5.0.1",
|
"@capacitor-community/electron": "^5.0.1",
|
||||||
"@capacitor-community/sqlite": "6.0.2",
|
"@capacitor-community/sqlite": "^7.0.3",
|
||||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
"@capacitor-mlkit/barcode-scanning": "^7.5.0",
|
||||||
"@capacitor/android": "^6.2.0",
|
"@capacitor/android": "^7.6.4",
|
||||||
"@capacitor/app": "^6.0.0",
|
"@capacitor/app": "^7.1.0",
|
||||||
"@capacitor/camera": "^6.0.0",
|
"@capacitor/camera": "^7.0.5",
|
||||||
"@capacitor/cli": "^6.2.0",
|
"@capacitor/cli": "^7.6.4",
|
||||||
"@capacitor/clipboard": "^6.0.2",
|
"@capacitor/clipboard": "^7.0.4",
|
||||||
"@capacitor/core": "^6.2.0",
|
"@capacitor/core": "^7.6.4",
|
||||||
"@capacitor/filesystem": "^6.0.0",
|
"@capacitor/filesystem": "^7.1.8",
|
||||||
"@capacitor/ios": "^6.2.0",
|
"@capacitor/ios": "^7.6.4",
|
||||||
"@capacitor/share": "^6.0.3",
|
"@capacitor/share": "^7.0.4",
|
||||||
"@capacitor/status-bar": "^6.0.2",
|
"@capacitor/status-bar": "^7.0.6",
|
||||||
"@capawesome/capacitor-file-picker": "^6.2.0",
|
"@capawesome/capacitor-file-picker": "^7.2.0",
|
||||||
"@dicebear/collection": "^5.4.1",
|
"@dicebear/collection": "^5.4.1",
|
||||||
"@dicebear/core": "^5.4.1",
|
"@dicebear/core": "^5.4.1",
|
||||||
"@ethersproject/hdnode": "^5.7.0",
|
"@ethersproject/hdnode": "^5.7.0",
|
||||||
|
|||||||
@@ -222,7 +222,9 @@ build_ios_app() {
|
|||||||
|
|
||||||
if [ "$BUILD_TYPE" = "debug" ]; then
|
if [ "$BUILD_TYPE" = "debug" ]; then
|
||||||
build_config="Debug"
|
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
|
else
|
||||||
build_config="Release"
|
build_config="Release"
|
||||||
destination="platform=iOS,id=auto"
|
destination="platform=iOS,id=auto"
|
||||||
@@ -232,18 +234,34 @@ build_ios_app() {
|
|||||||
|
|
||||||
cd ios/App
|
cd ios/App
|
||||||
|
|
||||||
# Build the app
|
# Prevent pkgx-managed libs (e.g. zlib) from leaking into the iOS SDK linker.
|
||||||
xcodebuild -workspace App.xcworkspace \
|
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" \
|
-scheme "$scheme" \
|
||||||
-configuration "$build_config" \
|
-configuration "$build_config" \
|
||||||
-destination "$destination" \
|
-destination "$destination" \
|
||||||
build \
|
build \
|
||||||
CODE_SIGN_IDENTITY="" \
|
CODE_SIGN_IDENTITY="" \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
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 ../..
|
cd ../..
|
||||||
|
|
||||||
|
if [ $build_exit -ne 0 ]; then
|
||||||
|
return $build_exit
|
||||||
|
fi
|
||||||
|
|
||||||
log_success "iOS app built successfully"
|
log_success "iOS app built successfully"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +424,7 @@ fi
|
|||||||
# Handle assets-only mode
|
# Handle assets-only mode
|
||||||
if [ "$ASSETS_ONLY" = true ]; then
|
if [ "$ASSETS_ONLY" = true ]; then
|
||||||
log_info "Assets-only mode: generating assets"
|
log_info "Assets-only mode: generating assets"
|
||||||
|
ensure_ios_capacitor_asset_directories
|
||||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||||
log_success "Assets generation completed successfully!"
|
log_success "Assets generation completed successfully!"
|
||||||
exit 0
|
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
|
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
|
||||||
|
|
||||||
# Step 7: Generate assets
|
# Step 7: Generate assets
|
||||||
|
ensure_ios_capacitor_asset_directories
|
||||||
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
|
||||||
|
|
||||||
# Step 8: Build iOS app
|
# Step 8: Build iOS app
|
||||||
@@ -564,16 +584,19 @@ safe_execute "Building iOS app" "build_ios_app" || exit 5
|
|||||||
if [ "$BUILD_IPA" = true ]; then
|
if [ "$BUILD_IPA" = true ]; then
|
||||||
log_info "Building IPA package..."
|
log_info "Building IPA package..."
|
||||||
cd ios/App
|
cd ios/App
|
||||||
xcodebuild -workspace App.xcworkspace \
|
xcodebuild -quiet \
|
||||||
|
-workspace App.xcworkspace \
|
||||||
-scheme App \
|
-scheme App \
|
||||||
-configuration Release \
|
-configuration Release \
|
||||||
-archivePath build/App.xcarchive \
|
-archivePath build/App.xcarchive \
|
||||||
archive \
|
archive \
|
||||||
CODE_SIGN_IDENTITY="" \
|
CODE_SIGN_IDENTITY="" \
|
||||||
CODE_SIGNING_REQUIRED=NO \
|
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 \
|
-archivePath build/App.xcarchive \
|
||||||
-exportPath build/ \
|
-exportPath build/ \
|
||||||
-exportOptionsPlist exportOptions.plist
|
-exportOptionsPlist exportOptions.plist
|
||||||
|
|||||||
@@ -337,6 +337,27 @@ parse_args() {
|
|||||||
fi
|
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 functions for use in child scripts
|
||||||
export -f log_info log_success log_warn log_error log_debug log_step
|
export -f log_info log_success log_warn log_error log_debug log_step
|
||||||
export -f measure_time print_header print_footer
|
export -f measure_time print_header print_footer
|
||||||
@@ -345,3 +366,4 @@ export -f safe_execute check_venv get_git_hash
|
|||||||
export -f clean_build_artifacts validate_env_vars
|
export -f clean_build_artifacts validate_env_vars
|
||||||
export -f setup_build_env setup_app_directories load_env_file print_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 */
|
properties * * @author Matthew Raymer */
|
||||||
<template>
|
<template>
|
||||||
<div id="sectionGiftedGiver">
|
<div id="sectionGiftedGiver">
|
||||||
<label class="block font-bold mb-1">
|
<label class="block font-semibold text-lg capitalize text-center">
|
||||||
{{ stepLabel }}
|
{{ stepLabel }}
|
||||||
</label>
|
</label>
|
||||||
<!-- Toggle link for entity type selection -->
|
<!-- Toggle link for entity type selection -->
|
||||||
<div class="text-right mb-4">
|
<div class="text-center mb-4">
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="handleToggleEntityType"
|
||||||
>
|
>
|
||||||
{{ toggleLinkText }}
|
{{ toggleLinkText }}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@
|
|||||||
@click="disabled ? notifyLocked() : addGroup()"
|
@click="disabled ? notifyLocked() : addGroup()"
|
||||||
>
|
>
|
||||||
<font-awesome icon="plus" class="text-sm" />
|
<font-awesome icon="plus" class="text-sm" />
|
||||||
New Group
|
New Do-Not-Pair Group
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { PlanData } from "../interfaces/records";
|
|||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
|
* ProjectSelectionDialog - Dialog for selecting a project
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - EntityGrid integration for project selection
|
* - EntityGrid integration for project selection
|
||||||
@@ -52,7 +52,7 @@ import { NotificationIface } from "../constants/app";
|
|||||||
EntityGrid,
|
EntityGrid,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export default class MeetingProjectDialog extends Vue {
|
export default class ProjectSelectionDialog extends Vue {
|
||||||
/** Whether the dialog is visible */
|
/** Whether the dialog is visible */
|
||||||
visible = false;
|
visible = false;
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
export enum AppString {
|
export enum AppString {
|
||||||
// This is used in titles and verbiage inside the app.
|
// This is used in titles and verbiage inside the app.
|
||||||
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
|
||||||
APP_NAME = "Gift Economies",
|
APP_NAME = "Giftopia",
|
||||||
APP_NAME_NO_SPACES = "GiftEconomies",
|
APP_NAME_NO_SPACES = APP_NAME,
|
||||||
|
|
||||||
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
|
||||||
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
|
||||||
|
|||||||
@@ -1175,11 +1175,6 @@ export const NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM = {
|
|||||||
message: "",
|
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 = {
|
export const NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER = {
|
||||||
title: "Missing Identifier",
|
title: "Missing Identifier",
|
||||||
message: "You must select an identifier before you can record a give.",
|
message: "You must select an identifier before you can record a give.",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export interface PlanActionClaim extends ClaimObject {
|
|||||||
agent?: { identifier: string };
|
agent?: { identifier: string };
|
||||||
description?: string;
|
description?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
|
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string };
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
lastClaimId?: string;
|
lastClaimId?: string;
|
||||||
|
|||||||
@@ -359,6 +359,23 @@ async function checkForSharedImageAndNavigate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// TEMPORARY SHARE TARGET DIAGNOSTICS
|
||||||
|
if (Capacitor.getPlatform() === "ios") {
|
||||||
|
try {
|
||||||
|
const diagnostics = await SharedImage.getShareExtensionDiagnostics();
|
||||||
|
logger.info(`[ShareTarget] Diagnostics ${JSON.stringify(diagnostics)}`);
|
||||||
|
} catch (diagnosticsError) {
|
||||||
|
logger.info(
|
||||||
|
`[ShareTarget] Diagnostics ${JSON.stringify({
|
||||||
|
error:
|
||||||
|
diagnosticsError instanceof Error
|
||||||
|
? diagnosticsError.message
|
||||||
|
: String(diagnosticsError),
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("[Main] 🔍 Checking for shared image on app activation");
|
logger.debug("[Main] 🔍 Checking for shared image on app activation");
|
||||||
const imageResult = await checkAndStoreNativeSharedImage();
|
const imageResult = await checkAndStoreNativeSharedImage();
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { WebPlugin } from "@capacitor/core";
|
import { WebPlugin } from "@capacitor/core";
|
||||||
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
|
import type {
|
||||||
|
SharedImagePlugin,
|
||||||
|
SharedImageResult,
|
||||||
|
ShareExtensionDiagnostics,
|
||||||
|
} from "./definitions";
|
||||||
|
|
||||||
export class SharedImagePluginWeb
|
export class SharedImagePluginWeb
|
||||||
extends WebPlugin
|
extends WebPlugin
|
||||||
@@ -18,4 +22,13 @@ export class SharedImagePluginWeb
|
|||||||
async hasSharedImage(): Promise<{ hasImage: boolean }> {
|
async hasSharedImage(): Promise<{ hasImage: boolean }> {
|
||||||
return { hasImage: false };
|
return { hasImage: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics> {
|
||||||
|
return {
|
||||||
|
shareExtensionLastStart: null,
|
||||||
|
sharedPhotoShareId: null,
|
||||||
|
sharedPhotoFilePath: null,
|
||||||
|
fileExists: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,18 @@ export interface SharedImageResult {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareExtensionDiagnostics {
|
||||||
|
shareExtensionLastStart: string | null;
|
||||||
|
sharedPhotoShareId: string | null;
|
||||||
|
sharedPhotoFilePath: string | null;
|
||||||
|
fileExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SharedImagePlugin {
|
export interface SharedImagePlugin {
|
||||||
/**
|
/**
|
||||||
* Get shared image data from native layer
|
* Get shared image data from native layer
|
||||||
* Returns base64 string and fileName, or null if no image exists
|
* 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>;
|
getSharedImage(): Promise<SharedImageResult | null>;
|
||||||
|
|
||||||
@@ -20,4 +27,9 @@ export interface SharedImagePlugin {
|
|||||||
* Useful for quick checks before calling getSharedImage()
|
* Useful for quick checks before calling getSharedImage()
|
||||||
*/
|
*/
|
||||||
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
hasSharedImage(): Promise<{ hasImage: boolean }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diagnostic snapshot of Share Extension startup and pending share state (iOS)
|
||||||
|
*/
|
||||||
|
getShareExtensionDiagnostics(): Promise<ShareExtensionDiagnostics>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "help-onboarding",
|
name: "help-onboarding",
|
||||||
component: () => import("../views/HelpOnboardingView.vue"),
|
component: () => import("../views/HelpOnboardingView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/help-terms",
|
||||||
|
name: "help-terms",
|
||||||
|
component: () => import("../views/HelpTermsView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
name: "home",
|
name: "home",
|
||||||
|
|||||||
@@ -123,10 +123,10 @@ export class CapacitorQRScanner implements QRScannerService {
|
|||||||
|
|
||||||
// Add listener for barcode scans
|
// Add listener for barcode scans
|
||||||
const handle = await BarcodeScanner.addListener(
|
const handle = await BarcodeScanner.addListener(
|
||||||
"barcodeScanned",
|
"barcodesScanned",
|
||||||
(result) => {
|
(result) => {
|
||||||
if (this.scanListener && result.barcode?.rawValue) {
|
if (this.scanListener && result.barcodes?.[0]?.rawValue) {
|
||||||
this.scanListener.onScan(result.barcode.rawValue);
|
this.scanListener.onScan(result.barcodes[0].rawValue);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -62,10 +62,6 @@ export class NativeNotificationService implements NotificationServiceInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Request notification permissions from the OS
|
|
||||||
* Shows native permission dialog on first call
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Request notification permissions from the OS
|
* Request notification permissions from the OS
|
||||||
* Shows native permission dialog on first call
|
* Shows native permission dialog on first call
|
||||||
|
|||||||
@@ -293,16 +293,12 @@
|
|||||||
<h3
|
<h3
|
||||||
data-testid="advancedSettings"
|
data-testid="advancedSettings"
|
||||||
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
|
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
|
||||||
@click="toggleShowGeneralAdvanced"
|
@click="showAdvanced = !showAdvanced"
|
||||||
>
|
>
|
||||||
{{
|
{{ showAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings" }}
|
||||||
showGeneralAdvanced
|
|
||||||
? "Hide Advanced Settings"
|
|
||||||
: "Show Advanced Settings"
|
|
||||||
}}
|
|
||||||
</h3>
|
</h3>
|
||||||
<section
|
<section
|
||||||
v-if="showGeneralAdvanced"
|
v-if="showAdvanced"
|
||||||
id="sectionAdvanced"
|
id="sectionAdvanced"
|
||||||
aria-labelledby="advancedHeading"
|
aria-labelledby="advancedHeading"
|
||||||
>
|
>
|
||||||
@@ -1095,6 +1091,7 @@ export default class AccountViewView extends Vue {
|
|||||||
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
|
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
|
||||||
this.searchBox = settings.searchBoxes?.[0] || null;
|
this.searchBox = settings.searchBoxes?.[0] || null;
|
||||||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
|
this.showAdvanced = this.showGeneralAdvanced;
|
||||||
this.showShortcutBvc = !!settings.showShortcutBvc;
|
this.showShortcutBvc = !!settings.showShortcutBvc;
|
||||||
this.warnIfProdServer = !!settings.warnIfProdServer;
|
this.warnIfProdServer = !!settings.warnIfProdServer;
|
||||||
this.warnIfTestServer = !!settings.warnIfTestServer;
|
this.warnIfTestServer = !!settings.warnIfTestServer;
|
||||||
@@ -1405,54 +1402,6 @@ export default class AccountViewView extends Vue {
|
|||||||
}, 150);
|
}, 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")
|
* Parse time string (e.g., "5:22 PM") to 24-hour format (e.g., "17:22")
|
||||||
*/
|
*/
|
||||||
@@ -2119,6 +2068,10 @@ export default class AccountViewView extends Vue {
|
|||||||
hasLocation: result.includeLocation,
|
hasLocation: result.includeLocation,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.data.userMessage) {
|
||||||
|
this.notify.info(response.data.userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} else {
|
} else {
|
||||||
logger.debug("[AccountViewView] No profile data found in response:", {
|
logger.debug("[AccountViewView] No profile data found in response:", {
|
||||||
@@ -2126,6 +2079,10 @@ export default class AccountViewView extends Vue {
|
|||||||
hasData: !!response.data,
|
hasData: !!response.data,
|
||||||
hasDataData: !!(response.data && response.data.data),
|
hasDataData: !!(response.data && response.data.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.data?.userMessage) {
|
||||||
|
this.notify.info(response.data.userMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -2232,6 +2189,10 @@ export default class AccountViewView extends Vue {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.data?.userMessage) {
|
||||||
|
this.notify.info(response.data.userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Handle specific HTTP status codes cleanly to suppress console spam
|
// Handle specific HTTP status codes cleanly to suppress console spam
|
||||||
@@ -2348,6 +2309,10 @@ export default class AccountViewView extends Vue {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (response.data?.userMessage) {
|
||||||
|
this.notify.info(response.data.userMessage);
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// Handle specific HTTP status codes cleanly to suppress console spam
|
// Handle specific HTTP status codes cleanly to suppress console spam
|
||||||
|
|||||||
@@ -398,6 +398,164 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Network Connections section: shows nearest neighbors in the registration
|
||||||
|
graph for all DIDs in this claim. The same conventions and styling are used
|
||||||
|
in UserProfileView.vue for user-profile nearest neighbors. Keep changes in sync.
|
||||||
|
-->
|
||||||
|
<div v-if="activeDid && hasVisibleNeighbors" class="mt-8">
|
||||||
|
<h2 class="text-lg font-semibold mb-3">
|
||||||
|
Network Connections
|
||||||
|
<button
|
||||||
|
title="What is this?"
|
||||||
|
class="ml-1 align-middle"
|
||||||
|
@click="showNeighborsInfo = true"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
icon="circle-info"
|
||||||
|
class="text-base text-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Info modal for network connections explanation -->
|
||||||
|
<div
|
||||||
|
v-if="showNeighborsInfo"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
|
||||||
|
@click.self="showNeighborsInfo = false"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
|
||||||
|
<p class="text-sm text-slate-700">
|
||||||
|
This section shows
|
||||||
|
{{
|
||||||
|
Object.values(claimNeighbors).flat().length === 1
|
||||||
|
? "a contact that is"
|
||||||
|
: "contacts that are"
|
||||||
|
}}
|
||||||
|
nearer to the people involved in this activity. If you want more
|
||||||
|
information, reach out to one of them and ask for an introduction.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
|
||||||
|
@click="showNeighborsInfo = false"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loadingNeighbors">
|
||||||
|
<div class="flex justify-center items-center py-8">
|
||||||
|
<font-awesome
|
||||||
|
icon="spinner"
|
||||||
|
class="fa-spin-pulse text-2xl text-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="neighborsError"
|
||||||
|
class="bg-red-50 border border-red-300 rounded-md p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<font-awesome
|
||||||
|
icon="exclamation-triangle"
|
||||||
|
class="text-red-500 mt-0.5"
|
||||||
|
/>
|
||||||
|
<p class="text-red-700">{{ neighborsError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="Object.keys(claimNeighbors).length > 0">
|
||||||
|
<div v-for="(neighbors, did) in claimNeighbors" :key="did" class="mb-4">
|
||||||
|
<h3
|
||||||
|
v-if="Object.keys(claimNeighbors).length > 1"
|
||||||
|
class="text-sm font-medium text-slate-600 mb-1"
|
||||||
|
>
|
||||||
|
Near {{ didInfo(did as string) }}:
|
||||||
|
</h3>
|
||||||
|
<!-- DID has no linked neighbors on this server -->
|
||||||
|
<p
|
||||||
|
v-if="neighbors.length === 0"
|
||||||
|
class="text-sm text-slate-500 italic"
|
||||||
|
>
|
||||||
|
Nobody on this server is linked to {{ didInfo(did as string) }}. The
|
||||||
|
data may be a mistake, or a test, or a reference to someone on a
|
||||||
|
different system. Anyway, we have no way to contact them.
|
||||||
|
</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="neighbor in neighbors"
|
||||||
|
:key="neighbor.did"
|
||||||
|
class="bg-slate-50 border border-slate-300 rounded-md"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-3 p-3">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
title="Copy claim link and expand"
|
||||||
|
class="text-blue-600 flex-shrink-0"
|
||||||
|
@click="onNeighborExpandClick(neighbor.did)"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
:icon="
|
||||||
|
expandedNeighborDid === neighbor.did
|
||||||
|
? 'chevron-down'
|
||||||
|
: 'chevron-right'
|
||||||
|
"
|
||||||
|
class="text-sm"
|
||||||
|
/>
|
||||||
|
{{ getNeighborDisplayName(neighbor.did) }}
|
||||||
|
</button>
|
||||||
|
<span :class="getRelationBadgeClass(neighbor.relation)">
|
||||||
|
{{ getRelationLabel(neighbor.relation) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="expandedNeighborDid === neighbor.did"
|
||||||
|
class="border-t border-slate-300 p-3 bg-white"
|
||||||
|
>
|
||||||
|
<router-link
|
||||||
|
:to="{ path: '/did/' + encodeURIComponent(neighbor.did) }"
|
||||||
|
class="text-blue-600 hover:text-blue-800 font-medium underline"
|
||||||
|
>
|
||||||
|
Go to contact info
|
||||||
|
</router-link>
|
||||||
|
and send them the claim link from your clipboard. Ask them for
|
||||||
|
an introduction.
|
||||||
|
<div
|
||||||
|
v-if="neighborIsNotInContacts(neighbor.did)"
|
||||||
|
class="flex flex-col gap-1 mt-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-slate-600">
|
||||||
|
This person is connected to you, but they are not in this
|
||||||
|
device's contacts. Copy this DID link and check on another
|
||||||
|
device or check with different people.
|
||||||
|
</p>
|
||||||
|
<span class="flex items-center gap-1 min-w-0">
|
||||||
|
<span class="text-xs truncate text-slate-600 min-w-0">
|
||||||
|
{{ neighbor.did }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
title="Copy DID Link"
|
||||||
|
class="text-blue-600 hover:text-blue-800 underline cursor-pointer flex-shrink-0"
|
||||||
|
@click.prevent="
|
||||||
|
copyTextToClipboard(
|
||||||
|
'DID link',
|
||||||
|
`${APP_SERVER}/deep-link/did/${encodeURIComponent(neighbor.did)}`,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<font-awesome icon="copy" class="text-sm" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue
|
Note that a similar section is found in ConfirmGiftView.vue, and kinda in HiddenDidDialog.vue
|
||||||
-->
|
-->
|
||||||
@@ -631,12 +789,19 @@ export default class ClaimView extends Vue {
|
|||||||
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
numConfsNotVisible = 0; // number of hidden DIDs in the confirmerIdList, minus the issuer if they aren't visible
|
||||||
providersForGive: ProviderInfo[] = [];
|
providersForGive: ProviderInfo[] = [];
|
||||||
showIdCopy = false;
|
showIdCopy = false;
|
||||||
|
showNeighborsInfo = false;
|
||||||
showVeriClaimDump = false;
|
showVeriClaimDump = false;
|
||||||
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||||
veriClaimDump = "";
|
veriClaimDump = "";
|
||||||
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
veriClaimDidsVisible: { [key: string]: string[] } = {};
|
||||||
windowDeepLink = window.location.href; // changed in the setup for deep linking
|
windowDeepLink = window.location.href; // changed in the setup for deep linking
|
||||||
|
|
||||||
|
// Network Connections state (same pattern as UserProfileView.vue)
|
||||||
|
claimNeighbors: Record<string, Array<{ did: string; relation: string }>> = {};
|
||||||
|
expandedNeighborDid: string | null = null;
|
||||||
|
loadingNeighbors = false;
|
||||||
|
neighborsError = "";
|
||||||
|
|
||||||
APP_SERVER = APP_SERVER;
|
APP_SERVER = APP_SERVER;
|
||||||
R = R;
|
R = R;
|
||||||
yaml = yaml;
|
yaml = yaml;
|
||||||
@@ -745,6 +910,16 @@ export default class ClaimView extends Vue {
|
|||||||
return (claim as { image?: string })?.image;
|
return (claim as { image?: string })?.image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the Network Connections section should be shown.
|
||||||
|
* Hidden if the only DIDs in claimNeighbors are the active user,
|
||||||
|
* or if there are no entries at all (after filtering).
|
||||||
|
*/
|
||||||
|
get hasVisibleNeighbors(): boolean {
|
||||||
|
const keys = Object.keys(this.claimNeighbors);
|
||||||
|
return keys.length > 0 || this.loadingNeighbors;
|
||||||
|
}
|
||||||
|
|
||||||
resetThisValues() {
|
resetThisValues() {
|
||||||
this.confirmerIdList = [];
|
this.confirmerIdList = [];
|
||||||
this.confsVisibleErrorMessage = "";
|
this.confsVisibleErrorMessage = "";
|
||||||
@@ -759,6 +934,10 @@ export default class ClaimView extends Vue {
|
|||||||
this.isEditedGlobalId = false;
|
this.isEditedGlobalId = false;
|
||||||
this.numConfsNotVisible = 0;
|
this.numConfsNotVisible = 0;
|
||||||
this.providersForGive = [];
|
this.providersForGive = [];
|
||||||
|
this.claimNeighbors = {};
|
||||||
|
this.expandedNeighborDid = null;
|
||||||
|
this.loadingNeighbors = false;
|
||||||
|
this.neighborsError = "";
|
||||||
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
|
||||||
this.veriClaimDump = "";
|
this.veriClaimDump = "";
|
||||||
this.veriClaimDidsVisible = {};
|
this.veriClaimDidsVisible = {};
|
||||||
@@ -825,6 +1004,7 @@ export default class ClaimView extends Vue {
|
|||||||
const claimId = this.$route.params.id as string;
|
const claimId = this.$route.params.id as string;
|
||||||
if (claimId) {
|
if (claimId) {
|
||||||
await this.loadClaim(claimId, this.activeDid);
|
await this.loadClaim(claimId, this.activeDid);
|
||||||
|
await this.loadClaimNeighbors();
|
||||||
} else {
|
} else {
|
||||||
this.notify.error("No claim ID was provided.");
|
this.notify.error("No claim ID was provided.");
|
||||||
}
|
}
|
||||||
@@ -1000,6 +1180,125 @@ export default class ClaimView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads nearest neighbors for all DIDs in this claim via the
|
||||||
|
* endorser-ch claimNearestNeighbors endpoint.
|
||||||
|
* Same display conventions as UserProfileView.vue's loadNeighbors.
|
||||||
|
*/
|
||||||
|
async loadClaimNeighbors() {
|
||||||
|
if (!this.veriClaim.id) return;
|
||||||
|
|
||||||
|
this.loadingNeighbors = true;
|
||||||
|
this.neighborsError = "";
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
this.apiServer +
|
||||||
|
"/api/claim/claimNearestNeighbors/" +
|
||||||
|
encodeURIComponent(this.veriClaim.id as string);
|
||||||
|
const headers = await serverUtil.getHeaders(this.activeDid);
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
if (resp.status === 200) {
|
||||||
|
const raw = resp.data.data || {};
|
||||||
|
// Filter out the current user's own DID entry — their neighbors
|
||||||
|
// aren't useful here since "You" is already known.
|
||||||
|
const filtered: Record<
|
||||||
|
string,
|
||||||
|
Array<{ did: string; relation: string }>
|
||||||
|
> = {};
|
||||||
|
for (const [did, neighbors] of Object.entries(raw)) {
|
||||||
|
if (did === this.activeDid) continue;
|
||||||
|
filtered[did] = neighbors as Array<{
|
||||||
|
did: string;
|
||||||
|
relation: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
this.claimNeighbors = filtered;
|
||||||
|
} else {
|
||||||
|
this.claimNeighbors = {};
|
||||||
|
this.neighborsError = "Failed to load network connections.";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await this.$logError(
|
||||||
|
"Error loading claim neighbors: " + JSON.stringify(error),
|
||||||
|
);
|
||||||
|
this.claimNeighbors = {};
|
||||||
|
this.neighborsError =
|
||||||
|
"An error occurred while loading network connections.";
|
||||||
|
} finally {
|
||||||
|
this.loadingNeighbors = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets display name for a neighbor DID (same as UserProfileView.vue)
|
||||||
|
*/
|
||||||
|
getNeighborDisplayName(did: string): string {
|
||||||
|
return serverUtil.didInfo(
|
||||||
|
did,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
neighborIsNotInContacts(did: string): boolean {
|
||||||
|
return !this.allContacts.some((contact) => contact.did === did);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets human-readable label for relation type (same as UserProfileView.vue)
|
||||||
|
*/
|
||||||
|
getRelationLabel(relation: string): string {
|
||||||
|
switch (relation) {
|
||||||
|
case "REGISTERED_BY_YOU":
|
||||||
|
return "Registered by You";
|
||||||
|
case "REGISTERED_YOU":
|
||||||
|
return "Registered You";
|
||||||
|
case "TARGET":
|
||||||
|
return "Yourself";
|
||||||
|
default:
|
||||||
|
return relation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets CSS classes for relation badge styling (same as UserProfileView.vue)
|
||||||
|
*/
|
||||||
|
getRelationBadgeClass(relation: string): string {
|
||||||
|
const baseClasses =
|
||||||
|
"text-xs font-semibold px-2 py-1 rounded whitespace-nowrap";
|
||||||
|
switch (relation) {
|
||||||
|
case "REGISTERED_BY_YOU":
|
||||||
|
return `${baseClasses} bg-blue-100 text-blue-700`;
|
||||||
|
case "REGISTERED_YOU":
|
||||||
|
return `${baseClasses} bg-green-100 text-green-700`;
|
||||||
|
case "TARGET":
|
||||||
|
return `${baseClasses} bg-purple-100 text-purple-700`;
|
||||||
|
default:
|
||||||
|
return `${baseClasses} bg-slate-100 text-slate-700`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles clicking expand on a neighbor - copies claim link and toggles
|
||||||
|
*/
|
||||||
|
async onNeighborExpandClick(did: string) {
|
||||||
|
if (this.expandedNeighborDid === did) {
|
||||||
|
this.expandedNeighborDid = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyToClipboard(this.windowDeepLink);
|
||||||
|
this.notify.copied("Claim link");
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(`Error copying claim link: ${error}`, true);
|
||||||
|
this.notify.error("Failed to copy claim link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.expandedNeighborDid = did;
|
||||||
|
}
|
||||||
|
|
||||||
async showFullClaim(claimId: string) {
|
async showFullClaim(claimId: string) {
|
||||||
const url =
|
const url =
|
||||||
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
this.apiServer + "/api/claim/full/" + encodeURIComponent(claimId);
|
||||||
@@ -1110,6 +1409,7 @@ export default class ClaimView extends Vue {
|
|||||||
(this.$router as Router).push(route).then(async () => {
|
(this.$router as Router).push(route).then(async () => {
|
||||||
this.resetThisValues();
|
this.resetThisValues();
|
||||||
await this.loadClaim(claimId, this.activeDid);
|
await this.loadClaim(claimId, this.activeDid);
|
||||||
|
await this.loadClaimNeighbors();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,11 +118,13 @@ import {
|
|||||||
CONTACT_CSV_HEADER,
|
CONTACT_CSV_HEADER,
|
||||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||||
generateEndorserJwtUrlForAccount,
|
generateEndorserJwtUrlForAccount,
|
||||||
|
register,
|
||||||
setVisibilityUtil,
|
setVisibilityUtil,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||||
import { retrieveAccountMetadata } from "../libs/util";
|
import { retrieveAccountMetadata } from "../libs/util";
|
||||||
|
|
||||||
|
import { AxiosError } from "axios";
|
||||||
import { Account } from "@/db/tables/accounts";
|
import { Account } from "@/db/tables/accounts";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
import {
|
import {
|
||||||
@@ -139,7 +141,10 @@ import {
|
|||||||
NOTIFY_QR_URL_COPIED,
|
NOTIFY_QR_URL_COPIED,
|
||||||
NOTIFY_QR_CODE_HELP,
|
NOTIFY_QR_CODE_HELP,
|
||||||
NOTIFY_QR_DID_COPIED,
|
NOTIFY_QR_DID_COPIED,
|
||||||
|
NOTIFY_QR_REGISTRATION_SUBMITTED,
|
||||||
|
NOTIFY_QR_REGISTRATION_ERROR,
|
||||||
createQRContactAddedMessage,
|
createQRContactAddedMessage,
|
||||||
|
createQRRegistrationSuccessMessage,
|
||||||
QR_TIMEOUT_MEDIUM,
|
QR_TIMEOUT_MEDIUM,
|
||||||
QR_TIMEOUT_STANDARD,
|
QR_TIMEOUT_STANDARD,
|
||||||
QR_TIMEOUT_LONG,
|
QR_TIMEOUT_LONG,
|
||||||
@@ -204,6 +209,7 @@ export default class ContactQRScanFull extends Vue {
|
|||||||
activeDid = "";
|
activeDid = "";
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
givenName = "";
|
givenName = "";
|
||||||
|
hideRegisterPromptOnNewContact = false;
|
||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
profileImageUrl = "";
|
profileImageUrl = "";
|
||||||
qrValue = "";
|
qrValue = "";
|
||||||
@@ -278,6 +284,8 @@ export default class ContactQRScanFull extends Vue {
|
|||||||
|
|
||||||
this.apiServer = settings.apiServer || "";
|
this.apiServer = settings.apiServer || "";
|
||||||
this.givenName = settings.firstName || "";
|
this.givenName = settings.firstName || "";
|
||||||
|
this.hideRegisterPromptOnNewContact =
|
||||||
|
!!settings.hideRegisterPromptOnNewContact;
|
||||||
this.isRegistered = !!settings.isRegistered;
|
this.isRegistered = !!settings.isRegistered;
|
||||||
this.profileImageUrl = settings.profileImageUrl || "";
|
this.profileImageUrl = settings.profileImageUrl || "";
|
||||||
|
|
||||||
@@ -575,6 +583,34 @@ export default class ContactQRScanFull extends Vue {
|
|||||||
createQRContactAddedMessage(!!this.activeDid),
|
createQRContactAddedMessage(!!this.activeDid),
|
||||||
QR_TIMEOUT_STANDARD,
|
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) {
|
} catch (error) {
|
||||||
logger.error("Error saving contact to database:", {
|
logger.error("Error saving contact to database:", {
|
||||||
did: contact.did,
|
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
|
* Vue lifecycle hook - component mounted
|
||||||
* Sets up event listeners and starts scanning automatically
|
* Sets up event listeners and starts scanning automatically
|
||||||
|
|||||||
@@ -897,7 +897,7 @@ export default class DiscoverView extends Vue {
|
|||||||
public computedStarredTabStyleClassNames() {
|
public computedStarredTabStyleClassNames() {
|
||||||
return {
|
return {
|
||||||
"inline-block": true,
|
"inline-block": true,
|
||||||
"py-3": true,
|
"py-2": true,
|
||||||
"rounded-t-lg": true,
|
"rounded-t-lg": true,
|
||||||
"border-b-2": true,
|
"border-b-2": true,
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<QuickNav />
|
||||||
|
|
||||||
<!-- CONTENT -->
|
<!-- CONTENT -->
|
||||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||||
<TopMessage />
|
<TopMessage />
|
||||||
@@ -263,7 +265,6 @@ import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
|||||||
import {
|
import {
|
||||||
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
|
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
|
||||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
|
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
|
||||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR,
|
|
||||||
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
|
NOTIFY_GIFTED_DETAILS_NO_IDENTIFIER,
|
||||||
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
|
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
|
||||||
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
|
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE,
|
||||||
@@ -302,6 +303,7 @@ export default class GiftedDetails extends Vue {
|
|||||||
giverName = "";
|
giverName = "";
|
||||||
hideBackButton = false;
|
hideBackButton = false;
|
||||||
imageUrl = "";
|
imageUrl = "";
|
||||||
|
imageUrlToDelete = "";
|
||||||
message = "";
|
message = "";
|
||||||
offerId = "";
|
offerId = "";
|
||||||
prevCredToEdit?: GenericCredWrapper<GiveActionClaim>;
|
prevCredToEdit?: GenericCredWrapper<GiveActionClaim>;
|
||||||
@@ -517,7 +519,10 @@ export default class GiftedDetails extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
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) {
|
if (this.destinationPathAfter) {
|
||||||
(this.$router as Router).push({ path: this.destinationPathAfter });
|
(this.$router as Router).push({ path: this.destinationPathAfter });
|
||||||
} else {
|
} else {
|
||||||
@@ -526,7 +531,10 @@ export default class GiftedDetails extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cancelBack() {
|
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();
|
(this.$router as Router).back();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,13 +547,18 @@ export default class GiftedDetails extends Vue {
|
|||||||
confirmDeleteImage() {
|
confirmDeleteImage() {
|
||||||
this.notify.confirm(
|
this.notify.confirm(
|
||||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM.message,
|
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,
|
TIMEOUTS.LONG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteImage() {
|
async deleteImage(imageUrl: string) {
|
||||||
if (!this.imageUrl) {
|
if (!imageUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -559,38 +572,21 @@ export default class GiftedDetails extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await this.axios.delete(
|
const response = await this.axios.delete(
|
||||||
DEFAULT_IMAGE_API_SERVER +
|
DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(imageUrl),
|
||||||
"/image/" +
|
|
||||||
encodeURIComponent(this.imageUrl),
|
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
// don't bother with a notification
|
// don't bother with a notification
|
||||||
// (either they'll simply continue or they're canceling and going back)
|
|
||||||
} else {
|
} else {
|
||||||
logger.error("Problem deleting image:", response);
|
logger.error("Problem deleting image:", response);
|
||||||
this.notify.error(
|
|
||||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
|
|
||||||
TIMEOUTS.LONG,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.imageUrl = "";
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error deleting image:", error);
|
logger.error("Error deleting image:", error);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if ((error as any)?.response?.status === 404) {
|
if ((error as any)?.response?.status === 404) {
|
||||||
logger.log("Weird: the image was already deleted.", error);
|
logger.log("Image was already deleted:", error);
|
||||||
|
|
||||||
this.imageUrl = "";
|
|
||||||
|
|
||||||
// it already doesn't exist so we won't say anything to the user
|
|
||||||
} else {
|
} else {
|
||||||
this.notify.error(
|
logger.error("Failed to delete image from server:", error);
|
||||||
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_ERROR.message,
|
|
||||||
TIMEOUTS.LONG,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,6 +729,12 @@ export default class GiftedDetails extends Vue {
|
|||||||
TIMEOUTS.LONG,
|
TIMEOUTS.LONG,
|
||||||
);
|
);
|
||||||
} else {
|
} 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(
|
this.notify.success(
|
||||||
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
|
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
|
||||||
TIMEOUTS.SHORT,
|
TIMEOUTS.SHORT,
|
||||||
|
|||||||
@@ -32,6 +32,9 @@
|
|||||||
>
|
>
|
||||||
<h3>Troubleshooting Notifications</h3>
|
<h3>Troubleshooting Notifications</h3>
|
||||||
|
|
||||||
|
Note that the notifications will not arrive exactly at the time you set
|
||||||
|
(because phones don't let non-alarm-apps set exact alarms).
|
||||||
|
|
||||||
<h4>Check your in-app notification settings</h4>
|
<h4>Check your in-app notification settings</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Tap <strong>Profile</strong> in the bottom bar</li>
|
<li>Tap <strong>Profile</strong> in the bottom bar</li>
|
||||||
|
|||||||
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>
|
</p>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
<h2 class="text-xl font-semibold">What are the terms & conditions and the privacy policy?</h2>
|
||||||
<p style="display:inline; align-items: center">
|
<p>
|
||||||
This work is public domain. (If you like rules, reference
|
<router-link
|
||||||
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
|
class="text-blue-500"
|
||||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
:to="{ name: 'help-terms' }"
|
||||||
<img
|
>
|
||||||
src="../assets/help/creative-commons-circle.svg"
|
Read them here.
|
||||||
alt="CC circle"
|
</router-link>
|
||||||
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>
|
</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>
|
<h2 class="text-xl font-semibold">How can I contribute?</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -109,12 +109,14 @@ Raymer * @version 1.0.0 */
|
|||||||
|
|
||||||
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
|
<div v-if="isUserRegistered" id="sectionRecordSomethingGiven">
|
||||||
<!-- Record Quick-Action -->
|
<!-- Record Quick-Action -->
|
||||||
<div class="mb-6">
|
<div
|
||||||
<div class="flex gap-2 items-center mb-2">
|
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 -->
|
<!-- Thank button - always visible and unchanged -->
|
||||||
<button
|
<button
|
||||||
type="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()"
|
@click="openPersonDialog()"
|
||||||
>
|
>
|
||||||
<font-awesome icon="plus" />
|
<font-awesome icon="plus" />
|
||||||
@@ -122,25 +124,29 @@ Raymer * @version 1.0.0 */
|
|||||||
</button>
|
</button>
|
||||||
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
|
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
|
||||||
<transition
|
<transition
|
||||||
enter-active-class="transition-all duration-1000 ease-out"
|
enter-active-class="transition-all duration-500 ease-out"
|
||||||
leave-active-class="transition-all duration-1000 ease-in"
|
leave-active-class="transition-all duration-500 ease-in"
|
||||||
enter-from-class="scale-0"
|
enter-from-class="opacity-0"
|
||||||
enter-to-class="scale-100"
|
enter-to-class="opacity-100"
|
||||||
leave-from-class="scale-100"
|
leave-from-class="opacity-100"
|
||||||
leave-to-class="scale-0"
|
leave-to-class="opacity-0"
|
||||||
>
|
>
|
||||||
<button
|
<div
|
||||||
v-if="isScrolled"
|
v-if="isScrolled"
|
||||||
type="button"
|
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]"
|
||||||
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()"
|
|
||||||
>
|
>
|
||||||
<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>
|
</transition>
|
||||||
<button
|
<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()"
|
@click="openGiftedPrompts()"
|
||||||
>
|
>
|
||||||
<font-awesome
|
<font-awesome
|
||||||
@@ -559,7 +565,7 @@ export default class HomeView extends Vue {
|
|||||||
const scrollTop = appElement
|
const scrollTop = appElement
|
||||||
? appElement.scrollTop
|
? appElement.scrollTop
|
||||||
: window.pageYOffset || document.documentElement.scrollTop || 0;
|
: window.pageYOffset || document.documentElement.scrollTop || 0;
|
||||||
const shouldBeScrolled = scrollTop > 100;
|
const shouldBeScrolled = scrollTop > 120;
|
||||||
if (this.isScrolled !== shouldBeScrolled) {
|
if (this.isScrolled !== shouldBeScrolled) {
|
||||||
this.isScrolled = shouldBeScrolled;
|
this.isScrolled = shouldBeScrolled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,49 @@
|
|||||||
@assign="handleRepresentativeAssigned"
|
@assign="handleRepresentativeAssigned"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Parent Project Selection -->
|
||||||
|
<div class="w-full flex items-stretch my-4">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
|
||||||
|
@click="openParentProjectDialog"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<font-awesome icon="folder" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'text-sm font-semibold': parentProjectHandleId,
|
||||||
|
'text-slate-400': !parentProjectHandleId,
|
||||||
|
}"
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
parentProjectHandleId
|
||||||
|
? parentProjectName || "Parent Project"
|
||||||
|
: "Select Parent Project\u2026"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="parentProjectHandleId"
|
||||||
|
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
||||||
|
@click="unsetParentProject"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectSelectionDialog
|
||||||
|
ref="parentProjectDialog"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:notify="$notify"
|
||||||
|
@assign="handleParentProjectSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p v-if="shouldShowOwnershipWarning">
|
<p v-if="shouldShowOwnershipWarning">
|
||||||
<span class="text-red-500">Beware!</span>
|
<span class="text-red-500">Beware!</span>
|
||||||
@@ -283,6 +326,7 @@ import { LeafletMouseEvent } from "leaflet";
|
|||||||
import EntityIcon from "../components/EntityIcon.vue";
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
|
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
|
||||||
|
import ProjectSelectionDialog from "../components/ProjectSelectionDialog.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import {
|
import {
|
||||||
AppString,
|
AppString,
|
||||||
@@ -311,6 +355,7 @@ import {
|
|||||||
PROJECT_TIMEOUT_VERY_LONG,
|
PROJECT_TIMEOUT_VERY_LONG,
|
||||||
} from "../constants/notifications";
|
} from "../constants/notifications";
|
||||||
import { PlanActionClaim } from "../interfaces/claims";
|
import { PlanActionClaim } from "../interfaces/claims";
|
||||||
|
import { PlanData } from "../interfaces/records";
|
||||||
import {
|
import {
|
||||||
createEndorserJwtVcFromClaim,
|
createEndorserJwtVcFromClaim,
|
||||||
getHeaders,
|
getHeaders,
|
||||||
@@ -378,6 +423,7 @@ import { logger } from "../utils/logger";
|
|||||||
components: {
|
components: {
|
||||||
EntityIcon,
|
EntityIcon,
|
||||||
ImageMethodDialog,
|
ImageMethodDialog,
|
||||||
|
ProjectSelectionDialog,
|
||||||
ProjectRepresentativeDialog,
|
ProjectRepresentativeDialog,
|
||||||
LMap,
|
LMap,
|
||||||
LMarker,
|
LMarker,
|
||||||
@@ -429,6 +475,8 @@ export default class NewEditProjectView extends Vue {
|
|||||||
latitude = 0;
|
latitude = 0;
|
||||||
longitude = 0;
|
longitude = 0;
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
|
parentProjectHandleId = "";
|
||||||
|
parentProjectName = "";
|
||||||
projectId = "";
|
projectId = "";
|
||||||
projectIssuerDid = "";
|
projectIssuerDid = "";
|
||||||
sendToTrustroots = false;
|
sendToTrustroots = false;
|
||||||
@@ -510,6 +558,10 @@ export default class NewEditProjectView extends Vue {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.fullClaim?.fulfills?.identifier) {
|
||||||
|
this.parentProjectHandleId = this.fullClaim.fulfills.identifier;
|
||||||
|
this.loadParentProjectName(this.parentProjectHandleId);
|
||||||
|
}
|
||||||
if (this.fullClaim.startTime) {
|
if (this.fullClaim.startTime) {
|
||||||
const localDateTime = DateTime.fromISO(
|
const localDateTime = DateTime.fromISO(
|
||||||
this.fullClaim.startTime as string,
|
this.fullClaim.startTime as string,
|
||||||
@@ -623,6 +675,14 @@ export default class NewEditProjectView extends Vue {
|
|||||||
} else {
|
} else {
|
||||||
delete vcClaim.agent;
|
delete vcClaim.agent;
|
||||||
}
|
}
|
||||||
|
if (this.parentProjectHandleId) {
|
||||||
|
vcClaim.fulfills = {
|
||||||
|
"@type": "PlanAction",
|
||||||
|
identifier: this.parentProjectHandleId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
delete vcClaim.fulfills;
|
||||||
|
}
|
||||||
if (this.imageUrl) {
|
if (this.imageUrl) {
|
||||||
vcClaim.image = this.imageUrl;
|
vcClaim.image = this.imageUrl;
|
||||||
} else {
|
} else {
|
||||||
@@ -1075,5 +1135,33 @@ export default class NewEditProjectView extends Vue {
|
|||||||
unsetRepresentative(): void {
|
unsetRepresentative(): void {
|
||||||
this.agentDid = "";
|
this.agentDid = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openParentProjectDialog(): void {
|
||||||
|
(this.$refs.parentProjectDialog as ProjectSelectionDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleParentProjectSelected(project: PlanData): void {
|
||||||
|
this.parentProjectHandleId = project.handleId;
|
||||||
|
this.parentProjectName = project.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsetParentProject(): void {
|
||||||
|
this.parentProjectHandleId = "";
|
||||||
|
this.parentProjectName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadParentProjectName(handleId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const url =
|
||||||
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(handleId);
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
if (resp.status === 200 && resp.data?.claim?.name) {
|
||||||
|
this.parentProjectName = resp.data.claim.name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Parent project name will remain empty
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -267,7 +267,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MeetingProjectDialog
|
<ProjectSelectionDialog
|
||||||
ref="meetingProjectDialog"
|
ref="meetingProjectDialog"
|
||||||
:active-did="activeDid"
|
:active-did="activeDid"
|
||||||
:all-my-dids="allMyDids"
|
:all-my-dids="allMyDids"
|
||||||
@@ -366,7 +366,8 @@
|
|||||||
Do Not Pair Together
|
Do Not Pair Together
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-gray-500 mb-2">
|
<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>
|
||||||
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
|
<p v-if="hasActiveMatches" class="text-xs text-amber-600 mb-2">
|
||||||
Erase matches to change restrictions.
|
Erase matches to change restrictions.
|
||||||
@@ -585,7 +586,7 @@ import TopMessage from "../components/TopMessage.vue";
|
|||||||
import MeetingMembersList from "../components/MeetingMembersList.vue";
|
import MeetingMembersList from "../components/MeetingMembersList.vue";
|
||||||
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
|
import MeetingMemberMatch from "../components/MeetingMemberMatch.vue";
|
||||||
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
|
import MeetingExclusionGroups from "../components/MeetingExclusionGroups.vue";
|
||||||
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
import ProjectSelectionDialog from "../components/ProjectSelectionDialog.vue";
|
||||||
import ProjectIcon from "../components/ProjectIcon.vue";
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import {
|
import {
|
||||||
errorStringForLog,
|
errorStringForLog,
|
||||||
@@ -637,7 +638,7 @@ interface MeetingSetupInputs {
|
|||||||
MeetingMembersList,
|
MeetingMembersList,
|
||||||
MeetingMemberMatch,
|
MeetingMemberMatch,
|
||||||
MeetingExclusionGroups,
|
MeetingExclusionGroups,
|
||||||
MeetingProjectDialog,
|
ProjectSelectionDialog,
|
||||||
ProjectIcon,
|
ProjectIcon,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
@@ -1468,7 +1469,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
* Open the project link selection dialog
|
* Open the project link selection dialog
|
||||||
*/
|
*/
|
||||||
openProjectLinkDialog(): void {
|
openProjectLinkDialog(): void {
|
||||||
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
|
(this.$refs.meetingProjectDialog as ProjectSelectionDialog).open();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -193,7 +193,7 @@
|
|||||||
class="bg-slate-100 px-4 py-3 rounded-md"
|
class="bg-slate-100 px-4 py-3 rounded-md"
|
||||||
>
|
>
|
||||||
<h3 class="text-sm uppercase font-semibold mt-3">
|
<h3 class="text-sm uppercase font-semibold mt-3">
|
||||||
Projects That Contribute To This
|
These Projects Are Part Of This
|
||||||
</h3>
|
</h3>
|
||||||
<!--
|
<!--
|
||||||
centering because long, wrapped project names didn't left align with blank
|
centering because long, wrapped project names didn't left align with blank
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
|
<div v-if="fulfilledByThis" class="bg-slate-100 px-4 py-3 rounded-md">
|
||||||
<h3 class="text-sm uppercase font-semibold mb-3">
|
<h3 class="text-sm uppercase font-semibold mb-3">
|
||||||
Projects Getting Contributions From This
|
This Project Is Part Of These
|
||||||
</h3>
|
</h3>
|
||||||
<!--
|
<!--
|
||||||
centering because long, wrapped project names didn't left align with blank
|
centering because long, wrapped project names didn't left align with blank
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ export default class QuickActionBvcBeginView extends Vue {
|
|||||||
);
|
);
|
||||||
this.notify.error(
|
this.notify.error(
|
||||||
timeResult?.error || NOTIFY_BVC_TIME_ERROR.message,
|
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(
|
this.notify.error(
|
||||||
attendResult?.error || NOTIFY_BVC_ATTENDANCE_ERROR.message,
|
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);
|
logger.error("[QuickActionBvcBeginView] Error sending claims:", error);
|
||||||
this.notify.error(
|
this.notify.error(
|
||||||
error.userMessage || NOTIFY_BVC_SUBMISSION_ERROR.message,
|
error.userMessage || NOTIFY_BVC_SUBMISSION_ERROR.message,
|
||||||
TIMEOUTS.LONG,
|
error.userMessage ? TIMEOUTS.MODAL : TIMEOUTS.LONG,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nearest Neighbors Section -->
|
<!--
|
||||||
|
Network Connections section: shows nearest neighbors in the registration
|
||||||
|
graph for this user profile. The same conventions and styling are used in
|
||||||
|
ClaimView.vue for claim-level nearest neighbors. Keep changes in sync.
|
||||||
|
-->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
|
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
|
||||||
@@ -63,7 +67,46 @@
|
|||||||
"
|
"
|
||||||
class="mt-6"
|
class="mt-6"
|
||||||
>
|
>
|
||||||
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
|
<h2 class="text-lg font-semibold mb-3">
|
||||||
|
Network Connections
|
||||||
|
<button
|
||||||
|
title="What is this?"
|
||||||
|
class="ml-1 align-middle"
|
||||||
|
@click="showNeighborsInfo = true"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
icon="circle-info"
|
||||||
|
class="text-base text-blue-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Info modal for network connections explanation -->
|
||||||
|
<div
|
||||||
|
v-if="showNeighborsInfo"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 flex items-start justify-center pt-16 z-50"
|
||||||
|
@click.self="showNeighborsInfo = false"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg p-6 mx-4 max-w-md">
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Network Connections</h3>
|
||||||
|
<p class="text-sm text-slate-700">
|
||||||
|
This section shows
|
||||||
|
{{
|
||||||
|
neighbors.length === 1
|
||||||
|
? "a contact that is"
|
||||||
|
: "contacts that are"
|
||||||
|
}}
|
||||||
|
nearer to this person. If you want more information, reach out to
|
||||||
|
one of them and ask for an introduction.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="mt-4 w-full bg-blue-600 text-white py-2 rounded-md"
|
||||||
|
@click="showNeighborsInfo = false"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loadingNeighbors">
|
<div v-if="loadingNeighbors">
|
||||||
<div class="flex justify-center items-center py-8">
|
<div class="flex justify-center items-center py-8">
|
||||||
@@ -124,8 +167,8 @@
|
|||||||
>
|
>
|
||||||
Go to contact info
|
Go to contact info
|
||||||
</router-link>
|
</router-link>
|
||||||
and send them the link in your clipboard and ask for an
|
and send them the profile link from your clipboard. Ask them to
|
||||||
introduction to this person.
|
introduce you to this person.
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
getNeighborDisplayName(neighbor.did) === '' ||
|
getNeighborDisplayName(neighbor.did) === '' ||
|
||||||
@@ -269,6 +312,7 @@ export default class UserProfileView extends Vue {
|
|||||||
neighborsError = "";
|
neighborsError = "";
|
||||||
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
|
||||||
profile: UserProfile | null = null;
|
profile: UserProfile | null = null;
|
||||||
|
showNeighborsInfo = false;
|
||||||
|
|
||||||
// make this function available to the Vue template
|
// make this function available to the Vue template
|
||||||
didInfo = didInfo;
|
didInfo = didInfo;
|
||||||
|
|||||||
@@ -130,11 +130,13 @@ test('Add contact, record gift, confirm gift', async ({ page }) => {
|
|||||||
|
|
||||||
// Refresh home view and check gift
|
// Refresh home view and check gift
|
||||||
await page.goto('./');
|
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.
|
const item = page.locator('li').filter({ hasText: finalTitle }).locator('[data-testid="circle-info-link"]');
|
||||||
// It may be similar to the CORS problem below.
|
await expect(item).toBeVisible();
|
||||||
const item = await page.locator('li:first-child').filter({ hasText: finalTitle });
|
await item.click();
|
||||||
await item.locator('[data-testid="circle-info-link"]').click();
|
|
||||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||||
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
|
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user