Compare commits

...

50 Commits

Author SHA1 Message Date
Jose Olarte III
4fc30562fb docs(ios): add App Group configuration audit for share extension
Document App/extension entitlements, bundle IDs, teams, and signing,
flagging a code-vs-entitlement App Group ID mismatch (code uses
group.app.timesafari.share while entitlements grant
group.app.trentlarson.timesafari.share) that breaks shared storage.
2026-06-25 17:48:53 +08:00
Jose Olarte III
6afe40bc23 docs(ios): add Share Extension configuration audit
Document that TimeSafariShareExtension is a code-based extension with
ShareViewController as NSExtensionPrincipalClass, confirm viewDidLoad
executes on launch, and note no blocking Info.plist/storyboard mismatches.
2026-06-25 17:28:20 +08:00
Jose Olarte III
402bd2681f chore(ios): log share extension diagnostics on startup (temporary)
Call getShareExtensionDiagnostics() during the iOS shared-image startup
check and print the result to Xcode logs for share-target investigation.
2026-06-24 19:43:27 +08:00
Jose Olarte III
498a4926bf feat(ios): add Share Extension startup diagnostic marker and API
Write shareExtensionLastStart on ShareViewController.viewDidLoad and
expose getShareExtensionDiagnostics() through SharedImagePlugin with
shareId, file path, and fileExists for debugging failed share flows.
2026-06-24 19:38:25 +08:00
Jose Olarte III
f0ca49b5dc feat(ios): add diagnostic logging to Share Extension share flow
Log start/end, shareId, attachment counts, UTType, byte counts, filenames,
and every early return across viewDidLoad through completeRequest to trace
how far a share progresses when debugging failures.
2026-06-24 19:28:09 +08:00
Jose Olarte III
07463246f0 feat(ios): add share-target diagnostic logging in SharedImageUtility
Log shareId, sharedPhotoFilePath, metadataExists, and fileExists at the
start of getSharedImageData() and hasSharedImage() to debug pending
App Group shares without changing retrieval behavior.
2026-06-24 17:19:54 +08:00
Jose Olarte III
79ceebbd1d feat(ios): make shared image retrieval non-destructive (Phase 1C)
Stop deleting App Group metadata and image files in getSharedImageData()
so retrieval is read-only while preserving the existing plugin API shape.
Document removed deletion paths in the iOS share target audit.
2026-06-24 16:49:45 +08:00
Jose Olarte III
ddbd07f315 feat(ios): use UUID-based filenames for shared images (Phase 1B)
Store shared images as <shareId>.<ext> in the App Group container while
keeping the original filename in metadata, preventing on-disk collisions
without changing retrieval, deletion, or JS consumer behavior.
2026-06-23 19:37:11 +08:00
Jose Olarte III
35a6a6bfb3 feat(ios): add share ID tracking for share target (Phase 1A)
Generate a UUID per incoming share in the Share Extension, persist it
as sharedPhotoShareId in App Group metadata, and add [ShareTarget] logs
for receive/store/retrieve events without changing retrieval or deletion.
2026-06-23 19:13:44 +08:00
Jose Olarte III
08a55202f5 docs(ios): add share target implementation audit
Document the Share Extension → App Group → main app flow, including
read/write/delete points, startup detection hooks, timing behavior,
and race conditions to support share-target reliability work.
2026-06-23 17:20:53 +08:00
ec41dd52d5 bump version to v 1.4.3 build 69 2026-06-21 10:59:24 -06:00
463db39a6b remove hard-coded daily android notification 2026-06-19 23:43:40 -06:00
fe97dff752 Merge pull request 'Rework Thanks Button' (#234) from thanks-button-rework into master
Reviewed-on: #234
2026-06-19 07:21:37 +00:00
Jose Olarte III
903047f13b style(gift): center entity selection step heading and entity type toggle 2026-06-18 21:09:32 +08:00
Jose Olarte III
48be234af4 fix(home): offset scrolled Thank button for safe-area-inset-bottom 2026-06-17 17:57:13 +08:00
6c0907d905 remove unused function & duplicate comment 2026-06-16 16:09:31 -06:00
Jose Olarte III
8d8bcf2a7e style(home): rework Thank button and sticky scroll action bar
Replace the floating circular plus FAB with a full-width bottom bar that
matches the inline Thank button. Wrap the quick-action section in a styled
container and raise the scroll threshold to 120px.
2026-06-16 21:48:34 +08:00
a4b47904c8 Merge pull request 'Add footer to Gifted Details view' (#233) from gifted-details-footer into master
Reviewed-on: #233
2026-06-16 08:27:47 +00:00
Jose Olarte III
bb890baacf fix(gifted-details): add QuickNav footer navigation 2026-06-15 17:20:18 +08:00
dae23300fe point to a single .entitlements file (undo most of previous commit) 2026-06-02 15:50:17 -06:00
9e401febea add 'share' to the entitlements for production, for sharing with this app 2026-06-02 15:46:36 -06:00
cd4b279703 Merge pull request '16kb-pages' (#232) from 16kb-pages into master
Reviewed-on: #232
2026-05-25 20:01:18 +00:00
a3a2d97b9a update version to v 1.4.2 build 68 2026-05-24 21:50:39 -06:00
802050259c update android build, fix ios build for new version of MLKit BarcodeScanner (both build) 2026-05-24 21:24:18 -06:00
efd7d50a84 fix build error 2026-05-24 19:12:40 -06:00
39c389cda8 make do-not-pair group verbiage more clear 2026-05-24 18:38:56 -06:00
93fdcaf7ff fix timing error for a click (that only showed in firefox) 2026-05-24 18:29:11 -06:00
ad419efa0d utilize 'userMessage' if sent by server 2026-05-24 16:23:47 -06:00
ce45ddb2bd update after 'audit fix' 2026-05-24 16:23:09 -06:00
7d306bd204 add first cut for 16kb page sizes, all by Claude 2026-05-10 10:15:10 -06:00
9713313a40 fix HTML syntax warning 2026-05-10 09:43:46 -06:00
Jose Olarte III
ffa7bac319 fix(ios): ensure capacitor-assets output dirs exist on fresh clones
Gitignored AppIcon.appiconset and Splash.imageset are absent after clone,
which made `capacitor-assets generate --ios` fail (missing paths and
Contents.json). Add ensure_ios_capacitor_asset_directories in common.sh
to mkdir and seed minimal Contents.json when needed; call it from
build-ios.sh before asset generation and from the build:native npm script.
Document the behavior in ios/.gitignore.
2026-04-13 16:20:51 +08:00
e0e0a0a183 bump version and add -beta 2026-04-05 20:08:24 -06:00
ea662f4430 bump to v 1.3.13 (for a web release) 2026-04-05 19:58:36 -06:00
81647e1f3c make terms & conditions into a separate page 2026-04-05 19:21:43 -06:00
bf1ee78025 allow a custom error message to stay on the screen indefinitely 2026-03-29 19:11:49 -06:00
Jose Olarte III
66b7d0f46e docs(readme): expand Setup & Building quick start for all platforms
Restructure the quick start with Web, Android, and iOS subheadings; put
each npm command in its own code block; fold the test-page step into the
Web section. Document Android (build:android:test:run + ADB, link to
BUILDING.md) and iOS (build:ios:studio + Xcode prerequisites).
2026-03-26 19:41:03 +08:00
Jose Olarte III
63dcf44125 fix(ios): make build-ios.sh work on current simulators and trim xcodebuild noise
Use generic/platform=iOS Simulator instead of a fixed device name so CLI builds
do not fail when that simulator is not installed (e.g. newer Xcode runtimes).

Pass -quiet to xcodebuild and enable SWIFT_SUPPRESS_WARNINGS plus
GCC_WARN_INHIBIT_ALL_WARNINGS for scripted builds and IPA archive/export so
terminal output stays smaller; full diagnostics remain available in Xcode.
2026-03-26 19:40:07 +08:00
cf1ecdfb4c add registration for new contacts that are unregistered 2026-03-22 20:20:33 -06:00
e9ad61b780 don't delete a gift image on an edit unless they hit 'save' 2026-03-22 20:07:59 -06:00
ad8df3eb93 fix problem where canceling an edit deletes an image 2026-03-22 20:06:58 -06:00
05d346edce add project selection for one that this 'fulfills' 2026-03-22 17:58:46 -06:00
e259e60fa7 bump version and add "-beta" 2026-03-22 17:39:46 -06:00
821de3f006 do not toggle off the 'advanced' section in account view with the 'general' toggle is disabled 2026-03-22 09:53:56 -06:00
43f83031d4 rename app from "Gifties" to "Giftopia" 2026-03-21 16:27:21 -06:00
688a48a332 bump to version 1.3.12 build 67 2026-03-21 16:22:14 -06:00
8938c242ee change more files to name the app "Gifties" 2026-03-20 19:33:04 -06:00
358af42afd rename from "Gift Economies" to "Gifties" 2026-03-19 21:18:11 -06:00
59c00241b8 add the nearest-neighbor feature to the claim screen 2026-03-19 20:24:09 -06:00
33ec90e571 move the 'discover' page 'starred' word to be on the same level 2026-03-18 19:44:25 -06:00
58 changed files with 6725 additions and 4657 deletions

View File

@@ -1 +1 @@
18.19.0 20.18.1

2
.nvmrc
View File

@@ -1 +1 @@
18.19.0 20.18.1

View File

@@ -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

View File

@@ -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]

View File

@@ -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).

View File

@@ -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 {

View File

@@ -2,8 +2,8 @@
android { android {
compileOptions { compileOptions {
sourceCompatibility JavaVersion.VERSION_17 sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_21
} }
} }

View File

@@ -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"
}, },

View File

@@ -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);

View File

@@ -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>

View File

@@ -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
} }
} }

View File

@@ -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 = [:]

View File

@@ -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'
} }

View File

@@ -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"
}, },

View File

@@ -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'
}, },

View File

@@ -0,0 +1,155 @@
# iOS App Group Configuration Audit
**Generated:** 2026-06-25 17:31:15 PST
## Scope
Static inspection of App Group configuration for the **App** target and the **TimeSafariShareExtension** target: entitlements, capabilities, bundle identifiers, Debug/Release build settings, and signing. No code was modified.
### Files Inspected
| File | Role |
|------|------|
| `ios/App/App/App.entitlements` | App target App Group declaration |
| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | Extension App Group declaration |
| `ios/App/App.xcodeproj/project.pbxproj` | Bundle IDs, teams, signing, entitlement linkage |
| `ios/App/App/SharedImageUtility.swift` | App Group identifier used by main app code |
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | App Group identifier used by extension code |
---
## CRITICAL FINDING — Code vs Entitlements App Group Mismatch
The entitlements and the Swift source declare **different** App Group identifiers:
| Location | App Group identifier |
|----------|----------------------|
| `App.entitlements` | `group.app.trentlarson.timesafari.share` |
| `TimeSafariShareExtension.entitlements` | `group.app.trentlarson.timesafari.share` |
| `SharedImageUtility.swift` (`appGroupIdentifier`) | `group.app.timesafari.share` |
| `ShareViewController.swift` (`appGroupIdentifier`) | `group.app.timesafari.share` |
The runtime code targets `group.app.timesafari.share`, but **neither target is entitled to that group** — both entitlements now grant `group.app.trentlarson.timesafari.share`.
This is an **uncommitted change**: `git diff` shows both entitlements were just changed from `group.app.timesafari.share``group.app.trentlarson.timesafari.share`, while the Swift code still uses the old value. Before this edit the code and entitlements matched; after it they do not.
### Runtime Consequences
- `FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.app.timesafari.share")` returns **nil** (the app is not entitled to that group). The extension's `storeImageData` aborts via `guard let containerURL` → image file is never written; the main app's reads return nil.
- `UserDefaults(suiteName: "group.app.timesafari.share")` does **not** resolve to the shared, entitled suite. Writes fall back to each process's own preferences domain, so the extension's keys (`sharedPhotoFilePath`, `sharedPhotoShareId`, `shareExtensionLastStart`, `sharedPhotoReady`) are **not visible** to the main app.
Net effect: the entire share-target handoff via the App Group breaks while this mismatch exists. This is the most likely root cause of "App Group UserDefaults writes failing."
**Note:** This affects both Debug and Release (the entitlements have no per-configuration variants), not Debug only.
---
## Direct Answers
### Do both targets declare the same App Group?
**Yes — the two entitlements files match each other.** Both `App.entitlements` and `TimeSafariShareExtension.entitlements` declare exactly:
```xml
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.trentlarson.timesafari.share</string>
</array>
```
However, **the code does not match the entitlements** (see Critical Finding). So "same App Group" is true at the entitlement level, false at the entitlement-vs-code level.
### Are there any Debug vs Release differences?
**Entitlements / App Group:** No. A single entitlements file per target applies to both configurations; the App Group string is identical in Debug and Release (`group.app.trentlarson.timesafari.share`).
**Bundle identifiers:** Yes — they differ by configuration:
| Target | Debug | Release |
|--------|-------|---------|
| App | `app.trentlarson.timesafari` | `app.timesafari` |
| Extension | `app.trentlarson.timesafari.TimeSafariShareExtension` | `app.timesafari.TimeSafariShareExtension` |
(The Debug bundle IDs were just changed from the `app.timesafari*` form per `git diff`.)
**Development team:** Yes — differs by configuration (see next answer).
In both configurations the extension bundle ID is correctly nested under the app bundle ID, which is required for an app extension.
### Are there any team-ID differences that could affect App Group access?
| Configuration | App team | Extension team | Match? |
|---------------|----------|----------------|--------|
| Debug | `7XVXYPEQYJ` | `7XVXYPEQYJ` | ✅ same |
| Release | `GM3FS5JQPH` | `GM3FS5JQPH` | ✅ same |
- **Within each configuration, both targets use the same team** — this is the condition required for two targets to share an App Group, and it is satisfied.
- **Across configurations the teams differ** (Debug `7XVXYPEQYJ` vs Release `GM3FS5JQPH`). The Debug team was just changed from `GM3FS5JQPH` per `git diff`.
Implications:
1. The App Group container is namespaced by Team ID at runtime (`$(TeamID).group...`). A Debug install (team `7XVXYPEQYJ`) and a Release install (team `GM3FS5JQPH`) use **different physical containers** and cannot share data with each other. This is normal and only matters if you expect data continuity between Debug and Release builds.
2. With **Automatic** signing, the App Group `group.app.trentlarson.timesafari.share` must be registered/enabled for **both** teams. If it is not provisioned under the Debug team `7XVXYPEQYJ`, automatic signing of the Debug build can fail to include the App Group entitlement (or fail to sign), which would also break App Group access in Debug.
### Are there signing/entitlement mismatches that could cause App Group UserDefaults writes to fail in Debug builds?
**Yes.** In order of severity:
1. **(Primary) Code/entitlement group-ID mismatch.** Code uses `group.app.timesafari.share`; entitlements grant `group.app.trentlarson.timesafari.share`. The code's group is not entitled, so shared `UserDefaults`/container access fails. Affects Debug and Release.
2. **(Debug-specific risk) App Group provisioning under the Debug team.** Debug now signs with team `7XVXYPEQYJ` (changed from `GM3FS5JQPH`). Under Automatic signing, if `group.app.trentlarson.timesafari.share` is not enabled for team `7XVXYPEQYJ`, the Debug build's App Group entitlement may not be granted, causing writes to silently fall back to the local domain.
3. **(Consistency) Bundle-ID change accompanying the team change.** Debug bundle IDs changed to `app.trentlarson.timesafari*`. App Groups don't have to match bundle IDs, so this is not a direct cause, but combined with the new team it means Debug provisioning is a distinct profile/identifier set that must independently carry the App Group capability.
No mismatch was found **between the two entitlement files themselves**, and no per-configuration entitlement override exists.
---
## Detailed Configuration
### Entitlements (identical content in both files)
```xml
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.trentlarson.timesafari.share</string>
</array>
```
`CODE_SIGN_ENTITLEMENTS` linkage (both Debug and Release):
| Target | Entitlements path |
|--------|-------------------|
| App | `App/App.entitlements` |
| Extension | `TimeSafariShareExtension/TimeSafariShareExtension.entitlements` |
### Bundle Identifiers, Teams, Signing (project.pbxproj)
| Setting | App Debug | App Release | Ext Debug | Ext Release |
|---------|-----------|-------------|-----------|-------------|
| `PRODUCT_BUNDLE_IDENTIFIER` | `app.trentlarson.timesafari` | `app.timesafari` | `app.trentlarson.timesafari.TimeSafariShareExtension` | `app.timesafari.TimeSafariShareExtension` |
| `DEVELOPMENT_TEAM` | `7XVXYPEQYJ` | `GM3FS5JQPH` | `7XVXYPEQYJ` | `GM3FS5JQPH` |
| `CODE_SIGN_STYLE` | Automatic | Automatic | Automatic | Automatic |
| `CODE_SIGN_ENTITLEMENTS` | `App/App.entitlements` | same | `TimeSafariShareExtension/...entitlements` | same |
| App Group (from entitlements) | `group.app.trentlarson.timesafari.share` | same | same | same |
### App Group Identifier Used in Code
```swift
// SharedImageUtility.swift:13 and ShareViewController.swift:13
private let appGroupIdentifier = "group.app.timesafari.share" // does NOT match entitlements
```
---
## Recommendations (no code changed)
1. **Resolve the group-ID mismatch.** Either revert the entitlements back to `group.app.timesafari.share`, or update the two Swift constants to `group.app.trentlarson.timesafari.share`. Both sides must use one identical string.
2. **Confirm App Group provisioning per team.** Ensure `group.app.trentlarson.timesafari.share` (whichever string is chosen) is enabled for both `7XVXYPEQYJ` (Debug) and `GM3FS5JQPH` (Release) so Automatic signing includes the capability in both configurations.
3. **Decide whether the Debug↔Release team/bundle-ID split is intentional.** If cross-config data continuity is ever expected, note that different Team IDs yield different App Group containers.
4. **Verify at runtime** using the existing `getShareExtensionDiagnostics()` / `[ShareTarget]` logs: after aligning identifiers, `shareExtensionLastStart` written by the extension should become readable by the main app.
---
## Conclusion
The two **entitlement files agree** on the App Group (`group.app.trentlarson.timesafari.share`) and, **within each build configuration**, both targets share the same Development Team and consistent nested bundle IDs — the structural requirements for App Group sharing are met. The decisive problem is that the **Swift code still references the old group `group.app.timesafari.share`**, which no entitlement grants; this breaks both shared `UserDefaults` and the shared container in all builds. Secondarily, the recent Debug switch to team `7XVXYPEQYJ` means the chosen App Group must be provisioned under that team for Debug App Group access to work under Automatic signing.

View File

@@ -0,0 +1,188 @@
# iOS Share Extension Configuration Audit
**Generated:** 2026-06-25 15:33:39 PST
## Scope
Static inspection of the `TimeSafariShareExtension` target configuration to determine the extension entry point, principal view controller, storyboard vs. code-based setup, and whether `ShareViewController.viewDidLoad()` is guaranteed to execute. No code was modified.
### Files Inspected
| File | Role |
|------|------|
| `ios/App/App.xcodeproj/project.pbxproj` | Target, build settings, file membership |
| `ios/App/TimeSafariShareExtension/Info.plist` | NSExtension configuration |
| `ios/App/TimeSafariShareExtension/ShareViewController.swift` | Principal class implementation |
| `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | App Group access |
---
## Direct Answers
### 1. What class is configured as the extension entry point?
`ShareViewController`, resolved via `Info.plist` key `NSExtensionPrincipalClass`:
```xml
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
```
`$(PRODUCT_MODULE_NAME)` resolves to `TimeSafariShareExtension` (derived from `PRODUCT_NAME = $(TARGET_NAME)`), so the runtime entry point is `TimeSafariShareExtension.ShareViewController`.
### 2. Is ShareViewController actually the configured principal view controller?
**Yes.** `ShareViewController.swift` declares:
```swift
class ShareViewController: UIViewController {
```
within the `TimeSafariShareExtension` target. The class name, module, and `UIViewController` base class match the `NSExtensionPrincipalClass` reference. There is no competing storyboard-designated initial controller, so `ShareViewController` is unambiguously the principal view controller.
### 3. Is the extension storyboard-based or code-based?
**Code-based.**
- `Info.plist` contains `NSExtensionPrincipalClass` and does **not** contain `NSExtensionMainStoryboard`.
- The extension folder contains no `.storyboard` file (only `Info.plist`, `ShareViewController.swift`, and the entitlements file).
- The only storyboards in the project (`Main.storyboard`, `LaunchScreen.storyboard`) belong exclusively to the **App** target's resources, not the extension.
This deviates from the default Xcode Share Extension template (which ships a `MainInterface.storyboard` + `NSExtensionMainStoryboard`). The deviation is intentional and internally consistent.
### 4. Does the configuration guarantee that ShareViewController.viewDidLoad() executes when the extension launches?
**Yes, under normal launch.** Because:
- The principal class is a `UIViewController` subclass, the extension host instantiates it and installs its view into the extension's window. This triggers the standard view lifecycle: `loadView()``viewDidLoad()`.
- `ShareViewController` overrides `viewDidLoad()` and calls `super.viewDidLoad()`, then immediately runs `processAndOpenApp()`. The startup marker (`shareExtensionLastStart`) and `[ShareTarget] viewDidLoad started` log execute before any other logic.
- The Swift source is compiled into the extension target via the Xcode 16 **file-system synchronized group** (`PBXFileSystemSynchronizedRootGroup` for `TimeSafariShareExtension`), so the class is guaranteed to be present in the built `.appex`.
**Caveats (not failures, but worth noting):**
- The guarantee holds only if the OS successfully resolves and instantiates the principal class. If `$(PRODUCT_MODULE_NAME)` ever diverges from the actual Swift module name (e.g., a custom `PRODUCT_MODULE_NAME`), runtime class lookup would fail and the extension would not launch. Currently they match.
- `viewDidLoad()` executing does not, by itself, guarantee the *share* succeeds — the asynchronous `loadItem` work in `processSharedImage` happens after `viewDidLoad` returns.
### 5. Are there any mismatches between Info.plist, storyboard, and ShareViewController?
**No blocking mismatches.** Details:
| Check | Result |
|-------|--------|
| `NSExtensionPrincipalClass` ↔ Swift class name | Match (`ShareViewController`) |
| Principal class module ↔ target module | Match (`TimeSafariShareExtension`) |
| `NSExtensionMainStoryboard` ↔ storyboard file | Consistent — neither exists (code-based) |
| Activation rule ↔ implementation | Consistent — `NSExtensionActivationSupportsImageWithMaxCount = 1` matches first-image-only handling |
| `NSExtensionPointIdentifier` | `com.apple.share-services` (correct for a Share extension) |
| Source file membership | `ShareViewController.swift` compiled via synchronized group |
See "Observations / Non-Blocking Notes" for environment-specific items.
---
## Detailed Configuration
### NSExtension (Info.plist)
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
```
| Key | Value | Meaning |
|-----|-------|---------|
| `NSExtensionPointIdentifier` | `com.apple.share-services` | Registers as a Share sheet extension |
| `NSExtensionPrincipalClass` | `$(PRODUCT_MODULE_NAME).ShareViewController` | Code-based entry point |
| `NSExtensionActivationRule` | `…ImageWithMaxCount = 1` | Activates for shares containing at least one image; processes one |
| `NSExtensionMainStoryboard` | *absent* | Confirms code-based (no storyboard UI) |
### TimeSafariShareExtension Target (project.pbxproj)
| Attribute | Value |
|-----------|-------|
| `isa` | `PBXNativeTarget` |
| `productType` | `com.apple.product-type.app-extension` |
| `productReference` | `TimeSafariShareExtension.appex` |
| `CreatedOnToolsVersion` | `26.1.1` |
| File membership | `fileSystemSynchronizedGroups``TimeSafariShareExtension` (auto-membership) |
| Sources build phase | Empty explicit list (handled by synchronized group) |
| `Info.plist` membership | Excepted from synchronized group (`PBXFileSystemSynchronizedBuildFileExceptionSet`) |
| Embedded into | App target's "Embed Foundation Extensions" copy phase |
| Target dependency | App target depends on `TimeSafariShareExtension` |
### Extension Build Settings (Debug / Release)
| Setting | Debug | Release |
|---------|-------|---------|
| `INFOPLIST_FILE` | `TimeSafariShareExtension/Info.plist` | same |
| `GENERATE_INFOPLIST_FILE` | `YES` | `YES` |
| `PRODUCT_NAME` | `$(TARGET_NAME)``TimeSafariShareExtension` | same |
| `PRODUCT_BUNDLE_IDENTIFIER` | `app.trentlarson.timesafari.TimeSafariShareExtension` | `app.timesafari.TimeSafariShareExtension` |
| `CODE_SIGN_ENTITLEMENTS` | `TimeSafariShareExtension/TimeSafariShareExtension.entitlements` | same |
| `IPHONEOS_DEPLOYMENT_TARGET` | `14.0` | `14.0` |
| `DEVELOPMENT_TEAM` | `7XVXYPEQYJ` | `GM3FS5JQPH` |
| `SWIFT_VERSION` | `5.0` | `5.0` |
| `SKIP_INSTALL` | `YES` | `YES` |
`PRODUCT_MODULE_NAME` is not overridden, so it defaults to `PRODUCT_NAME` = `TimeSafariShareExtension`, making the principal class resolve to `TimeSafariShareExtension.ShareViewController`.
### ShareViewController Linkage
```swift
import UIKit
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
...
override func viewDidLoad() {
// writes shareExtensionLastStart, logs, then super + processAndOpenApp()
}
}
```
- Subclass of `UIViewController` → eligible as a code-based principal class.
- Lives in the `TimeSafariShareExtension` target via the synchronized group.
- No `@objc(...)` annotation is required because the principal class is referenced with the fully-qualified Swift name (`module.Class`).
### Scene / Lifecycle Configuration
- **No** `UIApplicationSceneManifest` / `UISceneConfigurations` in the extension `Info.plist`.
- **No** `SceneDelegate` in the extension target.
- The extension relies entirely on the principal `UIViewController` lifecycle (`viewDidLoad``processAndOpenApp``processSharedImage``completeRequest`).
- The main app (`AppDelegate`) is a `UIApplicationDelegate` and is unrelated to the extension's lifecycle except via the shared App Group.
### App Group Linkage
`TimeSafariShareExtension.entitlements` grants `group.app.timesafari.share`, matching the App target's entitlement. This is what allows `viewDidLoad()`'s `shareExtensionLastStart` write to be visible to the main app's `getShareExtensionDiagnostics()`.
---
## Observations / Non-Blocking Notes
1. **Config-dependent bundle IDs & teams.** Debug uses `app.trentlarson.timesafari*` with team `7XVXYPEQYJ`; Release uses `app.timesafari*` with team `GM3FS5JQPH`. Within each configuration the extension bundle ID is correctly nested under the app bundle ID. Ensure provisioning profiles for both teams include the App Group capability.
2. **`GENERATE_INFOPLIST_FILE = YES` alongside an explicit `INFOPLIST_FILE`.** Xcode merges auto-generated keys into the supplied `Info.plist`. This is supported and the explicit `NSExtension` block is preserved; no conflict observed.
3. **Deployment target gap.** Extension targets iOS 14.0 while the App target targets iOS 15.5. Valid (an extension may target lower), and not a launch concern.
4. **Principal-class resolution dependency.** The launch guarantee depends on `$(PRODUCT_MODULE_NAME)` matching the compiled module. If `PRODUCT_MODULE_NAME` is later customized or the target renamed without updating expectations, the OS would fail to instantiate `ShareViewController` and `viewDidLoad()` would never run. Currently consistent.
5. **Code-based template divergence.** Since there is no `MainInterface.storyboard`, any future tooling or documentation that assumes the stock storyboard-based Share Extension template will not apply here.
---
## Conclusion
The `TimeSafariShareExtension` is a **code-based** Share extension whose entry point is `ShareViewController` (a `UIViewController` subclass) via `NSExtensionPrincipalClass`. The Info.plist, (absent) storyboard, and Swift implementation are mutually consistent. Under normal extension launch, `ShareViewController.viewDidLoad()` is guaranteed to run, executing the startup marker and the share-processing pipeline. No blocking misconfiguration was found; only environment-specific items (signing identities, principal-class resolution dependency) warrant ongoing attention.

View File

@@ -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 14 can all fire for a single share event. Only the JS paths (#24) 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` |

View File

@@ -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
View File

@@ -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

View File

@@ -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)";

View File

@@ -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>

View File

@@ -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())
}
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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;

View File

@@ -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();

View File

@@ -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,
};
}
} }

View File

@@ -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>;
} }

View File

@@ -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",

View File

@@ -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);
} }
}, },
); );

View File

@@ -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

View File

@@ -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

View File

@@ -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();
}); });
} }

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
View 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>

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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();
} }
/** /**

View File

@@ -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

View File

@@ -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,
); );
} }
} }

View File

@@ -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;

View File

@@ -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();