Compare commits

...

74 Commits

Author SHA1 Message Date
Matthew Raymer
f354d89ece feat: implement DB normalization, WebAuthn server, diagnostics, and build improvements
Major Features:
- Normalize dbExec() changes count across all platforms using SQLite changes()
- Create WebAuthn verification server (Fastify) for secure passkey operations
- Add platform diagnostics interface and UI view
- Implement diagnostic export service with memory logs and git hash
- Add git hash extraction to build config

Database Improvements:
- Create dbResultNormalizer.ts shared helper for reliable change counts
- Update CapacitorPlatformService to use normalizer with SQLite queries
- Remove read-before/read-after workaround from databaseUtil.ts
- All platforms now return reliable { changes: number; lastId?: number }

WebAuthn Security:
- Split WebAuthn into client/offline modules for proper isolation
- Create passkeyDidPeer.client.ts (server endpoint integration)
- Create passkeyDidPeer.offlineVerify.ts (offline mode, dynamic import only)
- Refactor passkeyDidPeer.ts as facade routing to client/offline
- Server-side verification required by default (offline mode behind flag)

WebAuthn Server:
- Fastify-based server in server/ directory
- 4 endpoints: registration/options, registration/verify, authentication/options, authentication/verify
- In-memory storage for development (production-ready structure)
- Comprehensive API documentation and setup guide

Platform Diagnostics:
- Create PlatformDiagnostics interface
- Implement getDiagnostics() in all platform services
- Create PlatformDiagnosticsView.vue debug UI
- Add /debug/diagnostics route
- Display platform info, capabilities, DB status, worker/queue stats

Diagnostic Export:
- Update DiagnosticExportService to include memory logs
- Add redaction for sensitive data
- Include git hash and build info in exports
- Export via platform file sharing

Build Configuration:
- Extract git hash at build time in vite.config.common.mts
- Set VITE_GIT_HASH via define for all builds
- Available in diagnostics and export bundles

Documentation:
- Add WebAuthn server section to README.md and BUILDING.md
- Explain security rationale for server-side verification
- Document setup, deployment, and configuration
- Add environment variable examples

Files Created:
- src/services/dbResultNormalizer.ts
- src/libs/crypto/vc/passkeyDidPeer.client.ts
- src/views/debug/PlatformDiagnosticsView.vue
- server/package.json, server/tsconfig.json, server/src/index.ts
- server/README.md, server/.env.example

Files Modified:
- src/services/platforms/CapacitorPlatformService.ts
- src/db/databaseUtil.ts
- src/libs/crypto/vc/passkeyDidPeer.ts
- src/services/DiagnosticExportService.ts
- src/services/platforms/WebPlatformService.ts
- src/services/platforms/ElectronPlatformService.ts
- src/services/PlatformService.ts
- src/interfaces/diagnostics.ts
- src/router/index.ts
- src/constants/app.ts
- vite.config.common.mts
- README.md, BUILDING.md
2026-01-01 12:07:44 +00:00
Matthew Raymer
5247a37fac fix: resolve build failures, security issues, and architectural improvements
Critical Fixes:
- Remove missing sw_combine.js from prebuild script and all documentation
- Remove missing test-safety-check.sh from test:all script
- Add build:web:build alias to fix docker commands
- Fix syntax errors in validate-critical-files.sh script

Security:
- Fix Electron path traversal vulnerability in export-data-to-downloads handler
  - Sanitize file names using basename() to prevent directory traversal
  - Enforce allowed file extensions (.json, .txt, .csv, .md, .log)
  - Add validation for empty names, path separators, and length limits

Architecture Improvements:
- Add queue size guard to CapacitorPlatformService (max 1000 operations)
  - Fail-fast when queue is full to prevent memory exhaustion
  - Add warning at 80% capacity
  - Add getQueueTelemetry() method for monitoring queue health
  - Track peak queue size for diagnostics

- Standardize environment variable usage in PlatformServiceFactory
  - Prefer import.meta.env.VITE_PLATFORM (standard Vite pattern)
  - Maintain backward compatibility with process.env fallback

Documentation:
- Clarify PWA status: remove misleading VitePWA comments
- Update BUILDING.md to reflect removed sw_combine.js step
- Update build-arch-guard.sh to remove sw_combine.js from protected files

All changes maintain backward compatibility and improve code quality.
2026-01-01 10:54:07 +00:00
f64846ae17 fix issue showing ID without name when affirming delivery of an offer 2025-12-30 18:28:54 -07:00
24b636cd2f bump version and add "-beta" for work before next release 2025-12-30 07:10:42 -07:00
faef83a664 bump to version 1.1.5 2025-12-30 07:08:26 -07:00
c992afe4d4 Merge pull request 'feat: remove the 'lock' from the giving & receiving sides' (#229) from no-locks into master
Reviewed-on: #229
2025-12-23 03:23:07 +00:00
941d93f6db fix: disallow affirming delivery of orders where giver is hidden 2025-12-20 20:20:10 -07:00
f460d6c3e2 fix: adjust playwright tests to new home-page button, and use 3 threads for fewer random failures 2025-12-20 19:59:30 -07:00
e7ca2bb791 fix: verbiage on pop-up for giving/receiving person/project 2025-12-20 19:09:47 -07:00
b864f1632d feat: remove the 'lock' from the giving & receiving sides 2025-12-20 18:58:18 -07:00
ffeac44b39 chore: bump version and add "-beta" 2025-12-18 20:29:03 -07:00
08d55519e6 chore: bump version to 1.1.4 2025-12-18 20:27:30 -07:00
bf8694fc75 fix: change the Apple group ID to something that works, ie group.app.timesafari.share 2025-12-18 20:26:02 -07:00
386b7604eb Merge pull request 'allow changing of both giver and receiver to projects or people' (#228) from giver-receiver-selectable into master
Reviewed-on: #228
2025-12-19 02:10:06 +00:00
9260892838 Revert "feat: make the new "+" appear centered on the home button"
This reverts commit fe1df9a9fb.
2025-12-18 08:56:54 -07:00
fe1df9a9fb feat: make the new "+" appear centered on the home button 2025-12-18 08:51:21 -07:00
7ef5889185 feat: make it so there's no jump when scrolling down on home feed, and the new "+" slowly appears 2025-12-18 08:46:36 -07:00
3a4cdf78d8 fix: show starred projects with most-recently-added first 2025-12-18 08:34:48 -07:00
0697b14411 refactor: change word on first button to "Thank", and center on button when it drops down 2025-12-18 08:30:07 -07:00
7aea818f01 feat: shrink & reword the button on the front, and put it at the bottom when they scroll down 2025-12-17 21:43:49 -07:00
d4a7c0dda0 feat: make the project/person selector on pop-up right-justified 2025-12-17 21:26:21 -07:00
34a7119086 feat: disallow selection of a person or project if it's already selected on the other side (giver/receiver) 2025-12-17 21:20:27 -07:00
70a0ef7ef6 feat: allow changing of both giver and receiver to projects or people 2025-12-17 21:08:09 -07:00
306e221479 feat: add starred projects at the top of the list to choose 2025-12-17 18:18:37 -07:00
4b118b0b91 Merge pull request 'web-share-target-native-implementation' (#227) from web-share-target-native-implementation into master
Reviewed-on: #227
2025-12-16 06:44:55 +00:00
3d2201fc17 fix: add intent-handling and automated stream-closing to Java 2025-12-10 18:53:08 -07:00
Jose Olarte III
bb92e3ac4f refactor(ios): preserve original image formats in share extension
Remove JPEG conversion and preserve original image formats and filenames
to test support for all image file types. Refactor image processing logic
for clarity and maintainability.

Changes:
- Remove forced JPEG conversion (was using 0.9 compression quality)
- Preserve original filename extensions instead of forcing .jpg
- Refactor from 3 branches to 2 (removed unlikely UIImage branch)
- Replace if statement with guard for non-image attachment filtering
- Simplify error check from optional binding to nil check
- Extract filename extension helper method to reduce duplication
- Update default filename from "shared-image.jpg" to "shared-image"

The only conversion that may occur is to PNG (lossless) when raw data
cannot be read from a file URL, preserving image quality while ensuring
compatibility.
2025-12-10 19:34:46 +08:00
Jose Olarte III
a672c977a8 Fix Android image share workflow and add local plugin persistence
- Add SafeArea and SharedImage plugins to capacitor.plugins.json
- Create restore-local-plugins.js to persist plugins after cap sync
- Integrate plugin restoration into build scripts
- Improve Android share intent timing with retry logic
- Clean up debug logging while keeping essential error handling
- Add documentation for local plugin management

Fixes issue where SharedImage plugin wasn't recognized, causing
"UNIMPLEMENTED" errors. Image sharing now works correctly on Android.
2025-12-09 21:36:01 +08:00
38b137a86b doc: enhance and dedup some build instructions 2025-12-08 19:59:48 -07:00
dbd18bba6c allow to see the discover page without the onboarding message 2025-12-08 19:57:58 -07:00
Jose Olarte III
0c66142093 Fix iOS share extension large image handling
- Fix large images not loading from share sheet (exceeded UserDefaults 4MB limit)
- Store all shared images as files in App Group container instead of base64 in UserDefaults
- Fix image refresh when sharing new image while already on SharedPhotoView
- Add route query watcher to detect _refresh parameter changes
- Remove debug logging and redundant UIImage check

The previous implementation tried to store base64-encoded images in UserDefaults,
which failed for large images (HEIF images were often 6MB+ when base64 encoded). Now all images
are stored as files in the App Group container, with only the file path stored
in UserDefaults. This supports images of any size and provides consistent
behavior regardless of format.

Also fixes an issue where sharing a new image while already viewing
SharedPhotoView wouldn't refresh the displayed image.
2025-12-04 20:39:26 +08:00
Jose Olarte III
84983ee10b refactor(shared-image): replace temp file approach with native Capacitor plugins
Replace the buggy temp file polling mechanism with direct native-to-JS
communication via custom Capacitor plugins for iOS and Android. This
eliminates race conditions, file management complexity, and polling overhead.

BREAKING CHANGE: Removes backward compatibility with temp file approach.
Android minSdkVersion upgraded from 22 to 23.

Changes:
- iOS: Created SharedImagePlugin (CAPBridgedPlugin) and SharedImageUtility
  - Uses App Group UserDefaults for data sharing between extension and app
  - Implements getSharedImage() and hasSharedImage() methods
  - Removes temp file writing/reading logic from AppDelegate
- Android: Created SharedImagePlugin with @CapacitorPlugin annotation
  - Uses SharedPreferences for data storage instead of temp files
  - Implements same interface as iOS plugin
  - Removes temp file handling from MainActivity
- TypeScript: Added plugin definitions and registration
  - Created SharedImagePlugin interface and web fallback
  - Updated main.capacitor.ts to use plugin instead of Filesystem API
  - Removed pollForFileExistence() and related file I/O code
- Android: Upgraded minSdkVersion from 22 to 23
  - Required for SharedPreferences improvements and better API support

Benefits:
- Eliminates race conditions from file polling
- Removes file cleanup complexity
- Direct native-to-JS communication (no file I/O)
- Better performance (no polling overhead)
- More reliable data transfer between share extension and app
- Cleaner architecture with proper plugin abstraction
2025-12-04 18:03:47 +08:00
Jose Olarte III
eeac7fdb66 fix(deps): align FontAwesome brands package version with other packages
Downgrade @fortawesome/free-brands-svg-icons from ^7.1.0 to ^6.5.1 to match
the version of @fortawesome/fontawesome-svg-core and other FontAwesome
packages. This resolves a TypeScript type error where IconDefinition from
the brands package was incompatible with the library.add() function due to
version mismatch.
2025-12-01 17:10:45 +08:00
Jose Olarte III
1a8383bc63 fix: update Android share intent handling for API 33+ compatibility
Fix deprecation warnings and ensure future compatibility by updating
getParcelableExtra() calls to use the new API introduced in Android 13
(API 33), while maintaining backward compatibility with older versions.

Changes:
- Update getParcelableExtra() to use typed version (Uri.class) on API 33+
- Update getParcelableArrayListExtra() to use typed version on API 33+
- Add version checks with Build.VERSION.SDK_INT >= TIRAMISU
- Maintain backward compatibility for API 22-32 using deprecated API
  with @SuppressWarnings("deprecation")
- No functional changes - share feature works identically across versions

The new API (getParcelableExtra(key, Class)) was introduced in API 33
to provide type safety. The old API is deprecated but still functional
on older versions, so we use runtime checks to support both.

This ensures the app builds cleanly with targetSdkVersion 36 and remains
compatible with minSdkVersion 22.
2025-11-28 16:43:36 +08:00
Jose Olarte III
4c771d8be3 fix: update iOS share extension deployment target and fix compatibility issues
Fix share extension not appearing in share sheet on iOS 18.6 and earlier
versions by updating deployment target and resolving API availability issues.

Changes:
- Update share extension deployment target from 26.1 to 14.0
  - iOS 18.6 cannot use extensions requiring iOS 26.1
  - UTType API requires iOS 14.0+ (used in ShareViewController)
- Update share extension display name from "TimeSafariShareExtension" to
  "TimeSafari" for cleaner appearance in share sheet
- Fix compiler warning: replace unused error binding with boolean check
  (if let error = error → if error != nil)

The share extension now works on iOS 14.0+ (matching UTType requirements)
and displays properly in the share sheet on older iOS versions.
2025-11-28 15:47:10 +08:00
Jose Olarte III
0627cd32b7 refactor: remove unused ShareImage plugin code, simplify to temp file approach
Remove unused Capacitor plugin implementation and simplify shared image
handling to use only the temp file approach, which is already working
reliably for both iOS and Android.

Changes:
- Delete ios/App/App/ShareImagePlugin.swift (unused, never registered)
- Delete ios/App/App/ShareImageBridge.swift (only used by plugin)
- Remove ~80 lines of plugin fallback code from src/main.capacitor.ts
- Simplify error handling to single code path
- Add documentation comment explaining temp file approach and future
  plugin implementation possibility

Benefits:
- ~154 lines of code removed
- Single, consistent code path for both platforms
- Easier to maintain and debug
- No functional changes - temp file approach already working

The temp file approach uses Capacitor's Filesystem plugin to read shared
image data written by native code (AppDelegate on iOS, MainActivity on
Android). This is simpler and more reliable than the plugin approach,
which required registration and bridge setup.

Future improvement: Consider implementing Capacitor plugins for direct
native-to-JS communication if lower latency becomes a priority, though
the current approach performs well in production.
2025-11-27 21:23:34 +08:00
Jose Olarte III
e1eb91f26d feat: Add Android share target support for image sharing
Implement native Android share functionality to allow users to share
images from other apps directly to TimeSafari. This mirrors the iOS
share extension functionality and provides a seamless cross-platform
experience.

Changes:
- Add ACTION_SEND and ACTION_SEND_MULTIPLE intent filters to
  AndroidManifest.xml to register the app as a share target for images
- Implement share intent handling in MainActivity.java:
  - Process incoming share intents in onCreate() and onNewIntent()
  - Read shared image data from content URI using ContentResolver
  - Convert image to Base64 encoding
  - Write image data to temporary JSON file in app's internal storage
  - Use getFilesDir() which maps to Capacitor's Directory.Data
- Update src/main.capacitor.ts to support Android platform:
  - Extend checkAndStoreNativeSharedImage() to check for Android platform
  - Use Directory.Data for Android file operations (vs Directory.Documents for iOS)
  - Add Android to initial load and app state change listeners
  - Ensure shared image detection works on app launch and activation

The implementation follows the same pattern as iOS:
- Native layer (MainActivity) writes shared image to temp file
- JavaScript layer polls for file existence with exponential backoff
- Image is stored in temp database and user is navigated to SharedPhotoView

This enables users to share images from Gallery, Photos, and other apps
directly to TimeSafari on Android devices.
2025-11-26 20:01:14 +08:00
Jose Olarte III
09a230f43e refactor(ios): improve file polling and extract shared image storage logic
Replace hardcoded timeout with reliable file existence polling and extract
duplicate image storage code into reusable function.

File Polling Improvements:
- Replace hardcoded 300ms timeout with pollForFileExistence() function
- Use Filesystem.stat() to check file existence instead of guessing timing
- Implement exponential backoff (100ms, 200ms, 400ms, 800ms, 1600ms)
- Maximum 5 retries with configurable parameters
- More reliable: actually checks if file exists rather than fixed wait time

Code Deduplication:
- Extract storeSharedImageInTempDB() function to eliminate code duplication
- Consolidates clearing old data, base64-to-dataURL conversion, and DB storage
- Used in both temp file and plugin fallback code paths
- Improves maintainability and reduces risk of inconsistencies

This makes the code more robust and maintainable while ensuring the file
is actually available before attempting to read it.
2025-11-25 18:56:45 +08:00
Jose Olarte III
eff4126043 feat(ios): improve share extension UX and fix image reload issues
Improve iOS share extension implementation to skip interstitial UI and
fix issues with subsequent image shares not updating the view.

iOS Share Extension Improvements:
- Replace SLComposeServiceViewController with custom UIViewController
  to eliminate interstitial "Post" button UI
- Use minimal URL (timesafari://) instead of deep link for app launch
- Implement app lifecycle detection via Capacitor appStateChange listener
  instead of relying solely on deep links

Deep Link and Navigation Fixes:
- Remove "shared-photo" from deep link schemas (no longer needed)
- Add empty path URL handling for share extension launches
- Implement processing lock to prevent duplicate image processing
- Add retry mechanism (300ms delay) to handle race conditions with
  AppDelegate writing temp files
- Use router.replace() when already on /shared-photo route to force refresh
- Clear old images from temp DB before storing new ones
- Delete temp file immediately after reading to prevent stale data

SharedPhotoView Component:
- Add route watcher (@Watch) to reload image when fileName query
  parameter changes
- Extract image loading logic into reusable loadSharedImage() method
- Improve error handling to clear image state on failures

This fixes the issue where sharing a second image while already on
SharedPhotoView would display the previous image instead of the new one.
2025-11-25 18:22:43 +08:00
Jose Olarte III
ae49c0e907 feat(ios): implement native share target for images
Implement iOS Share Extension to enable native image sharing from Photos
and other apps directly into TimeSafari. Users can now share images from
the iOS share sheet, which will open in SharedPhotoView for use as gifts
or profile pictures.

iOS Native Implementation:
- Add TimeSafariShareExtension target with ShareViewController
- Configure App Groups for data sharing between extension and main app
- Implement ShareViewController to process shared images and convert to base64
- Store shared image data in App Group UserDefaults
- Add ShareImageBridge utility to read shared data from App Group
- Update AppDelegate to handle shared-photo deep link and bridge data to JS

JavaScript Integration:
- Add checkAndStoreNativeSharedImage() in main.capacitor.ts to read shared
  images from native layer via temporary file bridge
- Convert base64 data to data URL format for compatibility with base64ToBlob
- Integrate with existing SharedPhotoView component
- Add "shared-photo" to deep link validation schema

Build System:
- Integrate Xcode 26 / CocoaPods compatibility workaround into build-ios.sh
- Add run_pod_install_with_workaround() for explicit pod install
- Add run_cap_sync_with_workaround() for Capacitor sync (which runs pod
  install internally)
- Automatically detect project format version 70 and apply workaround
- Remove standalone pod-install-workaround.sh script

Code Cleanup:
- Remove verbose debug logs from ShareViewController, AppDelegate, and
  main.capacitor.ts
- Retain essential logger calls for production debugging

Documentation:
- Add ios-share-extension-setup.md with manual Xcode setup instructions
- Add ios-share-extension-git-commit-guide.md for version control best practices
- Add ios-share-implementation-status.md tracking implementation progress
- Add native-share-target-implementation.md with overall architecture
- Add xcode-26-cocoapods-workaround.md documenting the compatibility issue

The implementation uses a temporary file bridge (AppDelegate writes to Documents
directory, JS reads via Capacitor Filesystem plugin) as a workaround for
Capacitor plugin auto-discovery issues. This can be improved in the future by
properly registering ShareImagePlugin in Capacitor's plugin registry.
2025-11-24 20:46:58 +08:00
Jose Olarte III
1b4ab7a500 fix(ios): add shared Xcode scheme for App target
Resolves "No Scheme" issue in Xcode by adding a shared scheme file.
Capacitor doesn't automatically create shared schemes, so this needs
to be manually added and committed to version control.
2025-11-24 16:20:12 +08:00
6ec2002cb0 Merge pull request 'refactor: move Import Contacts section to DataExportSection component' (#226) from accountview-contact-management-bundling into master
Reviewed-on: #226
2025-11-21 11:02:59 +00:00
Jose Olarte III
36eb9a16b0 fix: preserve contact methods and notes in export/import workflow
- Fix contactMethods not being exported (was checking for string instead of array)
- Add missing notes and iViewContent fields to $insertContact method
- Normalize empty strings to null when saving contacts in ContactEditView

This ensures contact data integrity is maintained during export/import operations.
2025-11-20 18:11:27 +08:00
7d295dd062 feat: make the contact methods more presentable, and clarify exact types 2025-11-19 20:00:42 -07:00
5f1b4dcc21 chore: bump version and add "-beta" 2025-11-19 20:00:09 -07:00
11f122552d chore: bump to version 1.1.3 number 48 2025-11-19 19:58:48 -07:00
c84a3b6705 add instructions to connect to any user profile (#224)
See https://app.clickup.com/t/86b76734v

Reviewed-on: #224
Co-authored-by: Trent Larson <trent@trentlarson.com>
Co-committed-by: Trent Larson <trent@trentlarson.com>
2025-11-19 18:58:49 +00:00
Jose Olarte III
203cf6b078 refactor(DataExportSection): rename section title to "Data Management"
Update the section title from "Data Export" to "Data Management" to better reflect that the component handles both data export and import functionality.
2025-11-19 21:32:35 +08:00
Jose Olarte III
9b84b28a78 refactor: move Import Contacts section to DataExportSection component
- Move Import Contacts UI section from AccountViewView to DataExportSection
- Consolidate import/export functionality in a single component
- Move related methods: uploadImportFile, showContactImport, checkContactImports
- Convert module-level ref to component property (inputImportFileName)
- Remove unused imports (ref, ImportContent) from AccountViewView
- Rename "Download Contacts" button to "Export Contacts"
- Improve import UI styling with full-width file input and button
2025-11-19 19:26:36 +08:00
e64902321f Merge pull request 'fix(GiftedDialog): preserve recipient when changing giver project' (#225) from gifted-dialog-recipient-fix into master
Reviewed-on: #225
2025-11-19 09:56:55 +00:00
7abce8f95c fix: don't count any changed projects on the front page that had blank differences 2025-11-18 19:53:52 -07:00
88dce4d100 fix: show a "project changed" entry if the server reports something 2025-11-18 19:49:40 -07:00
Jose Olarte III
c4eb6f2d1d fix(GiftedDialog): preserve recipient when changing giver project
Modified selectProject() to only set receiver to "You" if no receiver
has been selected yet, preventing recipient from being reset when
changing giver project in Project-to-Person context.
2025-11-18 15:50:11 +08:00
06fdaff879 Merge pull request 'entitygrid-infinite-scroll-improvements' (#223) from entitygrid-infinite-scroll-improvements into master
Reviewed-on: #223
2025-11-18 06:56:55 +00:00
8024a3d02a Merge pull request 'meeting-project-dialog' (#222) from meeting-project-dialog into master
Reviewed-on: #222
2025-11-18 06:56:23 +00:00
Jose Olarte III
223031866b refactor: remove unused loadMoreCallback prop from EntityGrid
Remove loadMoreCallback prop and related backward compatibility code.
No parent components were using this prop, and it has been superseded
by the internal pagination mechanism using fetchProjects() and beforeId.
2025-11-17 19:58:55 +08:00
Jose Olarte III
cb75b25529 refactor: consolidate project loading into EntityGrid component
Unify project loading and searching logic in EntityGrid.vue to eliminate
duplication. Make entities prop optional for projects, add internal
project state, and auto-load projects when needed.

- EntityGrid: Combine search/load into fetchProjects(), add internal
  allProjects state, handle pagination internally for both search and
  load modes
- OnboardMeetingSetupView: Remove project loading methods
- MeetingProjectDialog: Remove project props
- GiftedDialog: Remove project loading logic
- EntitySelectionStep: Make projects prop optional

Reduces code duplication by ~150 lines and simplifies component APIs.
All project selection now uses EntityGrid's internal loading.
2025-11-17 19:49:17 +08:00
83b470e28a fix: link from DID page to Help 2025-11-16 15:35:19 -07:00
Jose Olarte III
acf104eaa7 refactor: remove debug loggers from EntityGrid component
Remove three logger.debug() calls used for debugging project search
results and pagination state. Error logging remains intact.
2025-11-13 21:41:34 +08:00
Jose Olarte III
e793d7a9e2 refactor: defer project loading until MeetingProjectDialog opens
- Move loadProjects() call from created() to handleDialogOpen()
- Remove allProjects check from ensureSelectedProjectLoaded()
- Projects now load only when dialog is opened, improving initial page load performance
- ensureSelectedProjectLoaded() now directly fetches project by handleId when needed
2025-11-13 21:28:47 +08:00
Jose Olarte III
3ecae0be0f refactor(OnboardMeetingSetupView): fix selected project display after refresh
Refactor selectedProject computation to use separate storage instead of
relying on allProjects array. This fixes a bug where the selected project
wouldn't display after page refresh if it wasn't in the initial allProjects
batch.

Changes:
- Add selectedProjectData property to store selected project independently
- Simplify selectedProject computed to return selectedProjectData directly
- Add fetchProjectByHandleId() to fetch single project by handleId
- Add ensureSelectedProjectLoaded() to check allProjects first, then fetch
- Update handleProjectLinkAssigned() to store directly in selectedProjectData
- Remove band-aid solution of adding selected projects to allProjects array
- Update startEditing() and cancelEditing() to ensure selected project loads
- Call ensureSelectedProjectLoaded() in created() lifecycle hook

This ensures the selected project always displays correctly, even when:
- Selected from search results (not in allProjects)
- Page is refreshed (allProjects reloads without selected project)
- Project is in a later pagination batch
2025-11-13 19:21:22 +08:00
Jose Olarte III
d37e53b1a9 fix: pause MembersList auto-refresh during project dialog interaction
Stop auto-refresh when MeetingProjectDialog opens and resume when it closes
to prevent UI conflicts during project selection.
2025-11-13 18:10:35 +08:00
Jose Olarte III
2f89c7e13b feat(EntityGrid): add server-side search with pagination for projects
Implement server-side search for projects using API endpoint with
pagination support via beforeId parameter. Contacts continue using
client-side filtering from complete local database.

- Add PlatformServiceMixin for internal apiServer access
- Implement performProjectSearch() with pagination
- Update infinite scroll to handle search pagination
- Add search lifecycle management and error handling

No breaking changes to parent components.
2025-11-12 21:06:20 +08:00
Jose Olarte III
6bf4055c2f feat: add pagination support for project lists in dialogs
Add server-side pagination to EntityGrid component for projects, enabling
infinite scrolling to load all available projects instead of stopping after
the initial batch.

Changes:
- EntityGrid: Add loadMoreCallback prop to trigger server-side loading when
  scroll reaches end of loaded projects
- OnboardMeetingSetupView: Update loadProjects() to support pagination with
  beforeId parameter and add handleLoadMoreProjects() callback
- MeetingProjectDialog: Accept and pass through loadMoreCallback to EntityGrid
- GiftedDialog: Add pagination support to loadProjects() and
  handleLoadMoreProjects() callback
- EntitySelectionStep: Accept and pass through loadMoreCallback prop to
  EntityGrid when showing projects

This ensures users can access all projects in MeetingProjectDialog and
GiftedDialog by automatically loading more as they scroll, matching the
behavior already present in DiscoverView.

All project uses of EntityGrid now use pagination by default.
2025-11-12 17:10:03 +08:00
Jose Olarte III
bf7ee630d0 feat(meeting): enable selecting all projects in meeting setup
Update loadProjects to fetch all projects instead of only user's projects
by switching from plansByIssuer to plans endpoint.
2025-11-12 15:58:23 +08:00
1739567b18 Merge pull request 'feat: replace authorized representative input with contact selection dialog' (#219) from project-representative-dialog into master
Reviewed-on: #219
2025-11-12 01:42:48 -05:00
Jose Olarte III
a5a9af5ddc feat(meetings): add project selection dialog for meeting setup
Replace Project Link text input with interactive selection dialog
using new MeetingProjectDialog component. Dialog displays user's
projects with icons and issuer information, following the same
pattern as ProjectRepresentativeDialog.

- Create MeetingProjectDialog with EntityGrid integration
- Add clickable project field with icon, name, and issuer display
- Load projects from /api/v2/report/plansByIssuer endpoint
- Show issuer name instead of handleId for better UX
- Refactor loadProjects to remove unused rowId field
2025-11-11 21:34:11 +08:00
Jose Olarte III
4e3e293495 refactor(EntityGrid): simplify alphabetical section label
Change "Everyone Else" to "Everyone" for clearer, more concise labeling
2025-11-11 15:32:11 +08:00
Jose Olarte III
65533c15d2 Merge branch 'project-representative-dialog' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into project-representative-dialog 2025-11-11 15:15:01 +08:00
Jose Olarte III
2530bc0ec2 fix: ensure consistent "Recently Added" contacts in ProjectRepresentativeDialog
EntityGrid's recentContacts assumes contacts are sorted by date added
(newest first), but ProjectRepresentativeDialog was receiving contacts
sorted alphabetically from NewEditProjectView, causing it to show
different "Recently Added" contacts than GiftedDialog.

- Changed NewEditProjectView to use $contactsByDateAdded() instead of
  $getAllContacts()
- Added documentation comments to EntityGrid.vue to prevent this issue
  in future reuses
2025-11-11 15:06:07 +08:00
b1fa6ac458 feat: show the recent contacts in the alphabetical section of choosers 2025-11-07 18:27:05 -07:00
9ff24f8258 fix: in project-edit view, don't show agent warning on new one, and automatically switch if they're changing 2025-11-07 18:11:11 -07:00
Jose Olarte III
9a3409c29f refactor: remove unused code from ProjectRepresentativeDialog
- Remove conflictChecker prop (always passed as no-op function)
- Remove unused emitCancel method and cancel event handling
- Simplify handleEntitySelected by removing unnecessary type check
- Update NewEditProjectView to remove conflict-checker binding and empty cancel handler

The conflictChecker prop was not needed since representative selection
doesn't require conflict detection. The cancel event was never emitted
and the parent handler was empty, so both were removed.
2025-11-07 17:43:44 +08:00
Jose Olarte III
a142737771 feat: replace authorized representative input with contact selection dialog
Replace the plain text input for authorized representative with an
interactive contact selection interface that provides better UX and
maintains data consistency.

Changes:
- Add ProjectRepresentativeDialog component using EntityGrid for contact selection (excludes "You" and "Unnamed" special entities)
- Replace text input with clickable field showing contact icon, name, and DID
- Implement conditional UI states: initial "Assign..." placeholder vs assigned representative display with unset button
- Refactor selectedRepresentative to computed property derived from agentDid (single source of truth, prevents sync issues)
- Inline representativeDisplayName for simplicity
- Support changing representative by clicking on assigned field
- Support unsetting representative via trash button

The new implementation ensures agentDid remains the authoritative state while selectedRepresentative is automatically computed, preventing the previously possible desync when agentDid was set directly (e.g., via the
"make original owner an authorized representative" button).
2025-11-05 20:20:43 +08:00
105 changed files with 9881 additions and 853 deletions

View File

@@ -175,27 +175,6 @@ cp .env.example .env.development
### Troubleshooting Quick Fixes
#### Common Issues
```bash
# Clean and rebuild
npm run clean:all
npm install
npm run build:web:dev
# Reset mobile projects
npm run clean:ios
npm run clean:android
npm run build:ios # Regenerates iOS project
npm run build:android # Regenerates Android project
# Fix Android asset issues
npm run assets:validate:android # Validates and regenerates missing Android assets
# Check environment
npm run test:web # Verifies web setup
```
#### Platform-Specific Issues
- **iOS**: Ensure Xcode and Command Line Tools are installed
@@ -385,14 +364,13 @@ rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safa
(Note: The test BVC_MEETUPS_PROJECT_CLAIM_ID does not resolve as a URL because it's only in the test DB and the prod redirect won't redirect there.)
- For prod, get on the server and run the correct build:
- For prod, you can do the same with `build:web:prod` instead.
... and log onto the server:
... or log onto the server (though the build step can stay on "rendering chunks" for a long while):
- `pkgx +npm sh`
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout
1.0.2 && npm install && npm run build:web:prod && cd -`
- `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web:prod && cd -`
(The plain `npm run build:web:prod` uses the .env.production file.)
@@ -401,6 +379,50 @@ rsync -azvu -e "ssh -i ~/.ssh/..." dist ubuntutest@test.timesafari.app:time-safa
- Record the new hash in the changelog. Edit package.json to increment version &
add "-beta", `npm install`, commit, and push. Also record what version is on production.
## WebAuthn Verification Server
TimeSafari includes a server-side WebAuthn verification service for secure passkey registration and authentication. This server must be running for passkey features to work (unless offline mode is enabled).
### Quick Setup
```bash
# Navigate to server directory
cd server
# Install dependencies
npm install
# Copy and configure environment
cp .env.example .env
# Edit .env with your RP_ID, RP_NAME, RP_ORIGIN
# Start development server
npm run dev
```
The server runs on `http://localhost:3002` by default.
### Production Deployment
For production, you'll need to:
1. **Configure environment variables** in `.env`:
- `RP_ID`: Your domain (e.g., `timesafari.app`)
- `RP_NAME`: Application name
- `RP_ORIGIN`: Your app's origin URL
- `PORT`: Server port (default: 3002)
2. **Replace in-memory storage** with:
- Redis for challenge storage
- Database for credential persistence
- Session management for user binding
3. **Deploy the server** alongside your main application
4. **Configure client** via `VITE_WEBAUTHN_SERVER_URL` environment variable
See [server/README.md](server/README.md) for complete API documentation and deployment guide.
## Docker Deployment
The application can be containerized using Docker for consistent deployment across
@@ -1142,12 +1164,13 @@ If you need to build manually or want to understand the individual steps:
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
- In the App Developer setup (eg. https://developer.apple.com/account), under Identifiers and/or "Certificates, Identifiers & Profiles"
#### Each Release
##### 0. First time (or if dependencies change)
- `pkgx +rubygems.org sh`
- `pkgx +rubygems.org zsh`
- ... and you may have to fix these, especially with pkgx:
@@ -1158,10 +1181,10 @@ export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
```bash
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App && xcrun agvtool new-version 50 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.5;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
@@ -1304,8 +1327,8 @@ The recommended way to build for Android is using the automated build script:
# Standard build and open Android Studio
./scripts/build-android.sh
# Build with specific version numbers
./scripts/build-android.sh --version 1.0.3 --build-number 35
# Build with specific version numbers -- doesn't change source files
#./scripts/build-android.sh --version 1.1.3 --build-number 48
# Build without opening Android Studio (for CI/CD)
./scripts/build-android.sh --no-studio
@@ -1319,8 +1342,8 @@ The recommended way to build for Android is using the automated build script:
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
perl -p -i -e 's/versionCode .*/versionCode 50/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.5"/g' android/app/build.gradle
```
##### 2. Build
@@ -1555,6 +1578,7 @@ VITE_APP_SERVER=https://timesafari.app
# Feature Flags
VITE_PASSKEYS_ENABLED=true
VITE_WEBAUTHN_SERVER_URL=http://localhost:3002
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
```
@@ -1568,6 +1592,9 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_APP_SERVER=http://localhost:8080
# WebAuthn Server (for passkey verification)
VITE_WEBAUTHN_SERVER_URL=http://localhost:3002
```
**Test Environment** (`.env.test`):
@@ -1745,14 +1772,12 @@ npx prettier --write ./sw_scripts/
The `prebuild` script automatically runs before any build:
```json
"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 scripts/copy-wasm.js"
```
**What happens automatically:**
- **ESLint**: Checks and fixes code formatting in `src/`
- **Script Combination**: `sw_combine.js` combines all `sw_scripts/*.js` files
into `sw_scripts-combined.js`
- **WASM Copy**: `copy-wasm.js` copies SQLite WASM files to `public/wasm/`
#### Build Process Architecture
@@ -1760,10 +1785,10 @@ The `prebuild` script automatically runs before any build:
**Web Build Process:**
```text
1. Pre-Build: ESLint + Script Combination + WASM Copy
1. Pre-Build: ESLint + WASM Copy
2. Environment Setup: Load .env files, set NODE_ENV
3. Vite Build: Bundle web assets with PWA support
4. Service Worker: Inject combined scripts into PWA
4. Service Worker: Inject service worker scripts into PWA
5. Output: Production-ready files in dist/
```
@@ -1791,10 +1816,8 @@ The `prebuild` script automatically runs before any build:
**Script Organization:**
- `sw_scripts/` - Individual third-party scripts
- `sw_combine.js` - Combines scripts into single file
- `sw_scripts-combined.js` - Combined service worker (317KB, 10K+ lines)
- `vite.config.utils.mts` - PWA configuration using combined script
- `sw_scripts/` - Individual third-party scripts for service worker
- `vite.config.utils.mts` - PWA configuration
**PWA Integration:**
@@ -1802,18 +1825,16 @@ The `prebuild` script automatically runs before any build:
// vite.config.utils.mts
pwaConfig: {
strategies: "injectManifest",
filename: "sw_scripts-combined.js", // Uses our combined script
filename: "sw_scripts-combined.js", // Service worker file
// ... manifest configuration
}
```
**What Gets Combined:**
**Service Worker Scripts:**
- `nacl.js` - NaCl cryptographic library
- `noble-curves.js` - Elliptic curve cryptography (177KB)
- `noble-hashes.js` - Cryptographic hash functions (91KB)
- `safari-notifications.js` - Safari-specific notifications
- `additional-scripts.js` - Additional service worker functionality
#### Process Environment Configuration
@@ -1849,6 +1870,7 @@ VITE_APP_SERVER=https://timesafari.app
# Feature Flags
VITE_PASSKEYS_ENABLED=true
VITE_WEBAUTHN_SERVER_URL=http://localhost:3002
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
```

View File

@@ -6,8 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.2] - 2025.11.06
## [1.1.5] - 2025.12.28
### Fixed
- Incorrect prompts in give-dialog on a project or offer
## [1.1.4] - 2025.12.18
### Fixed
- Contact notes & contact methods preserved in export
### Added
- This is a target for sharing
- Switch to a project or person in give-dialog pop-up
- Starred projects onto project-choice in give-dialog pop-up
### Changed
- Front page: 1 green "Thank" button
## [1.1.3] - 2025.11.19
### Changed
- Project selection in dialogs now reaches out to server when filtering
- Project selection during onboarding meeting is a search (not an input box)
- Improve the switching of agent when agent edits a project
### Fixed
- Reassignment of "you" as recipient when changing giver project
- Bad counts for project-change notification on front page
## [1.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed

View File

@@ -89,6 +89,65 @@ VITE_LOG_LEVEL=debug npm run build:web:dev
See [Logging Configuration Guide](doc/logging-configuration.md) for complete details.
## WebAuthn Verification Server
TimeSafari includes a server-side WebAuthn verification service for secure passkey registration and authentication.
### Why a Separate Server?
WebAuthn verification **must** be performed server-side for security. Client-side verification can be tampered with and should never be trusted. The server:
- Verifies attestation signatures during registration
- Validates authentication signatures during login
- Prevents replay attacks by tracking counters
- Stores credentials securely with proper user binding
- Enforces origin and RP ID validation
**Note**: The client includes an optional "offline mode" for development (`VITE_OFFLINE_WEBAUTHN_VERIFY=true`), but this is not recommended for production as it compromises security.
### Quick Start
```bash
# Navigate to server directory
cd server
# Install dependencies
npm install
# Copy environment template
cp .env.example .env
# Edit .env with your configuration
# RP_ID=your-domain.com
# RP_NAME=Time Safari
# RP_ORIGIN=https://your-app-url.com
# Start development server
npm run dev
```
The server runs on `http://localhost:3002` by default (configurable via `PORT` in `.env`).
### Documentation
See [server/README.md](server/README.md) for:
- Complete API documentation
- Endpoint specifications
- Production deployment guide
- Security considerations
### Client Configuration
The client automatically uses the server when `VITE_OFFLINE_WEBAUTHN_VERIFY` is not set to `true`. Configure the server URL via:
- Environment variable: `VITE_WEBAUTHN_SERVER_URL`
- Defaults to `http://localhost:3002` in development
- Defaults to same origin in production
### Development Database Clearing
TimeSafari provides a simple script-based approach to clear the local database (not the claim server) for development purposes.
### Quick Usage
```bash
# Run the database clearing script
@@ -279,13 +338,11 @@ The application uses a platform-agnostic database layer with Vue mixins for serv
* `src/services/PlatformServiceFactory.ts` - Platform-specific service factory
* `src/services/AbsurdSqlDatabaseService.ts` - SQLite implementation
* `src/utils/PlatformServiceMixin.ts` - Vue mixin for database access with caching
* `src/db/` - Legacy Dexie database (migration in progress)
**Development Guidelines**:
- Always use `PlatformServiceMixin` for database operations in components
- Test with PlatformServiceMixin for new features
- Use migration tools for data transfer between systems
- Leverage mixin's ultra-concise methods: `$db()`, `$exec()`, `$one()`, `$contacts()`, `$settings()`
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
@@ -305,21 +362,9 @@ timesafari/
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
```
## Known Issues
### Critical Vue Reactivity Bug
A critical Vue reactivity bug was discovered during ActiveDid migration testing where component properties fail to trigger template updates correctly.
**Impact**: The `newDirectOffersActivityNumber` element in HomeView.vue requires a watcher workaround to render correctly.
**Status**: Workaround implemented, investigation ongoing.
**Documentation**: See [Vue Reactivity Bug Report](doc/vue-reactivity-bug-report.md) for details.
## 🤝 Contributing
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
2. **Use the PR template** - Complete the checklist for build-related changes
3. **Test your changes** - Ensure builds work on affected platforms
4. **Document updates** - Keep BUILDING.md current and accurate

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 47
versionName "1.1.2"
versionCode 50
versionName "1.1.5"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -27,6 +27,20 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
<!-- Share Target Intent Filter - Single Image -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Share Target Intent Filter - Multiple Images (optional, we'll handle first image) -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<provider

View File

@@ -34,5 +34,13 @@
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
},
{
"pkg": "SafeArea",
"classpath": "app.timesafari.safearea.SafeAreaPlugin"
},
{
"pkg": "SharedImage",
"classpath": "app.timesafari.sharedimage.SharedImagePlugin"
}
]

View File

@@ -1,6 +1,10 @@
package app.timesafari;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Base64;
import android.util.Log;
import android.view.View;
import android.view.WindowManager;
import android.view.WindowInsetsController;
@@ -11,9 +15,21 @@ import android.webkit.WebSettings;
import android.webkit.WebViewClient;
import com.getcapacitor.BridgeActivity;
import app.timesafari.safearea.SafeAreaPlugin;
import app.timesafari.sharedimage.SharedImagePlugin;
//import com.getcapacitor.community.sqlite.SQLite;
import android.content.SharedPreferences;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class MainActivity extends BridgeActivity {
private static final String TAG = "MainActivity";
private static final String SHARED_PREFS_NAME = "shared_image";
private static final String KEY_BASE64 = "shared_image_base64";
private static final String KEY_FILE_NAME = "shared_image_file_name";
private static final String KEY_READY = "shared_image_ready";
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -48,9 +64,156 @@ public class MainActivity extends BridgeActivity {
// Register SafeArea plugin
registerPlugin(SafeAreaPlugin.class);
// Register SharedImage plugin
registerPlugin(SharedImagePlugin.class);
// Initialize SQLite
//registerPlugin(SQLite.class);
// Handle share intent if app was launched from share sheet
handleShareIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleShareIntent(intent);
}
/**
* Handle share intents (ACTION_SEND or ACTION_SEND_MULTIPLE)
* Processes shared images and stores them in SharedPreferences for plugin to read
*/
private void handleShareIntent(Intent intent) {
if (intent == null) {
return;
}
String action = intent.getAction();
String type = intent.getType();
boolean handled = false;
// Handle single image share
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
Uri imageUri;
// Use new API for API 33+ (Android 13+), fall back to deprecated API for older versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
// Deprecated but still works on older versions
@SuppressWarnings("deprecation")
Uri uri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
imageUri = uri;
}
if (imageUri != null) {
String fileName = intent.getStringExtra(Intent.EXTRA_TEXT);
processSharedImage(imageUri, fileName);
handled = true;
}
}
// Handle multiple images share (we'll just process the first one)
else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
java.util.ArrayList<Uri> imageUris;
// Use new API for API 33+ (Android 13+), fall back to deprecated API for older versions
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri.class);
} else {
// Deprecated but still works on older versions
@SuppressWarnings("deprecation")
java.util.ArrayList<Uri> uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
imageUris = uris;
}
if (imageUris != null && !imageUris.isEmpty()) {
processSharedImage(imageUris.get(0), null);
handled = true;
}
}
// Clear the intent after handling to release URI permissions and prevent
// network issues in WebView. This is critical for preventing the WebView
// from losing network connectivity after processing shared content.
if (handled) {
intent.setAction(null);
intent.setData(null);
intent.removeExtra(Intent.EXTRA_STREAM);
intent.setType(null);
setIntent(new Intent());
Log.d(TAG, "Cleared share intent after processing");
}
}
/**
* Process a shared image: read it, convert to base64, and write to temp file
* Uses try-with-resources to ensure proper stream cleanup and prevent network issues
*/
private void processSharedImage(Uri imageUri, String fileName) {
// Extract filename from URI or use default (do this before opening streams)
String actualFileName = fileName;
if (actualFileName == null || actualFileName.isEmpty()) {
String path = imageUri.getPath();
if (path != null) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
actualFileName = path.substring(lastSlash + 1);
}
}
if (actualFileName == null || actualFileName.isEmpty()) {
actualFileName = "shared-image.jpg";
}
}
// Use try-with-resources to ensure streams are properly closed
// This is critical to prevent resource leaks that can affect WebView networking
try (InputStream inputStream = getContentResolver().openInputStream(imageUri);
ByteArrayOutputStream buffer = new ByteArrayOutputStream()) {
if (inputStream == null) {
Log.e(TAG, "Failed to open input stream for shared image");
return;
}
// Read image bytes
byte[] data = new byte[8192];
int nRead;
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
buffer.flush();
byte[] imageBytes = buffer.toByteArray();
// Convert to base64
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
// Store in SharedPreferences for plugin to read
storeSharedImageInPreferences(base64String, actualFileName);
Log.d(TAG, "Successfully processed shared image: " + actualFileName);
} catch (IOException e) {
Log.e(TAG, "Error processing shared image", e);
} catch (Exception e) {
Log.e(TAG, "Unexpected error processing shared image", e);
}
}
/**
* Store shared image data in SharedPreferences for plugin to read
* Plugin will read and clear the data when called
*/
private void storeSharedImageInPreferences(String base64, String fileName) {
try {
SharedPreferences prefs = getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putString(KEY_BASE64, base64);
editor.putString(KEY_FILE_NAME, fileName);
editor.putBoolean(KEY_READY, true);
editor.apply();
Log.d(TAG, "Stored shared image data in SharedPreferences");
} catch (Exception e) {
Log.e(TAG, "Error storing shared image in SharedPreferences", e);
}
}
}

View File

@@ -0,0 +1,84 @@
package app.timesafari.sharedimage;
import android.content.Context;
import android.content.SharedPreferences;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "SharedImage")
public class SharedImagePlugin extends Plugin {
private static final String SHARED_PREFS_NAME = "shared_image";
private static final String KEY_BASE64 = "shared_image_base64";
private static final String KEY_FILE_NAME = "shared_image_file_name";
private static final String KEY_READY = "shared_image_ready";
/**
* Get shared image data from SharedPreferences
* Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading
*/
@PluginMethod
public void getSharedImage(PluginCall call) {
try {
SharedPreferences prefs = getSharedPreferences();
String base64 = prefs.getString(KEY_BASE64, null);
String fileName = prefs.getString(KEY_FILE_NAME, null);
if (base64 == null || fileName == null) {
// No shared image exists - return null values (not an error)
JSObject result = new JSObject();
result.put("base64", (String) null);
result.put("fileName", (String) null);
call.resolve(result);
return;
}
// Clear the shared data after reading
SharedPreferences.Editor editor = prefs.edit();
editor.remove(KEY_BASE64);
editor.remove(KEY_FILE_NAME);
editor.remove(KEY_READY);
editor.apply();
// Return the shared image data
JSObject result = new JSObject();
result.put("base64", base64);
result.put("fileName", fileName);
call.resolve(result);
} catch (Exception e) {
android.util.Log.e("SharedImagePlugin", "Error in getSharedImage()", e);
call.reject("Error getting shared image: " + e.getMessage());
}
}
/**
* Check if shared image exists without reading it
* Useful for quick checks before calling getSharedImage()
*/
@PluginMethod
public void hasSharedImage(PluginCall call) {
SharedPreferences prefs = getSharedPreferences();
boolean hasImage = prefs.contains(KEY_BASE64) && prefs.contains(KEY_FILE_NAME);
JSObject result = new JSObject();
result.put("hasImage", hasImage);
call.resolve(result);
}
/**
* Get SharedPreferences instance for shared image data
*/
private SharedPreferences getSharedPreferences() {
Context context = getContext();
if (context == null) {
throw new IllegalStateException("Plugin context is null");
}
return context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE);
}
}

View File

@@ -1,5 +1,5 @@
ext {
minSdkVersion = 22
minSdkVersion = 23
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.8.0'

View File

@@ -105,8 +105,7 @@ Build Scripts:
├── electron/** # Electron build files
├── android/** # Android build configuration
├── ios/** # iOS build configuration
── sw_scripts/** # Service worker scripts
└── sw_combine.js # Service worker combination
── sw_scripts/** # Service worker scripts
Deployment:
├── Dockerfile # Docker configuration

View File

@@ -0,0 +1,259 @@
# Android API 23 Upgrade Impact Analysis
**Date:** 2025-12-03
**Current minSdkVersion:** 22 (Android 5.1 Lollipop)
**Proposed minSdkVersion:** 23 (Android 6.0 Marshmallow)
**Impact Assessment:** Low to Moderate
## Executive Summary
Upgrading from API 22 to API 23 will have **minimal code impact** but may affect device compatibility. The main change is that API 23 introduced runtime permissions, but since the app uses Capacitor plugins which handle permissions, the impact is minimal.
## Code Impact Analysis
### ✅ No Breaking Changes in Existing Code
#### 1. API Level Checks in Code
All existing API level checks are for **much higher APIs** than 23, so they won't be affected:
**MainActivity.java:**
- `Build.VERSION_CODES.R` (API 30+) - Edge-to-edge display
- `Build.VERSION_CODES.TIRAMISU` (API 33+) - Intent extras handling
- Legacy path (API 21-29) - Will still work, but API 22 devices won't be supported
**SafeAreaPlugin.java:**
- `Build.VERSION_CODES.R` (API 30+) - Safe area insets
**Conclusion:** No code changes needed for API level checks.
#### 2. Permissions Handling
**Current Permissions in AndroidManifest.xml:**
- `INTERNET` - Normal permission (no runtime needed)
- `READ_EXTERNAL_STORAGE` - Dangerous permission (runtime required on API 23+)
- `WRITE_EXTERNAL_STORAGE` - Dangerous permission (runtime required on API 23+)
- `CAMERA` - Dangerous permission (runtime required on API 23+)
**Current Implementation:**
- ✅ App uses **Capacitor plugins** for camera and file access
- ✅ Capacitor plugins **already handle runtime permissions** automatically
- ✅ No manual permission request code found in the codebase
- ✅ QR Scanner uses Capacitor's BarcodeScanner plugin which handles permissions
**Conclusion:** No code changes needed - Capacitor handles runtime permissions automatically.
#### 3. Dependencies Compatibility
**AndroidX Libraries:**
- `androidx.appcompat:appcompat:1.6.1` - ✅ Supports API 23+
- `androidx.core:core:1.12.0` - ✅ Supports API 23+
- `androidx.fragment:fragment:1.6.2` - ✅ Supports API 23+
- `androidx.coordinatorlayout:coordinatorlayout:1.2.0` - ✅ Supports API 23+
- `androidx.core:core-splashscreen:1.0.1` - ✅ Supports API 23+
**Capacitor Plugins:**
- `@capacitor/core:6.2.0` - ✅ Requires API 23+ (official requirement)
- `@capacitor/camera:6.0.0` - ✅ Handles runtime permissions
- `@capacitor/filesystem:6.0.0` - ✅ Handles runtime permissions
- `@capacitor-community/sqlite:6.0.2` - ✅ Supports API 23+
- `@capacitor-mlkit/barcode-scanning:6.0.0` - ✅ Supports API 23+
**Third-Party Libraries:**
- No Firebase or other libraries with API 22-specific requirements found
- All dependencies appear compatible with API 23+
**Conclusion:** All dependencies are compatible with API 23.
#### 4. Build Configuration
**Current Configuration:**
- `compileSdkVersion = 36` (Android 14)
- `targetSdkVersion = 36` (Android 14)
- `minSdkVersion = 22` (Android 5.1) ← **Only this needs to change**
**Required Change:**
```gradle
// android/variables.gradle
ext {
minSdkVersion = 23 // Change from 22 to 23
// ... rest stays the same
}
```
**Conclusion:** Only one line needs to be changed.
## Device Compatibility Impact
### Device Coverage Loss
**API 22 (Android 5.1 Lollipop):**
- Released: March 2015
- Market share: ~0.1% of active devices (as of 2024)
- Devices affected: Very old devices from 2015-2016
**API 23 (Android 6.0 Marshmallow):**
- Released: October 2015
- Market share: ~0.3% of active devices (as of 2024)
- Still very low, but slightly higher than API 22
**Impact:** Losing support for ~0.1% of devices (essentially negligible)
### User Base Impact
**Recommendation:** Check your analytics to see actual usage:
- If you have analytics, check percentage of users on API 22
- If < 0.5%, upgrade is safe
- If > 1%, consider the business impact
## Runtime Permissions (API 23 Feature)
### What Changed in API 23
**Before API 23 (API 22 and below):**
- Permissions granted at install time
- User sees all permissions during installation
- No runtime permission dialogs
**API 23+ (Runtime Permissions):**
- Dangerous permissions must be requested at runtime
- User sees permission dialogs when app needs them
- Better user experience and privacy
### Current App Status
**✅ Already Compatible:**
- App uses Capacitor plugins which **automatically handle runtime permissions**
- Camera plugin requests permissions when needed
- Filesystem plugin requests permissions when needed
- No manual permission code needed
**Conclusion:** App is already designed for runtime permissions via Capacitor.
## Potential Issues to Watch
### 1. APK Size
- Some developers report APK size increases after raising minSdkVersion
- **Action:** Monitor APK size after upgrade
- **Expected Impact:** Minimal (API 22 → 23 is a small jump)
### 2. Testing Requirements
- Need to test on API 23+ devices
- **Action:** Test on Android 6.0+ devices/emulators
- **Current:** App likely already tested on API 23+ devices
### 3. Legacy Code Path
- MainActivity has legacy code for API 21-29
- **Impact:** This code will still work, but API 22 devices won't be supported
- **Action:** No code changes needed, but legacy path becomes API 23-29
### 4. Capacitor Compatibility
- Capacitor 6.2.0 officially requires API 23+
- **Current Situation:** App runs on API 22 (may be working due to leniency)
- **After Upgrade:** Officially compliant with Capacitor requirements
- **Benefit:** Better compatibility guarantees
## Files That Need Changes
### 1. Build Configuration
**File:** `android/variables.gradle`
```gradle
ext {
minSdkVersion = 23 // Change from 22
// ... rest unchanged
}
```
### 2. Documentation
**Files to Update:**
- `doc/shared-image-plugin-implementation-plan.md` - Update version notes
- Any README files mentioning API 22
- Build documentation
### 3. No Code Changes Required
- ✅ No Java/Kotlin code changes needed
- ✅ No AndroidManifest.xml changes needed
- ✅ No permission handling code changes needed
## Testing Checklist
After upgrading to API 23, test:
- [ ] App builds successfully
- [ ] App installs on API 23 device/emulator
- [ ] Camera functionality works (permissions requested)
- [ ] File access works (permissions requested)
- [ ] Share functionality works
- [ ] QR code scanning works
- [ ] Deep linking works
- [ ] All Capacitor plugins work correctly
- [ ] No crashes or permission-related errors
- [ ] APK size is acceptable
## Rollback Plan
If issues arise:
1. Revert `android/variables.gradle` to `minSdkVersion = 22`
2. Rebuild and test
3. Document issues encountered
4. Address issues before retrying upgrade
## Recommendation
### ✅ **Proceed with Upgrade**
**Reasons:**
1. **Minimal Code Impact:** Only one line needs to change
2. **Already Compatible:** App uses Capacitor which handles runtime permissions
3. **Device Impact:** Negligible (~0.1% of devices)
4. **Capacitor Compliance:** Officially meets Capacitor 6 requirements
5. **Future-Proofing:** Better alignment with modern Android development
**Timeline:**
- **Low Risk:** Can be done anytime
- **Recommended:** Before implementing SharedImagePlugin (cleaner baseline)
- **Testing:** 1-2 hours of testing on API 23+ devices
## Migration Steps
1. **Update Build Configuration:**
```bash
# Edit android/variables.gradle
minSdkVersion = 23
```
2. **Sync Gradle:**
```bash
cd android
./gradlew clean
```
3. **Build and Test:**
```bash
npm run build:android:test
# Test on API 23+ device/emulator
```
4. **Verify Permissions:**
- Test camera access
- Test file access
- Verify permission dialogs appear
5. **Update Documentation:**
- Update any docs mentioning API 22
- Update implementation plan
## Summary
| Aspect | Impact | Status |
|--------|--------|--------|
| **Code Changes** | None required | ✅ Safe |
| **Dependencies** | All compatible | ✅ Safe |
| **Permissions** | Already handled | ✅ Safe |
| **Device Coverage** | ~0.1% loss | ⚠️ Minimal |
| **Build Config** | 1 line change | ✅ Simple |
| **Testing** | Standard testing | ✅ Required |
| **Risk Level** | Low | ✅ Low Risk |
**Final Recommendation:** Proceed with upgrade. The benefits (Capacitor compliance, future-proofing) outweigh the minimal risks (negligible device loss, no code changes needed).

View File

@@ -0,0 +1,139 @@
# iOS Share Extension - Git Commit Guide
**Date:** 2025-01-27
**Purpose:** Clarify which Xcode manual changes should be committed to the repository
## Quick Answer
**YES, most manual Xcode changes SHOULD be committed.** The only exceptions are user-specific settings that are already gitignored.
## What Gets Modified (and Should Be Committed)
When you create the Share Extension target and configure App Groups in Xcode, the following files are modified:
### 1. `ios/App/App.xcodeproj/project.pbxproj` ✅ **COMMIT THIS**
This is the main Xcode project file that tracks:
- **New targets** (Share Extension target)
- **File references** (which files belong to which targets)
- **Build settings** (compiler flags, deployment targets, etc.)
- **Build phases** (compile sources, link frameworks, etc.)
- **Capabilities** (App Groups configuration)
- **Target dependencies**
**This file IS tracked in git** (not in `.gitignore`), so changes should be committed.
### 2. Entitlements Files ✅ **COMMIT THESE**
When you enable App Groups capability, Xcode creates/modifies:
- `ios/App/App/App.entitlements` (for main app)
- `ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements` (for extension)
These files contain the App Group identifiers and should be committed.
### 3. Share Extension Source Files ✅ **ALREADY COMMITTED**
The following files are already in the repo:
- `ios/App/TimeSafariShareExtension/ShareViewController.swift`
- `ios/App/TimeSafariShareExtension/Info.plist`
- `ios/App/App/ShareImageBridge.swift`
These should already be committed (they were created as part of the implementation).
## What Should NOT Be Committed
### 1. User-Specific Settings ❌ **ALREADY GITIGNORED**
These are in `ios/.gitignore`:
- `xcuserdata/` - User-specific scheme selections, breakpoints, etc.
- `*.xcuserstate` - User's current Xcode state
### 2. Signing Identities ❌ **USER-SPECIFIC**
While the **App Groups capability** should be committed (it's in `project.pbxproj` and entitlements), your **personal signing identity/team** is user-specific and Xcode handles this automatically per developer.
## What Happens When You Commit
When you commit the changes:
1. **Other developers** who pull the changes will:
- ✅ Get the new Share Extension target automatically
- ✅ Get the App Groups capability configuration
- ✅ Get file references and build settings
- ✅ See the Share Extension in their Xcode project
2. **They will still need to:**
- Configure their own signing team/identity (Xcode prompts for this)
- Build the project (which may trigger CocoaPods updates)
- But they **won't** need to manually create the target or configure App Groups
## Step-by-Step: What to Commit
After completing the Xcode setup steps:
```bash
# Check what changed
git status
# You should see:
# - ios/App/App.xcodeproj/project.pbxproj (modified)
# - ios/App/App/App.entitlements (new or modified)
# - ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements (new)
# - Possibly other project-related files
# Review the changes
git diff ios/App/App.xcodeproj/project.pbxproj
# Commit the changes
git add ios/App/App.xcodeproj/project.pbxproj
git add ios/App/App/App.entitlements
git add ios/App/TimeSafariShareExtension/TimeSafariShareExtension.entitlements
git commit -m "Add iOS Share Extension target and App Groups configuration"
```
## Important Notes
### Merge Conflicts in project.pbxproj
The `project.pbxproj` file can have merge conflicts because:
- It's auto-generated by Xcode
- Multiple developers might modify it
- It uses UUIDs that can conflict
**If you get merge conflicts:**
1. Open the project in Xcode
2. Xcode will often auto-resolve conflicts
3. Or manually resolve by keeping both sets of changes
4. Test that the project builds
### Team/Developer IDs
The `DEVELOPMENT_TEAM` setting in `project.pbxproj` might be user-specific:
- Some teams commit this (if everyone uses the same team)
- Some teams use `.xcconfig` files to override per developer
- Check with your team's practices
If you see `DEVELOPMENT_TEAM = GM3FS5JQPH;` in the project file, this is already committed, so your team likely commits team IDs.
## Verification
After committing, verify that:
1. The Share Extension target appears in Xcode for other developers
2. App Groups capability is configured
3. The project builds successfully
4. No user-specific files were accidentally committed
## Summary
| Change Type | Commit? | Reason |
|------------|---------|--------|
| New target creation | ✅ Yes | Modifies `project.pbxproj` |
| App Groups capability | ✅ Yes | Creates/modifies entitlements files |
| File target membership | ✅ Yes | Modifies `project.pbxproj` |
| Build settings | ✅ Yes | Modifies `project.pbxproj` |
| Source files (Swift, plist) | ✅ Yes | Already in repo |
| User scheme selections | ❌ No | In `xcuserdata/` (gitignored) |
| Personal signing identity | ⚠️ Maybe | Depends on team practice |
**Bottom line:** Commit all the Xcode project configuration changes. Other developers will get the Share Extension target automatically when they pull, and they'll only need to configure their personal signing settings.

View File

@@ -0,0 +1,283 @@
# iOS Share Extension Improvements
**Date:** 2025-11-24
**Purpose:** Explore alternatives to improve user experience by eliminating interstitial UI and simplifying app launch mechanism
## Current Implementation Issues
1. **Interstitial UI**: Users see `SLComposeServiceViewController` with a "Post" button before the app opens
2. **Deep Link Dependency**: App relies on deep link (`timesafari://shared-photo`) to detect shared images, even though data is already in App Group
## Improvement 1: Skip Interstitial UI
### Current Approach
- Uses `SLComposeServiceViewController` which shows a UI with "Post" button
- User must tap "Post" to proceed
### Alternative: Custom UIViewController (Headless Processing)
Replace `SLComposeServiceViewController` with a custom `UIViewController` that:
- Processes the image immediately in `viewDidLoad`
- Shows no UI (or minimal loading indicator)
- Opens the app automatically
**Implementation:**
```swift
import UIKit
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari.share"
private let sharedPhotoBase64Key = "sharedPhotoBase64"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
override func viewDidLoad() {
super.viewDidLoad()
// Process image immediately without showing UI
processAndOpenApp()
}
private func processAndOpenApp() {
guard let extensionContext = extensionContext,
let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self else {
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
if success {
self.openMainApp()
}
// Complete immediately - no UI shown
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
// ... (same implementation as current)
}
private func openMainApp() {
guard let url = URL(string: "timesafari://shared-photo") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
extensionContext?.open(url, completionHandler: nil)
}
}
```
**Info.plist Changes:**
- Already configured correctly with `NSExtensionPrincipalClass`
- No storyboard needed (already removed)
**Benefits:**
- ✅ No interstitial UI - app opens immediately
- ✅ Faster user experience
- ✅ More seamless integration
**Considerations:**
- ⚠️ User has less control (can't cancel easily)
- ⚠️ No visual feedback during processing (could add minimal loading indicator)
- ⚠️ Apple guidelines: Extensions should provide value even if they don't open the app
## Improvement 2: Direct App Launch Without Deep Link
### Current Approach
- Share Extension stores data in App Group UserDefaults
- Share Extension opens app via deep link (`timesafari://shared-photo`)
- App receives deep link → checks App Group → processes image
### Alternative: App Lifecycle Detection
Instead of using deep links, the app can check for shared data when it becomes active:
**Option A: Check on App Activation**
```swift
// In AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
// Check for shared image from Share Extension
if let sharedData = getSharedImageData() {
// Store in temp file for JS to read
writeSharedImageToTempFile(sharedData)
// Navigate to shared-photo route directly
// This would need to be handled in JS layer
}
}
```
**Option B: Use Notification (More Reliable)**
```swift
// In ShareViewController.swift (after storing data)
private func openMainApp() {
// Store a flag that image is ready
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize()
// Open app (can use any URL scheme or even just launch the app)
guard let url = URL(string: "timesafari://") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
}
// In AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
let appGroupIdentifier = "group.app.timesafari.share"
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
// Check if shared photo is ready
if userDefaults.bool(forKey: "sharedPhotoReady") {
userDefaults.removeObject(forKey: "sharedPhotoReady")
userDefaults.synchronize()
// Process shared image
if let sharedData = getSharedImageData() {
writeSharedImageToTempFile(sharedData)
// Trigger JS to check for shared image
// This could be done via Capacitor App plugin or custom event
}
}
}
```
**Option C: Check on App Launch (Most Direct)**
```swift
// In AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Check for shared image immediately on launch
checkForSharedImageOnLaunch()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Also check when app becomes active (in case it was already running)
checkForSharedImageOnLaunch()
}
private func checkForSharedImageOnLaunch() {
if let sharedData = getSharedImageData() {
writeSharedImageToTempFile(sharedData)
// Post a notification or use Capacitor to notify JS
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
}
}
```
**JavaScript Integration:**
```typescript
// In main.capacitor.ts
import { App } from '@capacitor/app';
// Listen for app becoming active
App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
// Check for shared image when app becomes active
await checkAndStoreNativeSharedImage();
}
});
// Also check on initial load
if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios') {
checkAndStoreNativeSharedImage().then(result => {
if (result.success) {
// Navigate to shared-photo route
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
}
});
}
```
**Benefits:**
- ✅ No deep link routing needed
- ✅ More direct data flow
- ✅ App can detect shared content even if it was already running
- ✅ Simpler URL scheme handling
**Considerations:**
- ⚠️ Need to ensure app checks on both launch and activation
- ⚠️ May need to handle race conditions (app launching vs. share extension writing)
- ⚠️ Still need some way to open the app (minimal URL scheme still required)
## Recommended Approach
**Best of Both Worlds:**
1. **Use Custom UIViewController** (Improvement 1) - Eliminates interstitial UI
2. **Use App Lifecycle Detection** (Improvement 2, Option C) - Direct data flow
**Combined Implementation:**
```swift
// ShareViewController.swift - Custom UIViewController
class ShareViewController: UIViewController {
// Process immediately in viewDidLoad
// Store data in App Group
// Open app with minimal URL (just "timesafari://")
}
// AppDelegate.swift
func applicationDidBecomeActive(_ application: UIApplication) {
// Check for shared image
// If found, write to temp file and let JS handle navigation
}
```
**JavaScript:**
```typescript
// Check on app activation
App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
const result = await checkAndStoreNativeSharedImage();
if (result.success) {
router.push('/shared-photo' + (result.fileName ? `?fileName=${result.fileName}` : ''));
}
}
});
```
This approach:
- ✅ No interstitial UI
- ✅ No deep link routing complexity
- ✅ Direct data flow via App Group
- ✅ Works whether app is running or launching fresh

View File

@@ -0,0 +1,140 @@
# iOS Share Extension Setup Instructions
**Date:** 2025-01-27
**Purpose:** Step-by-step instructions for setting up the iOS Share Extension in Xcode
## Prerequisites
- Xcode installed
- iOS project already set up with Capacitor
- Access to Apple Developer account (for App Groups)
## Step 1: Create Share Extension Target
1. Open `ios/App/App.xcodeproj` in Xcode
2. In the Project Navigator, select the **App** project (top-level item)
3. Click the **+** button at the bottom of the Targets list
4. Select **iOS****Share Extension**
5. Click **Next**
6. Configure:
- **Product Name:** `TimeSafariShareExtension`
- **Bundle Identifier:** `app.timesafari.shareextension` (must match main app's bundle ID with `.shareextension` suffix)
- **Language:** Swift
7. Click **Finish**
## Step 2: Configure Share Extension Files
The following files have been created in `ios/App/TimeSafariShareExtension/`:
- `ShareViewController.swift` - Main extension logic
- `Info.plist` - Extension configuration
**Verify these files exist and are added to the Share Extension target.**
## Step 3: Configure App Groups
App Groups allow the Share Extension and main app to share data.
### For Main App Target:
1. Select the **App** target in Xcode
2. Go to **Signing & Capabilities** tab
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari.share`
7. Ensure it's checked/enabled
### For Share Extension Target:
1. Select the **TimeSafariShareExtension** target
2. Go to **Signing & Capabilities** tab
3. Click **+ Capability**
4. Select **App Groups**
5. Click **+** to add a new group
6. Enter: `group.app.timesafari.share` (same as main app)
7. Ensure it's checked/enabled
**Important:** Both targets must use the **exact same** App Group identifier.
## Step 4: Configure Share Extension Info.plist
The `Info.plist` file should already be configured, but verify:
1. Select `TimeSafariShareExtension/Info.plist` in Xcode
2. Ensure it contains:
- `NSExtensionPointIdentifier` = `com.apple.share-services`
- `NSExtensionPrincipalClass` = `$(PRODUCT_MODULE_NAME).ShareViewController`
- `NSExtensionActivationSupportsImageWithMaxCount` = `1`
## Step 5: Add ShareImageBridge to Main App
1. The file `ios/App/App/ShareImageBridge.swift` has been created
2. Ensure it's added to the **App** target (not the Share Extension target)
3. In Xcode, select the file and check the **Target Membership** in the File Inspector
## Step 6: Build and Test
1. Select the **App** scheme (not the Share Extension scheme)
2. Build and run on a device or simulator
3. Open Photos app
4. Select an image
5. Tap **Share** button
6. Look for **TimeSafari Share** in the share sheet
7. Select it
8. The app should open and navigate to the shared photo view
## Step 7: Troubleshooting
### Share Extension doesn't appear in share sheet
- Verify the Share Extension target builds successfully
- Check that `Info.plist` is correctly configured
- Ensure the extension's bundle identifier follows the pattern: `{main-app-bundle-id}.shareextension`
- Clean build folder (Product → Clean Build Folder)
### App Group access fails
- Verify both targets have the same App Group identifier
- Check that App Groups capability is enabled for both targets
- Ensure you're signed in with a valid Apple Developer account
- For development, you may need to enable App Groups in your Apple Developer account
### Shared image not appearing
- Check Xcode console for errors
- Verify `ShareViewController.swift` is correctly implemented
- Ensure the deep link `timesafari://shared-photo` is being handled
- Check that the native bridge method is being called
### Build errors
- Ensure Swift version matches between targets
- Check that all required frameworks are linked
- Verify deployment targets match between main app and extension
## Step 8: Native Bridge Implementation (TODO)
Currently, the JavaScript code needs a way to call the native `getSharedImageData()` method. This requires one of:
1. **Option A:** Create a minimal Capacitor plugin
2. **Option B:** Use Capacitor's existing bridge mechanisms
3. **Option C:** Expose the method via a custom URL scheme parameter
The current implementation in `main.capacitor.ts` has a placeholder that needs to be completed.
## Next Steps
After the Share Extension is set up and working:
1. Complete the native bridge implementation to read from App Group
2. Test end-to-end flow: Share image → Extension stores → App reads → Displays
3. Implement Android version
4. Add error handling and edge cases
## References
- [Apple Share Extensions Documentation](https://developer.apple.com/documentation/social)
- [App Groups Documentation](https://developer.apple.com/documentation/xcode/configuring-app-groups)
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)

View File

@@ -0,0 +1,93 @@
# iOS Share Extension Implementation Status
**Date:** 2025-01-27
**Status:** In Progress - Native Code Complete, Bridge Pending
## Completed
**Share Extension Files Created:**
- `ios/App/TimeSafariShareExtension/ShareViewController.swift` - Handles image sharing
- `ios/App/TimeSafariShareExtension/Info.plist` - Extension configuration
**Native Bridge Created:**
- `ios/App/App/ShareImageBridge.swift` - Native method to read from App Group
**JavaScript Integration Started:**
- `src/services/nativeShareHandler.ts` - Service to handle native shared images
- `src/main.capacitor.ts` - Updated to check for native shared images on deep link
**Documentation:**
- `doc/native-share-target-implementation.md` - Complete implementation guide
- `doc/ios-share-extension-setup.md` - Xcode setup instructions
## Pending
⚠️ **Xcode Configuration (Manual Steps Required):**
1. Create Share Extension target in Xcode
2. Configure App Groups for both main app and extension
3. Add ShareImageBridge.swift to App target
4. Build and test
⚠️ **JavaScript-Native Bridge:**
The current implementation has a placeholder for calling the native `ShareImageBridge.getSharedImageData()` method from JavaScript. This needs to be completed using one of:
**Option A: Minimal Capacitor Plugin** (Recommended for Option 1)
- Create a small plugin that exposes the method
- Clean and maintainable
- Follows Capacitor patterns
**Option B: Direct Bridge Call**
- Use Capacitor's executePlugin or similar mechanism
- Requires understanding Capacitor's internal bridge
- Less maintainable
**Option C: AppDelegate Integration**
- Have AppDelegate check on launch and expose via a different mechanism
- Workaround approach
- Less clean but functional
## Next Steps
1. **Complete Xcode Setup:**
- Follow `doc/ios-share-extension-setup.md`
- Create Share Extension target
- Configure App Groups
- Build and verify extension appears in share sheet
2. **Implement JavaScript-Native Bridge:**
- Choose one of the options above
- Complete the `checkAndStoreNativeSharedImage()` function in `main.capacitor.ts`
- Test end-to-end flow
3. **Testing:**
- Share image from Photos app
- Verify Share Extension appears
- Verify app opens and displays shared image
- Test "Record Gift" and "Save as Profile" flows
## Current Flow
1. ✅ User shares image → Share Extension receives
2. ✅ Share Extension converts to base64
3. ✅ Share Extension stores in App Group UserDefaults
4. ✅ Share Extension opens app with `timesafari://shared-photo?fileName=...`
5. ⚠️ App receives deep link (handled)
6. ⚠️ App checks App Group UserDefaults (bridge needed)
7. ⚠️ App stores in temp database (pending bridge)
8. ✅ SharedPhotoView reads from temp database (already works)
## Code Locations
- **Share Extension:** `ios/App/TimeSafariShareExtension/`
- **Native Bridge:** `ios/App/App/ShareImageBridge.swift`
- **JavaScript Handler:** `src/services/nativeShareHandler.ts`
- **Deep Link Integration:** `src/main.capacitor.ts`
- **View Component:** `src/views/SharedPhotoView.vue` (already complete)
## Notes
- The Share Extension code is complete and ready to use
- The main missing piece is the JavaScript-to-native bridge
- Once the bridge is complete, the entire flow should work end-to-end
- The existing `SharedPhotoView.vue` doesn't need changes - it already handles images from temp storage

View File

@@ -0,0 +1,507 @@
# Native Share Target Implementation Guide
**Date:** 2025-01-27
**Purpose:** Enable TimeSafari native iOS and Android apps to receive shared images from other apps
## Current State
The app currently supports **PWA/web share target** functionality:
- Service worker intercepts POST to `/share-target`
- Images stored in temp database as base64
- `SharedPhotoView.vue` processes and displays shared images
**This does NOT work for native iOS/Android builds** because:
- Service workers don't run in native app contexts
- Native platforms use different sharing mechanisms (Share Extensions on iOS, Intent Filters on Android)
## Required Changes
### 1. iOS Implementation
#### 1.1 Create Share Extension Target
1. Open `ios/App/App.xcodeproj` in Xcode
2. File → New → Target
3. Select "Share Extension" template
4. Name it "TimeSafariShareExtension"
5. Bundle Identifier: `app.timesafari.shareextension`
6. Language: Swift
#### 1.2 Configure Share Extension Info.plist
Add to `ios/App/TimeSafariShareExtension/Info.plist`:
```xml
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
```
#### 1.3 Implement ShareViewController
Create `ios/App/TimeSafariShareExtension/ShareViewController.swift`:
```swift
import UIKit
import Social
import MobileCoreServices
import Capacitor
class ShareViewController: SLComposeServiceViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.title = "Share to TimeSafari"
}
override func isContentValid() -> Bool {
return true
}
override func didSelectPost() {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// Handle image sharing
if itemProvider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
itemProvider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
if let url = item as? URL {
// Handle file URL
self.handleSharedImage(url: url)
} else if let image = item as? UIImage {
// Handle UIImage directly
self.handleSharedImage(image: image)
} else if let data = item as? Data {
// Handle image data
self.handleSharedImage(data: data)
}
}
}
}
private func handleSharedImage(url: URL? = nil, image: UIImage? = nil, data: Data? = nil) {
var imageData: Data?
var fileName: String?
if let url = url {
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
} else if let image = image {
imageData = image.jpegData(compressionQuality: 0.8)
fileName = "shared-image.jpg"
} else if let data = data {
imageData = data
fileName = "shared-image.jpg"
}
guard let imageData = imageData else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return
}
// Convert to base64
let base64String = imageData.base64EncodedString()
// Store in shared UserDefaults (accessible by main app)
let userDefaults = UserDefaults(suiteName: "group.app.timesafari.share")
userDefaults?.set(base64String, forKey: "sharedPhotoBase64")
userDefaults?.set(fileName ?? "shared-image.jpg", forKey: "sharedPhotoFileName")
userDefaults?.synchronize()
// Open main app with deep link
let url = URL(string: "timesafari://shared-photo?fileName=\(fileName?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "shared-image.jpg")")!
var responder = self as UIResponder?
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
break
}
responder = responder?.next
}
// Close share extension
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
override func configurationItems() -> [Any]! {
return []
}
}
```
#### 1.4 Configure App Groups
1. In Xcode, select main app target → Signing & Capabilities
2. Add "App Groups" capability
3. Create group: `group.app.timesafari.share`
4. Repeat for Share Extension target with same group name
#### 1.5 Update Main App to Read from App Group
The main app needs to check for shared images on launch. This should be added to `AppDelegate.swift` or handled in JavaScript.
### 2. Android Implementation
#### 2.1 Update AndroidManifest.xml
Add intent filter to `MainActivity` in `android/app/src/main/AndroidManifest.xml`:
```xml
<activity
android:name=".MainActivity"
... existing attributes ...>
... existing intent filters ...
<!-- Share Target Intent Filter -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
<!-- Multiple images support (optional) -->
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
```
#### 2.2 Handle Intent in MainActivity
Update `android/app/src/main/java/app/timesafari/MainActivity.java`:
```java
package app.timesafari;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Base64;
import android.util.Log;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.Plugin;
import java.io.InputStream;
import java.io.ByteArrayOutputStream;
public class MainActivity extends BridgeActivity {
private static final String TAG = "MainActivity";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
handleShareIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleShareIntent(intent);
}
private void handleShareIntent(Intent intent) {
if (intent == null) return;
String action = intent.getAction();
String type = intent.getType();
if (Intent.ACTION_SEND.equals(action) && type != null && type.startsWith("image/")) {
Uri imageUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (imageUri != null) {
handleSharedImage(imageUri, intent.getStringExtra(Intent.EXTRA_TEXT));
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action) && type != null && type.startsWith("image/")) {
// Handle multiple images (optional - for now just take first)
java.util.ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (imageUris != null && !imageUris.isEmpty()) {
handleSharedImage(imageUris.get(0), null);
}
}
}
private void handleSharedImage(Uri imageUri, String fileName) {
try {
// Read image data
InputStream inputStream = getContentResolver().openInputStream(imageUri);
if (inputStream == null) {
Log.e(TAG, "Failed to open input stream for shared image");
return;
}
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] data = new byte[8192];
int nRead;
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, nRead);
}
buffer.flush();
byte[] imageBytes = buffer.toByteArray();
// Convert to base64
String base64String = Base64.encodeToString(imageBytes, Base64.NO_WRAP);
// Extract filename from URI or use default
String actualFileName = fileName;
if (actualFileName == null || actualFileName.isEmpty()) {
String path = imageUri.getPath();
if (path != null) {
int lastSlash = path.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < path.length() - 1) {
actualFileName = path.substring(lastSlash + 1);
}
}
if (actualFileName == null || actualFileName.isEmpty()) {
actualFileName = "shared-image.jpg";
}
}
// Store in SharedPreferences (accessible by JavaScript via Capacitor)
android.content.SharedPreferences prefs = getSharedPreferences("TimeSafariShared", MODE_PRIVATE);
android.content.SharedPreferences.Editor editor = prefs.edit();
editor.putString("sharedPhotoBase64", base64String);
editor.putString("sharedPhotoFileName", actualFileName);
editor.apply();
// Trigger JavaScript event or navigate to shared-photo route
// This will be handled by JavaScript checking for shared data on app launch
Log.d(TAG, "Shared image stored, filename: " + actualFileName);
} catch (Exception e) {
Log.e(TAG, "Error handling shared image", e);
}
}
}
```
#### 2.3 Add Required Permissions
Ensure `AndroidManifest.xml` has:
```xml
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- Android 13+ -->
```
### 3. JavaScript Layer Updates
#### 3.1 Create Native Share Handler
Create `src/services/nativeShareHandler.ts`:
```typescript
/**
* Native Share Handler
* Handles shared images from native iOS and Android platforms
*/
import { Capacitor } from "@capacitor/core";
import { App } from "@capacitor/app";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { logger } from "../utils/logger";
import { SHARED_PHOTO_BASE64_KEY } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
/**
* Check for shared images from native platforms and store in temp database
*/
export async function checkForNativeSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
if (!Capacitor.isNativePlatform()) {
return false;
}
try {
if (Capacitor.getPlatform() === "ios") {
return await checkIOSSharedImage(platformService);
} else if (Capacitor.getPlatform() === "android") {
return await checkAndroidSharedImage(platformService);
}
} catch (error) {
logger.error("Error checking for native shared image:", error);
}
return false;
}
/**
* Check for shared image on iOS (from App Group UserDefaults)
*/
async function checkIOSSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
// iOS uses App Groups to share data between extension and main app
// We need to use a Capacitor plugin or native code to read from App Group
// For now, this is a placeholder - requires native plugin implementation
// Option 1: Use Capacitor plugin to read from App Group
// Option 2: Use native code bridge
logger.debug("Checking for iOS shared image (not yet implemented)");
return false;
}
/**
* Check for shared image on Android (from SharedPreferences)
*/
async function checkAndroidSharedImage(
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<boolean> {
// Android stores in SharedPreferences
// We need a Capacitor plugin to read from SharedPreferences
// For now, this is a placeholder - requires native plugin implementation
logger.debug("Checking for Android shared image (not yet implemented)");
return false;
}
/**
* Store shared image in temp database
*/
async function storeSharedImage(
base64Data: string,
fileName: string,
platformService: InstanceType<typeof PlatformServiceMixin>
): Promise<void> {
try {
const existing = await platformService.$getTemp(SHARED_PHOTO_BASE64_KEY);
if (existing) {
await platformService.$updateEntity(
"temp",
{ blobB64: base64Data },
"id = ?",
[SHARED_PHOTO_BASE64_KEY]
);
} else {
await platformService.$insertEntity(
"temp",
{ id: SHARED_PHOTO_BASE64_KEY, blobB64: base64Data },
["id", "blobB64"]
);
}
logger.debug("Stored shared image in temp database");
} catch (error) {
logger.error("Error storing shared image:", error);
throw error;
}
}
```
#### 3.2 Update main.capacitor.ts
Add check for shared images on app launch:
```typescript
// In main.capacitor.ts, after app mount:
import { checkForNativeSharedImage } from "./services/nativeShareHandler";
// Check for shared images when app becomes active
App.addListener("appStateChange", async (state) => {
if (state.isActive) {
// Check for native shared images
const hasSharedImage = await checkForNativeSharedImage(/* platformService */);
if (hasSharedImage) {
// Navigate to shared-photo view
await router.push({
name: "shared-photo",
query: { source: "native" }
});
}
}
});
// Also check on initial launch
App.getLaunchUrl().then((result) => {
if (result?.url) {
// Handle deep link
} else {
// Check for shared image
checkForNativeSharedImage(/* platformService */).then((hasShared) => {
if (hasShared) {
router.push({ name: "shared-photo", query: { source: "native" } });
}
});
}
});
```
#### 3.3 Update SharedPhotoView.vue
The existing `SharedPhotoView.vue` should work as-is, but we may want to add detection for native vs web sources.
### 4. Alternative Approach: Capacitor Plugin
Instead of implementing native code directly, consider creating a Capacitor plugin:
1. **Create plugin**: `@capacitor-community/share-target` or custom plugin
2. **Plugin methods**:
- `checkForSharedImage()`: Returns shared image data if available
- `clearSharedImage()`: Clears shared image data after processing
This would be cleaner and more maintainable.
### 5. Testing Checklist
- [ ] Test sharing image from Photos app on iOS
- [ ] Test sharing image from Gallery app on Android
- [ ] Test sharing from other apps (Safari, Chrome, etc.)
- [ ] Verify image appears in SharedPhotoView
- [ ] Test "Record Gift" flow with shared image
- [ ] Test "Save as Profile" flow with shared image
- [ ] Test cancel flow
- [ ] Verify temp storage cleanup
- [ ] Test app launch with shared image pending
- [ ] Test app already running when image is shared
### 6. Implementation Priority
**Phase 1: Android (Simpler)**
1. Update AndroidManifest.xml
2. Implement MainActivity intent handling
3. Create JavaScript handler
4. Test end-to-end
**Phase 2: iOS (More Complex)**
1. Create Share Extension target
2. Implement ShareViewController
3. Configure App Groups
4. Create JavaScript handler
5. Test end-to-end
### 7. Notes
- **App Groups (iOS)**: Required for sharing data between Share Extension and main app
- **SharedPreferences (Android)**: Standard way to share data between app components
- **Base64 Encoding**: Both platforms convert images to base64 for JavaScript compatibility
- **File Size Limits**: Consider large image handling and memory management
- **Permissions**: Android 13+ requires `READ_MEDIA_IMAGES` instead of `READ_EXTERNAL_STORAGE`
### 8. References
- [iOS Share Extensions](https://developer.apple.com/documentation/social)
- [Android Share Targets](https://developer.android.com/training/sharing/receive)
- [Capacitor App Plugin](https://capacitorjs.com/docs/apis/app)
- [Capacitor Native Bridge](https://capacitorjs.com/docs/guides/building-plugins)

View File

@@ -0,0 +1,528 @@
# Shared Image Plugin Implementation Plan
**Date:** 2025-12-03 15:40:38 PST
**Status:** Planning
**Goal:** Replace temp file approach with native Capacitor plugins for iOS and Android
## Minimum OS Version Compatibility Analysis
### Current Project Configuration:
- **iOS Deployment Target**: 13.0 (Podfile and Xcode project)
- **Android minSdkVersion**: 23 (API 23 - Android 6.0 Marshmallow) ✅ **Upgraded**
- **Capacitor Version**: 6.2.0
### Capacitor 6 Requirements:
- **iOS**: Requires iOS 13.0+ ✅ **Compatible** (current: 13.0)
- **Android**: Requires API 23+ ✅ **Compatible** (current: API 23)
### Plugin API Compatibility:
#### iOS Plugin APIs:
-`CAPPlugin` base class: Available in iOS 13.0+ (Capacitor requirement)
-`CAPPluginCall`: Available in iOS 13.0+ (Capacitor requirement)
-`UserDefaults(suiteName:)`: Available since iOS 8.0 (well below iOS 13.0)
-`@objc` annotations: Available since iOS 8.0
- ✅ Swift 5.0: Compatible with iOS 13.0+
**Conclusion**: iOS 13.0 is fully compatible with the plugin implementation. **No iOS version update required.**
#### Android Plugin APIs:
-`Plugin` base class: Available in API 21+ (Capacitor requirement)
-`PluginCall`: Available in API 21+ (Capacitor requirement)
-`SharedPreferences`: Available since API 1 (works on all Android versions)
-`@CapacitorPlugin` annotation: Available in API 21+ (Capacitor requirement)
-`@PluginMethod` annotation: Available in API 21+ (Capacitor requirement)
**Conclusion**: Android API 23 is fully compatible with the plugin implementation and officially meets Capacitor 6 requirements. ✅ **No Android version concerns.**
### Share Extension Compatibility:
- **iOS Share Extension**: Uses same deployment target as main app (iOS 13.0)
- **App Group**: Available since iOS 8.0, fully compatible
- No additional version requirements for share extension functionality
## Overview
This document outlines the migration from the current temp file approach to implementing dedicated Capacitor plugins for handling shared images. This will eliminate file I/O, polling, and timing issues, providing a more direct and reliable native-to-JS bridge.
## Current Implementation Issues
### Temp File Approach Problems:
1. **Timing Issues**: Requires polling with exponential backoff to wait for file creation
2. **Race Conditions**: File may not exist when JS checks, or may be read multiple times
3. **File Management**: Need to delete temp files after reading to prevent re-processing
4. **Platform Differences**: Different directories (Documents vs Data) add complexity
5. **Error Handling**: File I/O errors can be hard to debug
6. **Performance**: File system operations are slower than direct native calls
## Proposed Solution: Capacitor Plugins
### Benefits:
- ✅ Direct native-to-JS communication (no file I/O)
- ✅ Synchronous/async method calls (no polling needed)
- ✅ Type-safe TypeScript interfaces
- ✅ Better error handling and debugging
- ✅ Lower latency
- ✅ More maintainable and follows Capacitor best practices
## Implementation Layout
### 1. iOS Plugin Implementation
#### 1.1 Create iOS Plugin File
**Location:** `ios/App/App/SharedImagePlugin.swift`
**Structure:**
```swift
import Foundation
import Capacitor
@objc(SharedImagePlugin)
public class SharedImagePlugin: CAPPlugin {
private let appGroupIdentifier = "group.app.timesafari.share"
@objc func getSharedImage(_ call: CAPPluginCall) {
// Read from App Group UserDefaults
// Return base64 and fileName
// Clear data after reading
}
@objc func hasSharedImage(_ call: CAPPluginCall) {
// Check if shared image exists without reading it
// Useful for quick checks
}
}
```
**Key Points:**
- Use existing `getSharedImageData()` logic from AppDelegate
- Return data as JSObject with `base64` and `fileName` keys
- Clear UserDefaults after reading to prevent re-reading
- Handle errors gracefully with `call.reject()`
- **Version Compatibility**: Works with iOS 13.0+ (current deployment target)
#### 1.2 Register Plugin in iOS
**Location:** `ios/App/App/AppDelegate.swift`
**Changes:**
- Remove `writeSharedImageToTempFile()` method
- Remove temp file writing from `application(_:open:options:)`
- Remove temp file writing from `checkForSharedImageOnActivation()`
- Keep `getSharedImageData()` method (or move to plugin)
- Plugin auto-registers via Capacitor's plugin system
**Note:** Capacitor plugins are auto-discovered if they follow naming conventions and are in the app bundle.
### 2. Android Plugin Implementation
#### 2.1 Create Android Plugin File
**Location:** `android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java`
**Structure:**
```java
package app.timesafari.sharedimage;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "SharedImage")
public class SharedImagePlugin extends Plugin {
@PluginMethod
public void getSharedImage(PluginCall call) {
// Read from SharedPreferences or Intent extras
// Return base64 and fileName
// Clear data after reading
}
@PluginMethod
public void hasSharedImage(PluginCall call) {
// Check if shared image exists without reading it
}
}
```
**Key Points:**
- Use SharedPreferences to store shared image data between share intent and plugin call
- Store base64 and fileName when processing share intent
- Read and clear in `getSharedImage()` method
- Handle Intent extras if app was just launched
- **Version Compatibility**: Works with Android API 22+ (current minSdkVersion)
#### 2.2 Update MainActivity
**Location:** `android/app/src/main/java/app/timesafari/MainActivity.java`
**Changes:**
- Remove `writeSharedImageToTempFile()` method
- Remove `TEMP_FILE_NAME` constant
- Update `processSharedImage()` to store in SharedPreferences instead of file
- Register plugin: `registerPlugin(SharedImagePlugin.class);`
- Store shared image data in SharedPreferences when processing share intent
**SharedPreferences Approach:**
```java
// In processSharedImage():
SharedPreferences prefs = getSharedPreferences("shared_image", MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
editor.putString("base64", base64String);
editor.putString("fileName", actualFileName);
editor.putBoolean("hasSharedImage", true);
editor.apply();
```
### 3. TypeScript/JavaScript Integration
#### 3.1 Create TypeScript Plugin Definition
**Location:** `src/plugins/SharedImagePlugin.ts` (new file)
**Structure:**
```typescript
import { registerPlugin } from '@capacitor/core';
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
getSharedImage(): Promise<SharedImageResult | null>;
hasSharedImage(): Promise<{ hasImage: boolean }>;
}
const SharedImage = registerPlugin<SharedImagePlugin>('SharedImage', {
web: () => import('./SharedImagePlugin.web').then(m => new m.SharedImagePluginWeb()),
});
export * from './definitions';
export { SharedImage };
```
#### 3.2 Create Web Implementation (for development)
**Location:** `src/plugins/SharedImagePlugin.web.ts` (new file)
**Structure:**
```typescript
import { WebPlugin } from '@capacitor/core';
import type { SharedImagePlugin, SharedImageResult } from './definitions';
export class SharedImagePluginWeb extends WebPlugin implements SharedImagePlugin {
async getSharedImage(): Promise<SharedImageResult | null> {
// Return null for web platform
return null;
}
async hasSharedImage(): Promise<{ hasImage: boolean }> {
return { hasImage: false };
}
}
```
#### 3.3 Create Type Definitions
**Location:** `src/plugins/definitions.ts` (new file)
**Structure:**
```typescript
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
getSharedImage(): Promise<SharedImageResult | null>;
hasSharedImage(): Promise<{ hasImage: boolean }>;
}
```
#### 3.4 Update main.capacitor.ts
**Location:** `src/main.capacitor.ts`
**Changes:**
- Remove `pollForFileExistence()` function
- Remove temp file reading logic from `checkAndStoreNativeSharedImage()`
- Replace with direct plugin call:
```typescript
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
if (isProcessingSharedImage) {
logger.debug("[Main] ⏸️ Shared image processing already in progress, skipping");
return { success: false };
}
isProcessingSharedImage = true;
try {
if (!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android")) {
isProcessingSharedImage = false;
return { success: false };
}
// Direct plugin call - no polling needed!
const { SharedImage } = await import('./plugins/SharedImagePlugin');
const result = await SharedImage.getSharedImage();
if (result && result.base64) {
await storeSharedImageInTempDB(result.base64, result.fileName);
isProcessingSharedImage = false;
return { success: true, fileName: result.fileName };
}
isProcessingSharedImage = false;
return { success: false };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
isProcessingSharedImage = false;
return { success: false };
}
}
```
**Remove:**
- `pollForFileExistence()` function (lines 71-98)
- All Filesystem plugin imports related to temp file reading
- Temp file path constants and directory logic
### 4. Data Flow Comparison
#### Current (Temp File) Flow:
```
Share Extension/Intent
Native writes temp file
JS polls for file existence (with retries)
JS reads file via Filesystem plugin
JS parses JSON
JS deletes temp file
JS stores in temp DB
```
#### New (Plugin) Flow:
```
Share Extension/Intent
Native stores in UserDefaults/SharedPreferences
JS calls plugin.getSharedImage()
Native reads and clears data
Native returns data directly
JS stores in temp DB
```
## File Changes Summary
### New Files to Create:
1. `ios/App/App/SharedImagePlugin.swift` - iOS plugin implementation
2. `android/app/src/main/java/app/timesafari/sharedimage/SharedImagePlugin.java` - Android plugin
3. `src/plugins/SharedImagePlugin.ts` - TypeScript plugin registration
4. `src/plugins/SharedImagePlugin.web.ts` - Web fallback implementation
5. `src/plugins/definitions.ts` - TypeScript type definitions
### Files to Modify:
1. `ios/App/App/AppDelegate.swift` - Remove temp file writing
2. `android/app/src/main/java/app/timesafari/MainActivity.java` - Remove temp file writing, add SharedPreferences
3. `src/main.capacitor.ts` - Replace temp file logic with plugin calls
### Files to Remove:
- No files need to be deleted, but code will be removed from existing files
## Implementation Considerations
### 1. Data Storage Strategy
#### iOS:
- **Current**: App Group UserDefaults (already working)
- **Plugin**: Read from same UserDefaults, no changes needed
- **Clearing**: Clear immediately after reading in plugin method
#### Android:
- **Current**: Temp file in app's internal files directory
- **New**: SharedPreferences (persistent key-value store)
- **Alternative**: Could use Intent extras if app is launched fresh, but SharedPreferences is more reliable for backgrounded apps
### 2. Timing and Lifecycle
#### When to Check for Shared Images:
1. **App Launch**: Check in `checkForSharedImageAndNavigate()` (already exists)
2. **App Becomes Active**: Check in `appStateChange` listener (already exists)
3. **Deep Link**: Check in `handleDeepLink()` for empty path URLs (already exists)
#### Plugin Call Timing:
- Plugin calls are synchronous from JS perspective
- No polling needed - native side handles data availability
- If no data exists, plugin returns `null` immediately
### 3. Error Handling
#### Plugin Error Scenarios:
- **No shared image**: Return `null` (not an error)
- **Data corruption**: Return error via `call.reject()`
- **Missing permissions**: Return error (shouldn't happen with App Group/SharedPreferences)
#### JS Error Handling:
- Wrap plugin calls in try-catch
- Log errors appropriately
- Don't crash app if plugin fails
### 4. Backward Compatibility
#### Migration Path:
- Keep temp file code temporarily (commented out) for rollback
- Test thoroughly on both platforms
- Remove temp file code after verification
### 5. Testing Considerations
#### Test Cases:
1. **Share from Photos app** → Verify image appears in app
2. **Share while app is backgrounded** → Verify image appears when app becomes active
3. **Share while app is closed** → Verify image appears on app launch
4. **Multiple rapid shares** → Verify only latest image is processed
5. **Share then close app before processing** → Verify image persists
6. **Share then clear app data** → Verify graceful handling
#### Edge Cases:
- Very large images (memory concerns)
- Multiple images shared simultaneously
- App killed by OS before processing
- Network interruptions during processing
### 6. Performance Considerations
#### Benefits:
- **Latency**: Direct calls vs file I/O (faster)
- **CPU**: No polling overhead
- **Memory**: No temp file storage
- **Battery**: Less file system activity
#### Potential Issues:
- Large base64 strings in memory (same as current approach)
- UserDefaults/SharedPreferences size limits (shouldn't be an issue for single image)
### 7. Type Safety
#### TypeScript Benefits:
- Full type checking for plugin methods
- Autocomplete in IDE
- Compile-time error checking
- Better developer experience
### 8. Plugin Registration
#### iOS:
- Capacitor auto-discovers plugins via naming convention
- Ensure plugin is in app target (not extension target)
- No manual registration needed in AppDelegate
#### Android:
- Register in `MainActivity.onCreate()`:
```java
registerPlugin(SharedImagePlugin.class);
```
### 9. Capacitor Version Compatibility
#### Check Current Version:
- Verify Capacitor version supports custom plugins
- Ensure plugin API hasn't changed
- Test with current Capacitor version first
### 10. Build and Deployment
#### Build Steps:
1. Create plugin files
2. Register Android plugin in MainActivity
3. Update TypeScript code
4. Test on iOS simulator
5. Test on Android emulator
6. Test on physical devices
7. Remove temp file code
8. Update documentation
#### Deployment:
- No changes to build scripts needed
- No changes to CI/CD needed
- No changes to app configuration needed
## Migration Steps
### Phase 1: Create Plugins (Non-Breaking)
1. Create iOS plugin file
2. Create Android plugin file
3. Create TypeScript definitions
4. Register Android plugin
5. Test plugins independently (don't use in main code yet)
### Phase 2: Update JS Integration (Breaking)
1. Create TypeScript plugin wrapper
2. Update `checkAndStoreNativeSharedImage()` to use plugin
3. Remove temp file reading logic
4. Test on both platforms
### Phase 3: Cleanup Native Code (Breaking)
1. Remove temp file writing from iOS AppDelegate
2. Remove temp file writing from Android MainActivity
3. Update to use SharedPreferences on Android
4. Test thoroughly
### Phase 4: Final Cleanup
1. Remove `pollForFileExistence()` function
2. Remove Filesystem imports related to temp files
3. Update comments and documentation
4. Final testing
## Rollback Plan
If issues arise:
1. Revert JS changes to use temp file approach
2. Re-enable temp file writing in native code
3. Keep plugins for future migration attempt
4. Document issues encountered
## Success Criteria
✅ Plugin methods work on both iOS and Android
✅ No polling or file I/O needed
✅ Shared images appear correctly in app
✅ No memory leaks or performance issues
✅ Error handling works correctly
✅ All test cases pass
✅ Code is cleaner and more maintainable
## Additional Notes
### iOS App Group:
- Current App Group ID: `group.app.timesafari.share`
- Ensure plugin has access to same App Group
- Share Extension already writes to this App Group
### Android Share Intent:
- Current implementation handles `ACTION_SEND` and `ACTION_SEND_MULTIPLE`
- SharedPreferences key: `shared_image` (or similar)
- Store both base64 and fileName
### Future Enhancements:
- Consider adding event listeners for real-time notifications
- Could add method to clear shared image without reading
- Could add method to get image metadata without full data
## References
- [Capacitor Plugin Development Guide](https://capacitorjs.com/docs/plugins)
- Existing plugin example: `SafeAreaPlugin.java`
- Current temp file implementation: `main.capacitor.ts` lines 166-271
- iOS AppDelegate: `ios/App/App/AppDelegate.swift`
- Android MainActivity: `android/app/src/main/java/app/timesafari/MainActivity.java`

View File

@@ -0,0 +1,329 @@
# Shared Image Plugin - Pre-Implementation Decision Checklist
**Date:** 2025-12-03
**Status:** Pre-Implementation Planning
**Purpose:** Identify and document decisions needed before implementing SharedImagePlugin
## ✅ Completed Decisions
### 1. Minimum OS Versions
-**iOS**: Keep at 13.0 (no changes needed)
-**Android**: Upgraded from API 22 to API 23 (completed)
-**Rationale**: Meets Capacitor 6 requirements, minimal device impact
### 2. Data Storage Strategy
-**iOS**: Use App Group UserDefaults (already implemented in Share Extension)
-**Android**: Use SharedPreferences (to be implemented)
-**Rationale**: Direct, efficient, no file I/O needed
## 🔍 Decisions Needed Before Implementation
### 1. Plugin Method Design
#### Decision: What methods should the plugin expose?
**Options:**
- **Option A (Minimal)**: Only `getSharedImage()` - read and clear in one call
- **Option B (Recommended)**: `getSharedImage()` + `hasSharedImage()` - allows checking without reading
- **Option C (Extended)**: Add `clearSharedImage()` - explicit clearing without reading
**Recommendation:** **Option B**
- `getSharedImage()`: Returns `{ base64: string, fileName: string } | null`
- `hasSharedImage()`: Returns `{ hasImage: boolean }` - useful for quick checks
**Decision Needed:** ✅ Confirm Option B or choose alternative
---
### 2. Error Handling Strategy
#### Decision: How should the plugin handle errors?
**Options:**
- **Option A**: Return `null` for all errors (no shared image = no error)
- **Option B**: Use `call.reject()` for actual errors, return `null` only when no image exists
- **Option C**: Return error object in result: `{ error: string } | { base64: string, fileName: string }`
**Recommendation:** **Option B**
- `getSharedImage()` returns `null` when no image exists (normal case)
- `call.reject()` for actual errors (UserDefaults unavailable, data corruption, etc.)
- Clear distinction between "no data" (normal) vs "error" (exceptional)
**Decision Needed:** ✅ Confirm Option B or choose alternative
---
### 3. Data Clearing Strategy
#### Decision: When should shared image data be cleared?
**Current Behavior (temp file approach):**
- Data cleared after reading (immediate)
**Options:**
- **Option A**: Clear immediately after reading (current behavior)
- **Option B**: Clear on next read (allow re-reading until consumed)
- **Option C**: Clear after successful storage in temp DB (JS confirms receipt)
**Recommendation:** **Option A** (immediate clearing)
- Prevents accidental re-reading
- Simpler implementation
- Matches current behavior
- If JS fails to store, user can share again
**Decision Needed:** ✅ Confirm Option A or choose alternative
---
### 4. iOS Plugin Registration
#### Decision: How should the iOS plugin be registered?
**Options:**
- **Option A**: Auto-discovery (Capacitor finds plugins by naming convention)
- **Option B**: Manual registration in AppDelegate
- **Option C**: Hybrid (auto-discovery with manual registration as fallback)
**Recommendation:** **Option A** (auto-discovery)
- Follows Capacitor best practices
- Less code to maintain
- Other plugins in project use auto-discovery (SafeAreaPlugin uses manual, but that's older pattern)
**Note:** Need to verify plugin naming convention:
- Class name: `SharedImagePlugin`
- File name: `SharedImagePlugin.swift`
- Location: `ios/App/App/SharedImagePlugin.swift`
**Decision Needed:** ✅ Confirm Option A, or if auto-discovery doesn't work, use Option B
---
### 5. TypeScript Interface Design
#### Decision: What should the TypeScript interface look like?
**Proposed Interface:**
```typescript
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
getSharedImage(): Promise<SharedImageResult | null>;
hasSharedImage(): Promise<{ hasImage: boolean }>;
}
```
**Questions:**
- Should `fileName` be optional? (Currently always provided, but could be empty string)
- Should we include metadata (image size, MIME type)?
- Should `hasSharedImage()` return more info (like fileName without reading)?
**Recommendation:** Keep simple for now:
- `fileName` is always a string (may be default "shared-image.jpg")
- No metadata initially (can add later if needed)
- `hasSharedImage()` only returns boolean (keep it lightweight)
**Decision Needed:** ✅ Confirm interface design or request changes
---
### 6. Android Data Storage Timing
#### Decision: When should Android store shared image data in SharedPreferences?
**Current Flow:**
1. Share intent received in MainActivity
2. Image processed and written to temp file
3. JS reads temp file
**New Flow Options:**
- **Option A**: Store in SharedPreferences immediately when share intent received (in `processSharedImage()`)
- **Option B**: Store when plugin is first called (lazy loading)
- **Option C**: Store in both places during transition (backward compatibility)
**Recommendation:** **Option A** (immediate storage)
- Data available immediately when plugin is called
- No timing issues
- Matches iOS pattern (data stored by Share Extension)
**Implementation:**
- Update `processSharedImage()` in MainActivity to store in SharedPreferences
- Remove temp file writing
- Plugin reads from SharedPreferences
**Decision Needed:** ✅ Confirm Option A
---
### 7. Migration Strategy
#### Decision: How to handle the transition from temp file to plugin?
**Options:**
- **Option A (Clean Break)**: Remove temp file code immediately, use plugin only
- **Option B (Gradual)**: Support both approaches temporarily, remove temp file later
- **Option C (Feature Flag)**: Use feature flag to switch between approaches
**Recommendation:** **Option A** (clean break)
- Simpler implementation
- Less code to maintain
- Temp file approach is buggy anyway (why we're replacing it)
- Can rollback via git if needed
**Rollback Plan:**
- Keep temp file code in git history
- If plugin has issues, can revert commit
- Test thoroughly before removing temp file code
**Decision Needed:** ✅ Confirm Option A
---
### 8. Plugin Naming
#### Decision: What should the plugin be named?
**Options:**
- **Option A**: `SharedImage` (matches file/class names)
- **Option B**: `SharedImagePlugin` (more explicit)
- **Option C**: `NativeShare` (more generic, could handle other share types)
**Recommendation:** **Option A** (`SharedImage`)
- Matches Capacitor naming conventions (plugins are referenced without "Plugin" suffix)
- Examples: `Capacitor.Plugins.Camera`, `Capacitor.Plugins.Filesystem`
- TypeScript: `SharedImage.getSharedImage()`
**Decision Needed:** ✅ Confirm Option A
---
### 9. iOS: Reuse getSharedImageData() or Move to Plugin?
#### Decision: Should the plugin reuse AppDelegate's `getSharedImageData()` or implement its own?
**Current Code:**
- `AppDelegate.getSharedImageData()` exists and works
- Reads from App Group UserDefaults
- Clears data after reading
**Options:**
- **Option A**: Plugin calls `getSharedImageData()` from AppDelegate
- **Option B**: Plugin implements its own logic (duplicate code)
- **Option C**: Move `getSharedImageData()` to a shared utility, both use it
**Recommendation:** **Option C** (shared utility)
- DRY principle
- Single source of truth
- But: May be overkill for simple logic
**Alternative Recommendation:** **Option B** (plugin implements own logic)
- Plugin is self-contained
- No dependency on AppDelegate
- Logic is simple (just UserDefaults read/clear)
- Can remove `getSharedImageData()` from AppDelegate after migration
**Decision:****Option C** (shared utility) - **CONFIRMED**
- Create shared utility for reading from App Group UserDefaults
- Both AppDelegate and plugin use the shared utility
- Single source of truth for shared image data access
---
### 10. Android: SharedPreferences Key Names
#### Decision: What keys should be used in SharedPreferences?
**Proposed Keys:**
- `shared_image_base64` - Base64 string
- `shared_image_file_name` - File name
- `shared_image_ready` - Boolean flag (optional, for quick checks)
**Alternative:**
- Use a single JSON object: `shared_image_data` = `{ base64: "...", fileName: "..." }`
**Recommendation:** Separate keys (first option)
- Simpler to read/write
- No JSON parsing needed
- Matches iOS pattern (separate UserDefaults keys)
- Flag is optional but useful for `hasSharedImage()`
**Decision Needed:** ✅ Confirm key naming or request changes
---
### 11. Testing Strategy
#### Decision: What testing approach should we use?
**Options:**
- **Option A**: Manual testing only
- **Option B**: Manual + automated unit tests for plugin methods
- **Option C**: Manual + integration tests
**Recommendation:** **Option A** (manual testing) for now
- Plugins are hard to unit test (require native environment)
- Manual testing is sufficient for initial implementation
- Can add automated tests later if needed
**Test Scenarios:**
1. Share image from Photos app → Verify appears in app
2. Share while app backgrounded → Verify appears when app becomes active
3. Share while app closed → Verify appears on app launch
4. Multiple rapid shares → Verify only latest is processed
5. Share then close app before processing → Verify data persists
6. Share then clear app data → Verify graceful handling
**Decision Needed:** ✅ Confirm testing approach
---
### 12. Documentation Updates
#### Decision: What documentation needs updating?
**Files to Update:**
- ✅ Implementation plan (this document)
- ⚠️ `doc/native-share-target-implementation.md` - Update to reflect plugin approach
- ⚠️ `doc/ios-share-implementation-status.md` - Mark plugin as implemented
- ⚠️ Code comments in `main.capacitor.ts` - Update to reflect plugin usage
**Decision Needed:** ✅ Confirm documentation update list
---
## Summary of Decisions Needed
| # | Decision | Recommendation | Status |
|---|----------|----------------|--------|
| 1 | Plugin Methods | Option B: `getSharedImage()` + `hasSharedImage()` | ✅ Confirmed |
| 2 | Error Handling | Option B: `null` for no data, `reject()` for errors | ✅ Confirmed |
| 3 | Data Clearing | Option A: Clear immediately after reading | ✅ Confirmed |
| 4 | iOS Registration | Option A: Auto-discovery | ✅ Confirmed |
| 5 | TypeScript Interface | Proposed interface (see above) | ✅ Confirmed |
| 6 | Android Storage Timing | Option A: Store immediately on share intent | ✅ Confirmed |
| 7 | Migration Strategy | Option A: Clean break, remove temp file code | ✅ Confirmed |
| 8 | Plugin Naming | Option A: `SharedImage` | ✅ Confirmed |
| 9 | iOS Code Reuse | Option C: Shared utility | ✅ Confirmed |
| 10 | Android Key Names | Separate keys: `shared_image_base64`, `shared_image_file_name` | ✅ Confirmed |
| 11 | Testing Strategy | Option A: Manual testing | ✅ Confirmed |
| 12 | Documentation | Update listed files | ✅ Confirmed |
| - | Multiple Images | Single image only (SharedPhotoView requirement) | ✅ Confirmed |
| - | Backward Compatibility | No temp file backward compatibility | ✅ Confirmed |
## Next Steps
1. **Review this checklist** and confirm or modify recommendations
2. **Make decisions** on all pending items
3. **Update implementation plan** with confirmed decisions
4. **Begin implementation** with clear specifications
## Questions to Consider
- Are there any edge cases not covered?
- Should we support multiple images (currently only first image)?
- Should we add image metadata (size, MIME type) in the future?
- Do we need backward compatibility with temp file approach?
- Should plugin methods be synchronous or async? (Capacitor plugins are async by default)

View File

@@ -0,0 +1,76 @@
# Xcode 26 / CocoaPods Compatibility Workaround
**Date:** 2025-01-27
**Issue:** CocoaPods `xcodeproj` gem (1.27.0) doesn't support Xcode 26's project format version 70
## The Problem
Xcode 26.1.1 uses project format version 70, but the `xcodeproj` gem (1.27.0) only supports up to version 56. This causes CocoaPods to fail with:
```
ArgumentError - [Xcodeproj] Unable to find compatibility version string for object version `70`.
```
## Solutions
### Option 1: Temporarily Downgrade Project Format (Recommended for Now)
**Before running `pod install` or `npm run build:ios`:**
1. Edit `ios/App/App.xcodeproj/project.pbxproj`
2. Change line 6 from: `objectVersion = 70;` to: `objectVersion = 56;`
3. Run your build/sync command
4. Change it back to: `objectVersion = 70;` (Xcode will likely change it back automatically)
**Warning:** Xcode may automatically upgrade the format back to 70 when you open the project. This is okay - just repeat the process when needed.
### Option 2: Wait for xcodeproj Update
The `xcodeproj` gem maintainers will eventually release a version that supports format 70. You can:
- Check for updates: `bundle update xcodeproj`
- Monitor: https://github.com/CocoaPods/Xcodeproj/issues
### Option 3: Use Xcode Directly (Bypass CocoaPods for Now)
Since the Share Extension is already set up:
1. Open the project in Xcode
2. Build directly from Xcode (Product → Build)
3. Skip `npm run build:ios` for now
4. Test the Share Extension functionality
### Option 4: Automated Workaround (Integrated into Build Script) ✅
The workaround is now **automatically integrated** into `scripts/build-ios.sh`. When you run:
```bash
npm run build:ios
```
The build script will:
1. Automatically detect if the project format is version 70
2. Temporarily downgrade to version 56
3. Run `pod install`
4. Restore to version 70
5. Continue with the build
**No manual steps required!** The workaround is transparent and only applies when needed.
To remove the workaround in the future:
1. Check if `xcodeproj` gem supports format 70: `bundle exec gem list xcodeproj`
2. Test if `pod install` works without the workaround
3. If it works, remove the `run_pod_install_with_workaround()` function from `scripts/build-ios.sh`
4. Replace it with a simple `pod install` call
## Current Status
- ✅ Share Extension target exists
- ✅ Share Extension files are in place
- ✅ Workaround integrated into build script
-`npm run build:ios` works automatically
## Recommendation
**Use `npm run build:ios`** - the workaround is handled automatically. No manual intervention needed.
Once `xcodeproj` is updated to support format 70, the workaround can be removed from the build script.

View File

@@ -6,7 +6,7 @@ import electronIsDev from 'electron-is-dev';
import unhandled from 'electron-unhandled';
// import { autoUpdater } from 'electron-updater';
import { promises as fs } from 'fs';
import { join } from 'path';
import { join, basename } from 'path';
import { ElectronCapacitorApp, setupContentSecurityPolicy, setupReloadWatcher } from './setup';
@@ -151,15 +151,47 @@ app.on('activate', async function () {
* This provides a secure, native way to save files directly to the Downloads
* directory using the main process's file system access.
*
* Security: File names are sanitized to prevent path traversal attacks.
* Only safe file extensions are allowed (.json, .txt, .csv, .md).
*
* @param fileName - The name of the file to save (including extension)
* @param data - The data to write to the file (string or buffer)
* @returns Promise<{success: boolean, path?: string, error?: string}>
*/
ipcMain.handle('export-data-to-downloads', async (_event, fileName: string, data: string) => {
try {
// Security: Sanitize file name to prevent path traversal
// 1. Extract only the basename (removes any directory components)
const sanitizedBaseName = basename(fileName);
// 2. Reject if still contains path separators (shouldn't happen after basename, but double-check)
if (sanitizedBaseName.includes('/') || sanitizedBaseName.includes('\\')) {
throw new Error('Invalid file name: path separators not allowed');
}
// 3. Enforce allowed file extensions for security
const allowedExtensions = ['.json', '.txt', '.csv', '.md', '.log'];
const hasAllowedExtension = allowedExtensions.some(ext =>
sanitizedBaseName.toLowerCase().endsWith(ext.toLowerCase())
);
if (!hasAllowedExtension) {
throw new Error(`Invalid file extension. Allowed: ${allowedExtensions.join(', ')}`);
}
// 4. Additional validation: reject empty or suspicious names
if (!sanitizedBaseName || sanitizedBaseName.trim().length === 0) {
throw new Error('File name cannot be empty');
}
// 5. Reject names that are too long (prevent potential filesystem issues)
if (sanitizedBaseName.length > 255) {
throw new Error('File name too long (max 255 characters)');
}
// Get the user's Downloads directory path
const downloadsDir = app.getPath('downloads');
const filePath = join(downloadsDir, fileName);
const filePath = join(downloadsDir, sanitizedBaseName);
// Write the file to the Downloads directory
await fs.writeFile(filePath, data, 'utf-8');

View File

@@ -218,17 +218,50 @@ export class ElectronCapacitorApp {
}
}
// Set a CSP up for our application based on the custom scheme
/**
* Set up Content Security Policy for Electron application
*
* CSP is assembled from structured directives to prevent truncation/corruption.
* This ensures the CSP string is always complete and valid.
*
* @param customScheme - The custom URL scheme for the Electron app (e.g., 'capacitor-electron')
*/
export function setupContentSecurityPolicy(customScheme: string): void {
// Build CSP from structured directives to prevent truncation issues
const buildCSP = (isDev: boolean): string => {
const directives: string[] = [];
// Default source: allow custom scheme, inline scripts (required for some libs), and data URIs
const defaultSrc = [
`${customScheme}://*`,
"'unsafe-inline'",
"data:",
"https:",
];
if (isDev) {
// Development: allow devtools and eval for debugging
defaultSrc.push("devtools://*", "'unsafe-eval'", "http:");
}
directives.push(`default-src ${defaultSrc.join(" ")}`);
// Style source: allow custom scheme and inline styles
directives.push(`style-src ${customScheme}://* 'unsafe-inline'`);
// Font source: allow custom scheme and data URIs
directives.push(`font-src ${customScheme}://* data:`);
return directives.join("; ");
};
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const csp = buildCSP(electronIsDev);
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
electronIsDev
? `default-src ${customScheme}://* 'unsafe-inline' devtools://* 'unsafe-eval' data: https: http:; style-src ${customScheme}://* 'unsafe-inline'; font-src ${customScheme}://* data:`
: `default-src ${customScheme}://* 'unsafe-inline' data: https:; style-src ${customScheme}://* 'unsafe-inline'; font-src ${customScheme}://* data:`,
],
'Content-Security-Policy': [csp],
},
});
});

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@@ -15,8 +15,35 @@
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */; };
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
C86585DD2ED456DE00824752 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = C86585D42ED456DE00824752;
remoteInfo = TimeSafariShareExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
C86585E02ED456DE00824752 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
C86585DF2ED456DE00824752 /* TimeSafariShareExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
@@ -28,10 +55,39 @@
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimeSafariShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
C86585E52ED4577F00824752 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImageUtility.swift; sourceTree = "<group>"; };
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedImagePlugin.swift; sourceTree = "<group>"; };
E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
C86585D62ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
C86585E32ED456DE00824752 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = TimeSafariShareExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
504EC3011FED79650016851F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
@@ -41,6 +97,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D22ED456DE00824752 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -56,6 +119,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
504EC3051FED79650016851F /* Products */,
BA325FFCDCE8D334E5C7AEBE /* Pods */,
4B546315E668C7A13939F417 /* Frameworks */,
@@ -66,6 +130,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -73,6 +138,9 @@
504EC3061FED79650016851F /* App */ = {
isa = PBXGroup;
children = (
C8C56E152EE064CA00737D0E /* SharedImagePlugin.swift */,
C8C56E132EE0474B00737D0E /* SharedImageUtility.swift */,
C86585E52ED4577F00824752 /* App.entitlements */,
50379B222058CBB4000EE86E /* capacitor.config.json */,
504EC3071FED79650016851F /* AppDelegate.swift */,
504EC30B1FED79650016851F /* Main.storyboard */,
@@ -108,16 +176,40 @@
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
C86585E02ED456DE00824752 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
C86585DE2ED456DE00824752 /* PBXTargetDependency */,
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
C86585D42ED456DE00824752 /* TimeSafariShareExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */;
buildPhases = (
C86585D12ED456DE00824752 /* Sources */,
C86585D22ED456DE00824752 /* Frameworks */,
C86585D32ED456DE00824752 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
C86585D62ED456DE00824752 /* TimeSafariShareExtension */,
);
name = TimeSafariShareExtension;
packageProductDependencies = (
);
productName = TimeSafariShareExtension;
productReference = C86585D52ED456DE00824752 /* TimeSafariShareExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -125,7 +217,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastSwiftUpdateCheck = 2610;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
@@ -133,6 +225,9 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
C86585D42ED456DE00824752 = {
CreatedOnToolsVersion = 26.1.1;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -149,6 +244,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
C86585D42ED456DE00824752 /* TimeSafariShareExtension */,
);
};
/* End PBXProject section */
@@ -167,6 +263,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D32ED456DE00824752 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -253,12 +356,29 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C8C56E162EE064CB00737D0E /* SharedImagePlugin.swift in Sources */,
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
C8C56E142EE0474B00737D0E /* SharedImageUtility.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
C86585D12ED456DE00824752 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
C86585DE2ED456DE00824752 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = C86585D42ED456DE00824752 /* TimeSafariShareExtension */;
targetProxy = C86585DD2ED456DE00824752 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -402,8 +522,9 @@
baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 47;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +534,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -429,8 +550,9 @@
baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 47;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +562,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.2;
MARKETING_VERSION = 1.1.5;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -450,6 +572,80 @@
};
name = Release;
};
C86585E12ED456DE00824752 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
C86585E22ED456DE00824752 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 50;
DEVELOPMENT_TEAM = GM3FS5JQPH;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = TimeSafariShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TimeSafari;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.5;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -471,6 +667,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
C86585E42ED456DE00824752 /* Build configuration list for PBXNativeTarget "TimeSafariShareExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
C86585E12ED456DE00824752 /* Debug */,
C86585E22ED456DE00824752 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "504EC3031FED79650016851F"
BuildableName = "App.app"
BlueprintName = "App"
ReferencedContainer = "container:App.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari.share</string>
</array>
</dict>
</plist>

View File

@@ -12,9 +12,49 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
//let sqlite = SQLite()
//sqlite.initialize()
// Register SharedImage plugin manually after bridge is ready
// Try multiple times with increasing delays to ensure bridge is initialized
var attempts = 0
let maxAttempts = 5
func tryRegister() {
attempts += 1
if registerSharedImagePlugin() {
print("[AppDelegate] ✅ Plugin registration successful on attempt \(attempts)")
} else if attempts < maxAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) {
tryRegister()
}
} else {
print("[AppDelegate] ⚠️ Failed to register plugin after \(maxAttempts) attempts")
}
}
// Start registration attempts
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
tryRegister()
}
// Override point for customization after application launch.
return true
}
@discardableResult
private func registerSharedImagePlugin() -> Bool {
guard let window = self.window,
let bridgeVC = window.rootViewController as? CAPBridgeViewController,
let bridge = bridgeVC.bridge else {
return false
}
// Create plugin instance
// The @objc(SharedImage) annotation makes it available as "SharedImage" to Objective-C
// which matches the JavaScript registration name
let pluginInstance = SharedImagePlugin()
bridge.registerPluginInstance(pluginInstance)
print("[AppDelegate] ✅ Registered SharedImagePlugin (exposed as 'SharedImage' via @objc annotation)")
return true
}
func applicationWillResignActive(_ application: UIApplication) {
// Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
@@ -32,6 +72,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) {
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
// Check for shared image from Share Extension when app becomes active
checkForSharedImageOnActivation()
}
/**
* Check for shared image when app launches or becomes active
* This allows the app to detect shared images without requiring a deep link
* Note: JavaScript will read the shared image via SharedImagePlugin, so we just check the flag
*/
private func checkForSharedImageOnActivation() {
// Check if shared photo is ready
if SharedImageUtility.isSharedPhotoReady() {
// Clear the flag
SharedImageUtility.clearSharedPhotoReadyFlag()
// Post notification for JavaScript to handle navigation
// JavaScript will read the shared image via SharedImagePlugin
NotificationCenter.default.post(name: NSNotification.Name("SharedPhotoReady"), object: nil)
}
}
func applicationWillTerminate(_ application: UIApplication) {
@@ -41,6 +101,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
// Note: Share Extension opens app with timesafari:// (empty path), which is handled by JavaScript
// via the appUrlOpen listener in main.capacitor.ts
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
@@ -50,5 +112,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// tracking app url opens, make sure to keep this call
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

View File

@@ -0,0 +1,66 @@
//
// SharedImagePlugin.swift
// App
//
// Capacitor plugin for accessing shared image data from Share Extension
//
import Foundation
import Capacitor
@objc(SharedImage)
public class SharedImagePlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - CAPBridgedPlugin Conformance
public var identifier: String {
return "SharedImage"
}
public var jsName: String {
return "SharedImage"
}
public var pluginMethods: [CAPPluginMethod] {
return [
CAPPluginMethod(#selector(getSharedImage(_:)), returnType: .promise),
CAPPluginMethod(#selector(hasSharedImage(_:)), returnType: .promise)
]
}
// MARK: - Plugin Methods
/**
* Get shared image data from App Group UserDefaults
* Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading
*/
@objc public func getSharedImage(_ call: CAPPluginCall) {
guard let sharedData = SharedImageUtility.getSharedImageData() else {
// No shared image exists - return null (not an error)
call.resolve([
"base64": NSNull(),
"fileName": NSNull()
])
return
}
// Return the shared image data
call.resolve([
"base64": sharedData["base64"] ?? "",
"fileName": sharedData["fileName"] ?? ""
])
}
/**
* Check if shared image exists without reading it
* Useful for quick checks before calling getSharedImage()
*/
@objc public func hasSharedImage(_ call: CAPPluginCall) {
let hasImage = SharedImageUtility.hasSharedImage()
call.resolve([
"hasImage": hasImage
])
}
}

View File

@@ -0,0 +1,107 @@
//
// SharedImageUtility.swift
// App
//
// Shared utility for accessing shared image data from App Group container
// Images are stored as files in the App Group container to avoid UserDefaults size limits
// Used by both AppDelegate and SharedImagePlugin
//
import Foundation
public class SharedImageUtility {
private static let appGroupIdentifier = "group.app.timesafari.share"
private static let sharedPhotoFileNameKey = "sharedPhotoFileName"
private static let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private static let sharedPhotoReadyKey = "sharedPhotoReady"
/// Get the App Group container URL for accessing shared files
private static var appGroupContainerURL: URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
/**
* Get shared image data from App Group container file
* All images are stored as files for consistency and to avoid UserDefaults size limits
* Clears the data after reading to prevent re-reading
*
* @returns Dictionary with "base64" and "fileName" keys, or nil if no shared image
*/
static func getSharedImageData() -> [String: String]? {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return nil
}
// Get file path and filename from UserDefaults
guard let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
let containerURL = appGroupContainerURL else {
return nil
}
let fileName = userDefaults.string(forKey: sharedPhotoFileNameKey) ?? "shared-image.jpg"
let fileURL = containerURL.appendingPathComponent(filePath)
// Read image data from file
guard let imageData = try? Data(contentsOf: fileURL) else {
return nil
}
// Convert file data to base64 for JavaScript consumption
let base64String = imageData.base64EncodedString()
// Clear the shared data after reading
userDefaults.removeObject(forKey: sharedPhotoFilePathKey)
userDefaults.removeObject(forKey: sharedPhotoFileNameKey)
// Remove the file
try? FileManager.default.removeItem(at: fileURL)
userDefaults.synchronize()
return ["base64": base64String, "fileName": fileName]
}
/**
* Check if shared image exists without reading it
*
* @returns true if shared image file exists, false otherwise
*/
static func hasSharedImage() -> Bool {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier),
let filePath = userDefaults.string(forKey: sharedPhotoFilePathKey),
let containerURL = appGroupContainerURL else {
return false
}
let fileURL = containerURL.appendingPathComponent(filePath)
return FileManager.default.fileExists(atPath: fileURL.path)
}
/**
* Check if shared photo ready flag is set
* This flag is set by the Share Extension when image is ready
*
* @returns true if flag is set, false otherwise
*/
static func isSharedPhotoReady() -> Bool {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return false
}
return userDefaults.bool(forKey: sharedPhotoReadyKey)
}
/**
* Clear the shared photo ready flag
* Called after processing the shared image
*/
static func clearSharedPhotoReadyFlag() {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.removeObject(forKey: sharedPhotoReadyKey)
userDefaults.synchronize()
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<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>
</dict>
</plist>

View File

@@ -0,0 +1,207 @@
//
// ShareViewController.swift
// TimeSafariShareExtension
//
// Created by Aardimus on 11/24/25.
//
import UIKit
import UniformTypeIdentifiers
class ShareViewController: UIViewController {
private let appGroupIdentifier = "group.app.timesafari.share"
private let sharedPhotoFileNameKey = "sharedPhotoFileName"
private let sharedPhotoFilePathKey = "sharedPhotoFilePath"
private let sharedImageFileName = "shared-image"
/// Get the App Group container URL for storing shared files
private var appGroupContainerURL: URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
override func viewDidLoad() {
super.viewDidLoad()
// Set a minimal background (transparent or loading indicator)
view.backgroundColor = .systemBackground
// Process image immediately without showing UI
processAndOpenApp()
}
private func processAndOpenApp() {
// extensionContext is automatically available on UIViewController when used as extension principal class
guard let context = extensionContext,
let inputItems = context.inputItems as? [NSExtensionItem] else {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
return
}
processSharedImage(from: inputItems) { [weak self] success in
guard let self = self, let context = self.extensionContext else {
return
}
if success {
// Set flag that shared photo is ready
self.setSharedPhotoReadyFlag()
// Open the main app (using minimal URL - app will detect shared data on activation)
self.openMainApp()
}
// Complete immediately - no UI shown
context.completeRequest(returningItems: [], completionHandler: nil)
}
}
private func setSharedPhotoReadyFlag() {
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return
}
userDefaults.set(true, forKey: "sharedPhotoReady")
userDefaults.synchronize()
}
private func processSharedImage(from items: [NSExtensionItem], completion: @escaping (Bool) -> Void) {
// Find the first image attachment
for item in items {
guard let attachments = item.attachments else {
continue
}
for attachment in attachments {
// Skip non-image attachments
guard attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
continue
}
// Try to load raw data first to preserve original format
// This preserves the original image format without conversion
attachment.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { [weak self] (data, error) in
guard let self = self else {
completion(false)
return
}
if error != nil {
completion(false)
return
}
// Handle different image data types
// loadItem(forTypeIdentifier:) typically returns URL or Data, not UIImage
var imageData: Data?
var fileName: String = "shared-image"
if let url = data as? URL {
// Most common case: Image provided as file URL - read raw data to preserve format
let accessing = url.startAccessingSecurityScopedResource()
defer {
if accessing {
url.stopAccessingSecurityScopedResource()
}
}
// Read raw data directly to preserve original format
imageData = try? Data(contentsOf: url)
fileName = url.lastPathComponent
// Fallback: if raw data read fails, try UIImage and convert to PNG (lossless)
if imageData == nil, let image = UIImage(contentsOfFile: url.path) {
imageData = image.pngData()
fileName = self.getFileNameWithExtension(url.lastPathComponent, newExtension: "png")
}
} else if let data = data as? Data {
// Less common: Image provided as raw Data - use directly to preserve format
imageData = data
fileName = attachment.suggestedName ?? "shared-image"
}
guard let finalImageData = imageData else {
completion(false)
return
}
// Store image as file in App Group container
if self.storeImageData(finalImageData, fileName: fileName) {
completion(true)
} else {
completion(false)
}
}
return // Process only the first image
}
}
// No image found
completion(false)
}
/// Helper to get filename with a new extension, preserving base name
private func getFileNameWithExtension(_ originalName: String, newExtension: String) -> String {
if let nameWithoutExt = originalName.components(separatedBy: ".").first, !nameWithoutExt.isEmpty {
return "\(nameWithoutExt).\(newExtension)"
}
return "shared-image.\(newExtension)"
}
/// Store image data as a file in the App Group container
/// All images are stored as files regardless of size for consistency and simplicity
/// Returns true if successful, false otherwise
private func storeImageData(_ imageData: Data, fileName: String) -> Bool {
guard let containerURL = appGroupContainerURL else {
return false
}
// Create file URL in the container using the actual filename
// Extract extension from fileName if present, otherwise use sharedImageFileName
let actualFileName = fileName.isEmpty ? sharedImageFileName : fileName
let fileURL = containerURL.appendingPathComponent(actualFileName)
// Remove old file if it exists
try? FileManager.default.removeItem(at: fileURL)
// Write image data to file
do {
try imageData.write(to: fileURL)
} catch {
return false
}
// Store file path and filename in UserDefaults (small data, safe to store)
guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else {
return false
}
// Store relative path and filename
userDefaults.set(actualFileName, forKey: sharedPhotoFilePathKey)
userDefaults.set(fileName, forKey: sharedPhotoFileNameKey)
// Clean up any old base64 data that might exist
userDefaults.removeObject(forKey: "sharedPhotoBase64")
userDefaults.synchronize()
return true
}
private func openMainApp() {
// Open the main app with minimal URL - app will detect shared data on activation
guard let url = URL(string: "timesafari://") else {
return
}
var responder: UIResponder? = self
while responder != nil {
if let application = responder as? UIApplication {
application.open(url, options: [:], completionHandler: nil)
return
}
responder = responder?.next
}
// Fallback: use extension context
extensionContext?.open(url, completionHandler: nil)
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.timesafari.share</string>
</array>
</dict>
</plist>

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.3-beta",
"version": "1.1.6-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.3-beta",
"version": "1.1.6-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
@@ -27,6 +27,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
@@ -6789,6 +6790,17 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz",
"integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.3-beta",
"version": "1.1.6-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"
@@ -10,10 +10,10 @@
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"type-safety-check": "./scripts/type-safety-check.sh",
"type-check": "tsc --noEmit",
"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 scripts/copy-wasm.js",
"test:prerequisites": "node scripts/check-prerequisites.js",
"check:dependencies": "./scripts/check-dependencies.sh",
"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 && echo '\n\n\nGotta add the performance tests'",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
"test:mobile": "./scripts/test-mobile.sh",
"test:android": "node scripts/test-android.js",
@@ -27,8 +27,8 @@
"auto-run:android": "./scripts/auto-run.sh --platform=android",
"auto-run:electron": "./scripts/auto-run.sh --platform=electron",
"build:capacitor": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --mode capacitor --config vite.config.capacitor.mts",
"build:capacitor:sync": "npm run build:capacitor && npx cap sync",
"build:native": "vite build && npx cap sync && npx capacitor-assets generate",
"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",
"assets:config": "npx tsx scripts/assets-config.ts",
"assets:validate": "npx tsx scripts/assets-validator.ts",
"assets:validate:android": "./scripts/build-android.sh --assets-only",
@@ -64,6 +64,7 @@
"build:web:serve:test": "./scripts/build-web.sh --serve --test",
"build:web:serve:prod": "./scripts/build-web.sh --serve --prod",
"docker:up": "docker-compose up",
"build:web:build": "./scripts/build-web.sh",
"docker:up:test": "npm run build:web:build -- --mode test && docker-compose up test",
"docker:up:prod": "npm run build:web:build -- --mode production && docker-compose up production",
"docker:down": "docker-compose down",
@@ -156,6 +157,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",

View File

@@ -21,7 +21,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 4,
workers: 3,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],

View File

@@ -0,0 +1,60 @@
# Restore Local Capacitor Plugins
## Overview
The `restore-local-plugins.js` script ensures that local custom Capacitor plugins (`SafeArea` and `SharedImage`) are automatically restored to `android/app/src/main/assets/capacitor.plugins.json` after running `npx cap sync android`.
## Why This Is Needed
The `capacitor.plugins.json` file is auto-generated by Capacitor during `npx cap sync` and gets overwritten, removing any manually added local plugins. This script automatically restores them.
## Usage
### Automatic (Recommended)
The script is automatically run by:
- `./scripts/build-android.sh` (after `cap sync`)
- `npm run build:capacitor:sync`
- `npm run build:native`
### Manual
If you run `npx cap sync android` directly, you can restore plugins manually:
```bash
node scripts/restore-local-plugins.js
```
## What It Does
1. Reads `android/app/src/main/assets/capacitor.plugins.json`
2. Checks if local plugins (`SafeArea` and `SharedImage`) are present
3. Adds any missing local plugins
4. Preserves the existing JSON format
## Local Plugins
The following local plugins are automatically restored:
- **SafeArea**: `app.timesafari.safearea.SafeAreaPlugin`
- **SharedImage**: `app.timesafari.sharedimage.SharedImagePlugin`
## Adding New Local Plugins
To add a new local plugin, edit `scripts/restore-local-plugins.js` and add it to the `LOCAL_PLUGINS` array:
```javascript
const LOCAL_PLUGINS = [
// ... existing plugins ...
{
pkg: 'YourPluginName',
classpath: 'app.timesafari.yourpackage.YourPluginClass'
}
];
```
## Notes
- The script is idempotent - running it multiple times won't create duplicates
- The script preserves the existing JSON formatting (tabs, etc.)
- If the plugins file doesn't exist, the script will exit with an error (run `npx cap sync android` first)

View File

@@ -385,6 +385,7 @@ fi
if [ "$SYNC_ONLY" = true ]; then
log_info "Sync-only mode: syncing with Capacitor"
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
log_success "Sync completed successfully!"
exit 0
fi
@@ -472,6 +473,9 @@ fi
# Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8.5: Restore local plugins (capacitor.plugins.json gets overwritten by cap sync)
safe_execute "Restoring local plugins" "node scripts/restore-local-plugins.js" || exit 7
# Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7

View File

@@ -24,7 +24,6 @@ SENSITIVE=(
"android/**"
"ios/**"
"sw_scripts/**"
"sw_combine.js"
"Dockerfile"
"docker/**"
"capacitor.config.ts"

View File

@@ -404,8 +404,141 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Install CocoaPods dependencies (with Xcode 26 workaround)
# ===================================================================
# WORKAROUND: Xcode 26 / CocoaPods Compatibility Issue
# ===================================================================
# Xcode 26 uses project format version 70, but CocoaPods' xcodeproj gem
# (1.27.0) only supports up to version 56. This causes pod install to fail.
#
# This workaround temporarily downgrades the project format to 56, runs
# pod install, then restores it to 70. Xcode will automatically upgrade
# it back to 70 when opened, which is fine.
#
# NOTE: Both explicit pod install AND Capacitor sync (which runs pod install
# internally) need this workaround. See run_pod_install_with_workaround()
# and run_cap_sync_with_workaround() functions below.
#
# TO REMOVE THIS WORKAROUND IN THE FUTURE:
# 1. Check if xcodeproj gem has been updated: bundle exec gem list xcodeproj
# 2. Test if pod install works without the workaround
# 3. If it works, remove both workaround functions below
# 4. Replace with:
# - safe_execute "Installing CocoaPods dependencies" "cd ios/App && bundle exec pod install && cd ../.." || exit 6
# - safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# 5. Update this comment to indicate the workaround has been removed
# ===================================================================
run_pod_install_with_workaround() {
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
log_info "Installing CocoaPods dependencies (with Xcode 26 workaround)..."
# Check if project file exists
if [ ! -f "$PROJECT_FILE" ]; then
log_error "Project file not found: $PROJECT_FILE"
return 1
fi
# Check current format version
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
if [ -z "$current_version" ]; then
log_error "Could not determine project format version"
return 1
fi
log_debug "Current project format version: $current_version"
# Only apply workaround if format is 70
if [ "$current_version" = "70" ]; then
log_debug "Applying Xcode 26 workaround: temporarily downgrading to format 56"
# Downgrade to format 56 (supported by CocoaPods)
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
log_error "Failed to downgrade project format"
return 1
fi
# Run pod install
log_info "Running pod install..."
if ! (cd ios/App && bundle exec pod install && cd ../..); then
log_error "pod install failed"
# Try to restore format even on failure
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
return 1
fi
# Restore to format 70
log_debug "Restoring project format to 70..."
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
fi
log_success "CocoaPods dependencies installed successfully"
else
# Format is not 70, run pod install normally
log_debug "Project format is $current_version, running pod install normally"
if ! (cd ios/App && bundle exec pod install && cd ../..); then
log_error "pod install failed"
return 1
fi
log_success "CocoaPods dependencies installed successfully"
fi
}
safe_execute "Installing CocoaPods dependencies" "run_pod_install_with_workaround" || exit 6
# Step 6.5: Sync with Capacitor (also needs workaround since it runs pod install internally)
# Capacitor sync internally runs pod install, so we need to apply the workaround here too
run_cap_sync_with_workaround() {
local PROJECT_FILE="ios/App/App.xcodeproj/project.pbxproj"
# Check current format version
local current_version=$(grep "objectVersion" "$PROJECT_FILE" | head -1 | grep -o "[0-9]\+" || echo "")
if [ -z "$current_version" ]; then
log_error "Could not determine project format version for Capacitor sync"
return 1
fi
# Only apply workaround if format is 70
if [ "$current_version" = "70" ]; then
log_debug "Applying Xcode 26 workaround for Capacitor sync: temporarily downgrading to format 56"
# Downgrade to format 56 (supported by CocoaPods)
if ! sed -i '' 's/objectVersion = 70;/objectVersion = 56;/' "$PROJECT_FILE"; then
log_error "Failed to downgrade project format for Capacitor sync"
return 1
fi
# Run Capacitor sync (which will run pod install internally)
log_info "Running Capacitor sync..."
if ! npx cap sync ios; then
log_error "Capacitor sync failed"
# Try to restore format even on failure
sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE" || true
return 1
fi
# Restore to format 70
log_debug "Restoring project format to 70 after Capacitor sync..."
if ! sed -i '' 's/objectVersion = 56;/objectVersion = 70;/' "$PROJECT_FILE"; then
log_warn "Failed to restore project format to 70 (Xcode will upgrade it automatically)"
fi
log_success "Capacitor sync completed successfully"
else
# Format is not 70, run sync normally
log_debug "Project format is $current_version, running Capacitor sync normally"
if ! npx cap sync ios; then
log_error "Capacitor sync failed"
return 1
fi
log_success "Capacitor sync completed successfully"
fi
}
safe_execute "Syncing with Capacitor" "run_cap_sync_with_workaround" || exit 6
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Restore Local Capacitor Plugins
*
* This script ensures that local custom plugins (SafeArea and SharedImage)
* are present in capacitor.plugins.json after `npx cap sync` runs.
*
* The capacitor.plugins.json file is auto-generated by Capacitor and gets
* overwritten during sync, so we need to restore our local plugins.
*
* Usage:
* node scripts/restore-local-plugins.js
*
* This should be run after `npx cap sync android` or `npx cap sync ios`
*/
const fs = require('fs');
const path = require('path');
const PLUGINS_FILE = path.join(__dirname, '../android/app/src/main/assets/capacitor.plugins.json');
// Local plugins that need to be added
const LOCAL_PLUGINS = [
{
pkg: 'SafeArea',
classpath: 'app.timesafari.safearea.SafeAreaPlugin'
},
{
pkg: 'SharedImage',
classpath: 'app.timesafari.sharedimage.SharedImagePlugin'
}
];
function restoreLocalPlugins() {
try {
// Read the current plugins file
if (!fs.existsSync(PLUGINS_FILE)) {
console.error(`❌ Plugins file not found: ${PLUGINS_FILE}`);
console.error(' Run "npx cap sync android" first to generate the file.');
process.exit(1);
}
const content = fs.readFileSync(PLUGINS_FILE, 'utf8');
let plugins = JSON.parse(content);
if (!Array.isArray(plugins)) {
console.error(`❌ Invalid plugins file format: expected array, got ${typeof plugins}`);
process.exit(1);
}
// Check which local plugins are missing
const existingPackages = new Set(plugins.map(p => p.pkg));
const missingPlugins = LOCAL_PLUGINS.filter(p => !existingPackages.has(p.pkg));
if (missingPlugins.length === 0) {
console.log('✅ All local plugins are already present in capacitor.plugins.json');
return;
}
// Add missing plugins
plugins.push(...missingPlugins);
// Write back to file with proper formatting (matching existing style)
const formatted = JSON.stringify(plugins, null, '\t');
fs.writeFileSync(PLUGINS_FILE, formatted + '\n', 'utf8');
console.log('✅ Restored local plugins to capacitor.plugins.json:');
missingPlugins.forEach(p => {
console.log(` - ${p.pkg} (${p.classpath})`);
});
} catch (error) {
console.error('❌ Error restoring local plugins:', error.message);
process.exit(1);
}
}
// Run the script
restoreLocalPlugins();

View File

@@ -1,6 +1,11 @@
#!/bin/bash
#
# Critical Files Migration Validator
# Author: Matthew Raymer
# Description: Validates migration status of critical files
#
echo 🔍 Critical Files Migration Validator"
echo "🔍 Critical Files Migration Validator"
echo "====================================="
# Function to check actual usage (not comments)
@@ -10,77 +15,87 @@ check_actual_usage() {
local description="$3"
# Remove comments and check for actual usage
local count=$(grep -v ^[[:space:]]*//\|^[[:space:]]*\*\|^[[:space:]]*<!--" "$file" | \
grep -v TODO.*migration\|FIXME.*migration" | \
local count=$(grep -v "^[[:space:]]*//\|^[[:space:]]*\*\|^[[:space:]]*<!--" "$file" | \
grep -v "TODO.*migration\|FIXME.*migration" | \
grep -v "Migration.*replaced\|migrated.*from" | \
grep -c $pattern" || echo 0)
grep -c "$pattern" || echo 0)
if [$count" -gt0 then
echo$description: $count instances
return 1 else
echo$description: None found
if [ "$count" -gt 0 ]; then
echo "$description: $count instances"
return 1
else
echo "$description: None found"
return 0
fi
}
# Function to check notification migration
check_notifications() {
local file="$1
local file="$1"
# Check for notification helpers
local has_helpers=$(grep -c "createNotifyHelpers" $file" || echo "0")
local has_helpers=$(grep -c "createNotifyHelpers" "$file" || echo "0")
# Check for direct $notify calls (excluding helper setup)
local direct_notify=$(grep -v "createNotifyHelpers" "$file" | \
grep -v this\.notify\." | \
grep -v "this\.notify\." | \
grep -c "this\.\$notify" || echo 0)
if $has_helpers" -gt0 && $direct_notify" -eq0 then
echo " ✅ Complete notification migration
if [ "$has_helpers" -gt 0 ] && [ "$direct_notify" -eq 0 ]; then
echo " ✅ Complete notification migration"
return 0
elif $has_helpers" -gt0 && $direct_notify" -gt0 then
echo " ⚠️ Mixed pattern: $direct_notify direct calls
return 1 else
echo " ❌ No notification migration
elif [ "$has_helpers" -gt 0 ] && [ "$direct_notify" -gt 0 ]; then
echo " ⚠️ Mixed pattern: $direct_notify direct calls"
return 1
else
echo " ❌ No notification migration"
return 1
fi
}
# Function to analyze a file
analyze_file() {
local file="$1 echo ""
local file="$1"
echo ""
echo "📄 Analyzing: $file"
echo "----------------------------------------"
local issues=0 # Check legacy patterns
echo "🔍 Legacy Patterns:
check_actual_usage$file aseUtil" "databaseUtil usage || ((issues++))
check_actual_usage "$filelogConsoleAndDb ConsoleAndDb usage || ((issues++))
check_actual_usage$file formServiceFactory\.getInstance ct PlatformService usage ||((issues++))
local issues=0
# Check legacy patterns
echo "🔍 Legacy Patterns:"
check_actual_usage "$file" "databaseUtil" "databaseUtil usage" || ((issues++))
check_actual_usage "$file" "logConsoleAndDb" "ConsoleAndDb usage" || ((issues++))
check_actual_usage "$file" "formServiceFactory\.getInstance" "PlatformService usage" || ((issues++))
# Check notifications
echo 🔔 Notifications:"
check_notifications "$file ||((issues++))
echo "🔔 Notifications:"
check_notifications "$file" || ((issues++))
# Check PlatformServiceMixin
echo "🔧 PlatformServiceMixin:"
local has_mixin=$(grep -cPlatformServiceMixin" $file || echo 0)
local has_mixins=$(grep -cmixins.*PlatformServiceMixin\|mixins.*\[PlatformServiceMixin" $file" || echo 0)
local has_mixin=$(grep -c "PlatformServiceMixin" "$file" || echo 0)
local has_mixins=$(grep -c "mixins.*PlatformServiceMixin\|mixins.*\[PlatformServiceMixin" "$file" || echo 0)
if $has_mixin" -gt 0 && $has_mixins" -gt0 then
echo " ✅ PlatformServiceMixin properly integrated elif $has_mixin" -gt 0 && $has_mixins" -eq0 then
echo " ⚠️ Imported but not used as mixin ((issues++))
if [ "$has_mixin" -gt 0 ] && [ "$has_mixins" -gt 0 ]; then
echo " ✅ PlatformServiceMixin properly integrated"
elif [ "$has_mixin" -gt 0 ] && [ "$has_mixins" -eq 0 ]; then
echo " ⚠️ Imported but not used as mixin"
((issues++))
else
echo " ❌ No PlatformServiceMixin usage ((issues++))
echo " ❌ No PlatformServiceMixin usage"
((issues++))
fi
# Check TODO comments
local todo_count=$(grep -c TODO.*migration\|FIXME.*migration" $file || echo "0) if $todo_count" -gt0 then
echo ⚠️ TODO/FIXME comments: $todo_count ((issues++))
local todo_count=$(grep -c "TODO.*migration\|FIXME.*migration" "$file" || echo "0")
if [ "$todo_count" -gt 0 ]; then
echo " ⚠️ TODO/FIXME comments: $todo_count"
((issues++))
fi
if$issues" -eq0 then
echo "✅ File is fully migrated else
echo❌ $issues issues found"
if [ "$issues" -eq 0 ]; then
echo "✅ File is fully migrated"
else
echo "$issues issues found"
fi
return $issues
@@ -88,35 +103,39 @@ analyze_file() {
# Main analysis
echo ""
echo 📊 Critical Files Analysis"
echo "📊 Critical Files Analysis"
echo "=========================="
# Critical files from our assessment
files=(
src/components/MembersList.vue"
"src/components/MembersList.vue"
"src/views/ContactsView.vue"
src/views/OnboardMeetingSetupView.vue"
src/db/databaseUtil.ts"
src/db/index.ts
"src/views/OnboardMeetingSetupView.vue"
"src/db/databaseUtil.ts"
"src/db/index.ts"
)
total_issues=0
for file in ${files[@]}"; do
for file in "${files[@]}"; do
if [ -f "$file" ]; then
analyze_file "$file"
total_issues=$((total_issues + $?))
else
echo ❌ File not found: $file"
echo "❌ File not found: $file"
fi
done
# Summary
echo "echo📋 Summary"
echo=========="
echo ""
echo "📋 Summary"
echo "=========="
echo "Files analyzed: ${#files[@]}"
echo "Total issues found: $total_issues"
if$total_issues" -eq 0]; then
echo "✅ All critical files are properly migrated exit 0 echo "❌ Migration issues require attention"
if [ "$total_issues" -eq 0 ]; then
echo "✅ All critical files are properly migrated"
exit 0
else
echo "❌ Migration issues require attention"
exit 1
fi
fi

11
server/.env Normal file
View File

@@ -0,0 +1,11 @@
# Relying Party Configuration
RP_ID=localhost
RP_NAME=Time Safari
RP_ORIGIN=http://localhost:8080
# Server Configuration
PORT=3002
HOST=0.0.0.0
# CORS (optional, defaults to RP_ORIGIN)
# CORS_ORIGIN=http://localhost:8080

11
server/.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Relying Party Configuration
RP_ID=localhost
RP_NAME=Time Safari
RP_ORIGIN=http://localhost:8080
# Server Configuration
PORT=3002
HOST=0.0.0.0
# CORS (optional, defaults to RP_ORIGIN)
# CORS_ORIGIN=http://localhost:8080

197
server/README.md Normal file
View File

@@ -0,0 +1,197 @@
# WebAuthn Verification Server
Server-side WebAuthn verification service for Time Safari.
## Why This Server Exists
WebAuthn verification **must** be performed server-side for security. Client-side verification can be tampered with and should never be trusted for security-critical operations.
### Security Rationale
1. **Trust Boundary**: The client bundle runs in an untrusted environment (user's browser). Any verification code in the client can be modified, bypassed, or replaced by an attacker.
2. **Attestation Verification**: During registration, the server must verify:
- The attestation signature is valid
- The authenticator is genuine (not a software emulator)
- The challenge matches what was issued
- The origin and RP ID are correct
3. **Authentication Verification**: During authentication, the server must verify:
- The signature is valid for the stored credential
- The challenge matches
- The counter has increased (replay attack prevention)
- The origin and RP ID are correct
4. **Credential Storage**: Credentials must be stored securely server-side with proper user binding to prevent unauthorized access.
### Offline Mode
The client includes an optional "offline mode" (`VITE_OFFLINE_WEBAUTHN_VERIFY=true`) that allows client-side verification, but this is:
- **Not recommended for production** - security can be compromised
- **Intended for development/testing** - when a server isn't available
- **Clearly documented** - with security warnings
### Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │────────▶│ WebAuthn │────────▶│ Authenticator
│ (Browser) │ │ Server │ │ (Passkey)
└─────────────┘ └──────────────┘ └─────────────┘
│ │
│ 1. Request options │
│◀─────────────────────────│
│ │
│ 2. Create credential │
│ (browser API) │
│ │
│ 3. Send attestation │
│────────────────────────▶│
│ │
│ 4. Verify & store │
│ (server-side only) │
│ │
│◀─────────────────────────│
│ 5. Return credential info│
```
The server acts as the **Relying Party (RP)** and performs all cryptographic verification that cannot be safely done client-side.
## Setup
1. Install dependencies:
```bash
npm install
```
2. Copy `.env.example` to `.env` and configure:
```bash
cp .env.example .env
```
3. Update `.env` with your Relying Party configuration:
```
RP_ID=your-domain.com
RP_NAME=Time Safari
RP_ORIGIN=https://your-app-url.com
```
## Development
Run in development mode with hot reload:
```bash
npm run dev
```
## Production
Build and run:
```bash
npm run build
npm start
```
## Endpoints
### POST /webauthn/registration/options
Generate registration options for a new passkey.
**Request:**
```json
{
"username": "User Name",
"userId": "optional-user-id"
}
```
**Response:**
```json
{
"rp": { "name": "Time Safari", "id": "localhost" },
"user": { "id": "...", "name": "User Name", "displayName": "User Name" },
"challenge": "...",
"pubKeyCredParams": [...],
...
}
```
### POST /webauthn/registration/verify
Verify a registration response.
**Request:**
```json
{
"options": { ... },
"attestationResponse": { ... }
}
```
**Response:**
```json
{
"verified": true,
"credential": {
"credentialID": "...",
"credentialPublicKey": [...],
"counter": 0
}
}
```
### POST /webauthn/authentication/options
Generate authentication options.
**Request:**
```json
{
"credentialId": "...",
"userId": "optional-user-id"
}
```
**Response:**
```json
{
"challenge": "...",
"rpId": "localhost",
"allowCredentials": [...],
...
}
```
### POST /webauthn/authentication/verify
Verify an authentication response.
**Request:**
```json
{
"options": { ... },
"assertionResponse": { ... }
}
```
**Response:**
```json
{
"verified": true,
"counter": 1
}
```
## Storage
**Development:** Uses in-memory storage (challenges and credentials).
**Production:** Replace with:
- Redis for challenge storage
- Database for credential persistence
- Session management for user binding
## Security Notes
- Challenges expire after 5 minutes
- Credentials are stored in-memory (lost on restart)
- In production, implement proper credential persistence and user binding
- Use HTTPS in production
- Validate origin and RP ID strictly

1333
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "timesafari-webauthn-server",
"version": "1.0.0",
"description": "WebAuthn verification server for Time Safari",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@simplewebauthn/server": "^9.0.0",
"fastify": "^4.24.3",
"zod": "^3.22.4",
"@fastify/cors": "^8.4.0",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

340
server/src/index.ts Normal file
View File

@@ -0,0 +1,340 @@
/**
* WebAuthn Verification Server
*
* Fastify-based server for WebAuthn registration and authentication verification.
* This server handles the server-side verification of WebAuthn credentials.
*
* @author Matthew Raymer
*/
import Fastify from "fastify";
import cors from "@fastify/cors";
import dotenv from "dotenv";
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
VerifyRegistrationResponseOpts,
VerifyAuthenticationResponseOpts,
} from "@simplewebauthn/types";
// Load environment variables
dotenv.config();
const fastify = Fastify({
logger: true,
});
// Register CORS
await fastify.register(cors, {
origin: process.env.RP_ORIGIN || "http://localhost:8080",
credentials: true,
});
// Relying Party configuration from environment
const rpId = process.env.RP_ID || "localhost";
const rpName = process.env.RP_NAME || "Time Safari";
const rpOrigin = process.env.RP_ORIGIN || "http://localhost:8080";
// In-memory challenge storage (for development)
// In production, use Redis or a database
interface ChallengeStore {
challenge: string;
userId?: string;
expiresAt: number;
}
const challengeStore = new Map<string, ChallengeStore>();
// Credential storage (in-memory for development)
// In production, use a database
interface StoredCredential {
credentialID: string;
credentialPublicKey: Uint8Array;
counter: number;
userId?: string;
createdAt: number;
}
const credentialStore = new Map<string, StoredCredential>();
// Cleanup expired challenges every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, value] of challengeStore.entries()) {
if (value.expiresAt < now) {
challengeStore.delete(key);
}
}
}, 5 * 60 * 1000);
/**
* POST /webauthn/registration/options
* Generate registration options for a new passkey
*/
fastify.post<{
Body: {
username?: string;
userId?: string;
};
}>("/webauthn/registration/options", async (request, reply) => {
try {
const { username, userId } = request.body;
const options = await generateRegistrationOptions({
rpName,
rpID: rpId,
userName: username || rpName + " User",
userID: userId || crypto.randomUUID(),
timeout: 60000,
attestationType: "none",
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
authenticatorAttachment: "platform",
},
});
// Store challenge for verification
const challengeKey = userId || options.user.id;
challengeStore.set(challengeKey, {
challenge: options.challenge,
userId: userId,
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
});
return options;
} catch (error) {
fastify.log.error(error);
reply.code(500).send({ error: "Failed to generate registration options" });
}
});
/**
* POST /webauthn/registration/verify
* Verify a registration response
*/
fastify.post<{
Body: {
options: PublicKeyCredentialCreationOptionsJSON;
attestationResponse: unknown;
};
}>("/webauthn/registration/verify", async (request, reply) => {
try {
const { options, attestationResponse } = request.body;
// Retrieve stored challenge
const challengeKey = options.user.id;
const storedChallenge = challengeStore.get(challengeKey);
if (!storedChallenge) {
reply.code(400).send({ error: "Challenge not found or expired" });
return;
}
if (storedChallenge.expiresAt < Date.now()) {
challengeStore.delete(challengeKey);
reply.code(400).send({ error: "Challenge expired" });
return;
}
// Verify registration response
const verification = await verifyRegistrationResponse({
response: attestationResponse as any,
expectedChallenge: storedChallenge.challenge,
expectedOrigin: rpOrigin,
expectedRPID: rpId,
});
// Clean up challenge
challengeStore.delete(challengeKey);
if (!verification.verified || !verification.registrationInfo) {
reply.code(400).send({ verified: false, error: "Verification failed" });
return;
}
// Store credential
const credentialID = verification.registrationInfo.credentialID;
credentialStore.set(credentialID, {
credentialID: credentialID,
credentialPublicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
userId: storedChallenge.userId,
createdAt: Date.now(),
});
return {
verified: true,
credential: {
credentialID: credentialID,
credentialPublicKey: Array.from(verification.registrationInfo.credentialPublicKey),
counter: verification.registrationInfo.counter,
},
};
} catch (error) {
fastify.log.error(error);
reply.code(500).send({ error: "Verification failed", details: String(error) });
}
});
/**
* POST /webauthn/authentication/options
* Generate authentication options for an existing passkey
*/
fastify.post<{
Body: {
credentialId?: string;
userId?: string;
};
}>("/webauthn/authentication/options", async (request, reply) => {
try {
const { credentialId, userId } = request.body;
// Find credential(s) for user
let credentials: StoredCredential[] = [];
if (credentialId) {
const cred = credentialStore.get(credentialId);
if (cred) {
credentials = [cred];
}
} else if (userId) {
credentials = Array.from(credentialStore.values()).filter(
(c) => c.userId === userId
);
} else {
reply.code(400).send({ error: "credentialId or userId required" });
return;
}
if (credentials.length === 0) {
reply.code(404).send({ error: "Credential not found" });
return;
}
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((cred) => ({
id: cred.credentialID,
transports: ["internal"],
})),
userVerification: "preferred",
});
// Store challenge for verification
const challengeKey = credentialId || userId || options.challenge;
challengeStore.set(challengeKey, {
challenge: options.challenge,
userId: userId,
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
});
return options;
} catch (error) {
fastify.log.error(error);
reply.code(500).send({ error: "Failed to generate authentication options" });
}
});
/**
* POST /webauthn/authentication/verify
* Verify an authentication response
*/
fastify.post<{
Body: {
options: PublicKeyCredentialRequestOptionsJSON;
assertionResponse: unknown;
};
}>("/webauthn/authentication/verify", async (request, reply) => {
try {
const { options, assertionResponse } = request.body;
// Find credential by ID
const credentialId = (assertionResponse as any).id;
const credential = credentialStore.get(credentialId);
if (!credential) {
reply.code(404).send({ error: "Credential not found" });
return;
}
// Retrieve stored challenge
const challengeKey = credentialId;
const storedChallenge = challengeStore.get(challengeKey);
if (!storedChallenge) {
reply.code(400).send({ error: "Challenge not found or expired" });
return;
}
if (storedChallenge.expiresAt < Date.now()) {
challengeStore.delete(challengeKey);
reply.code(400).send({ error: "Challenge expired" });
return;
}
// Verify authentication response
const verification = await verifyAuthenticationResponse({
response: assertionResponse as any,
expectedChallenge: storedChallenge.challenge,
expectedOrigin: rpOrigin,
expectedRPID: rpId,
authenticator: {
credentialID: credential.credentialID,
credentialPublicKey: credential.credentialPublicKey,
counter: credential.counter,
},
});
// Clean up challenge
challengeStore.delete(challengeKey);
if (!verification.verified) {
reply.code(400).send({ verified: false, error: "Verification failed" });
return;
}
// Update counter
if (verification.authenticationInfo) {
credential.counter = verification.authenticationInfo.newCounter;
}
return {
verified: true,
counter: credential.counter,
};
} catch (error) {
fastify.log.error(error);
reply.code(500).send({ error: "Verification failed", details: String(error) });
}
});
/**
* Health check endpoint
*/
fastify.get("/health", async () => {
return { status: "ok", timestamp: new Date().toISOString() };
});
// Start server
const start = async () => {
try {
const port = parseInt(process.env.PORT || "3002");
const host = process.env.HOST || "0.0.0.0";
await fastify.listen({ port, host });
fastify.log.info(`WebAuthn server listening on ${host}:${port}`);
fastify.log.info(`RP ID: ${rpId}, RP Name: ${rpName}, RP Origin: ${rpOrigin}`);
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();

21
server/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
<template>
<div id="sectionDataExport" :class="containerClasses">
<div :class="titleClasses">Data Export</div>
<div :class="titleClasses">Data Management</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
:class="exportButtonClasses"
@click="exportDatabase()"
>
{{ isExporting ? "Exporting..." : "Download Contacts" }}
{{ isExporting ? "Exporting..." : "Export Contacts" }}
</button>
<div
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
</li>
</ul>
</div>
<!-- Import Contacts -->
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="mt-2">
<input
type="file"
class="w-full bg-white rounded-md pe-2 file:border-0 file:bg-gradient-to-b file:from-blue-400 file:to-blue-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:me-2 file:rounded-s-md"
@change="uploadImportFile"
/>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-2">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<button
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</transition>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app";
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers } from "@/utils/notify";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { ImportContent } from "@/interfaces/accountView";
/**
* @vue-component
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Router instance injected by Vue
* Used for navigation
*/
$router!: Router;
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
*/
showRedNotificationDot = false;
/**
* Reference to the selected import file
* Used to store the file selected by the user for import
*/
private inputImportFileName: Blob | undefined;
/**
* Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called
@@ -200,12 +257,30 @@ export default class DataExportSection extends Vue {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods
? typeof contact.contactMethods === "string" &&
contact.contactMethods.trim() !== ""
? JSON.parse(contact.contactMethods)
: []
: [];
// $contacts() returns normalized contacts where contactMethods is already an array,
// but we handle both array and string cases for robustness
if (contact.contactMethods) {
if (Array.isArray(contact.contactMethods)) {
// Already an array, use it directly
exContact.contactMethods = contact.contactMethods;
} else {
// Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness)
const contactMethodsValue = contact.contactMethods as unknown;
if (
typeof contactMethodsValue === "string" &&
contactMethodsValue.trim() !== ""
) {
// String that needs parsing
exContact.contactMethods = JSON.parse(contactMethodsValue);
} else {
// Invalid data, use empty array
exContact.contactMethods = [];
}
}
} else {
// No contactMethods, use empty array
exContact.contactMethods = [];
}
return exContact;
});
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
this.showRedNotificationDot = false;
}
}
/**
* Handles file selection for contact import
* Stores the selected file for later processing
*/
async uploadImportFile(event: Event): Promise<void> {
this.inputImportFileName = (event.target as HTMLInputElement).files?.[0];
}
/**
* Checks if a contact import file has been selected
* Used to conditionally show the import button
*/
showContactImport(): boolean {
return !!this.inputImportFileName;
}
/**
* Processes the selected import file and navigates to the contact import view
* Parses the JSON file and extracts contact data for import
*/
async checkContactImports(): Promise<void> {
if (!this.inputImportFileName) {
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(this.inputImportFileName);
}
}
</script>

View File

@@ -76,7 +76,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template>
<!-- Empty state message -->
<li v-if="entities.length === 0" :class="emptyStateClasses">
<li v-if="hasNoEntities" :class="emptyStateClasses">
{{ emptyStateMessage }}
</li>
@@ -108,7 +108,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Everyone Else
Everyone
</li>
<PersonCard
v-for="person in alphabeticalContacts"
@@ -139,17 +139,65 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template>
<template v-else-if="entityType === 'projects'">
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
<!-- When showing projects without search: split into recently bookmarked and rest -->
<template v-if="!searchTerm.trim()">
<!-- Recently Bookmarked Section -->
<template v-if="recentBookmarkedProjects.length > 0">
<li
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
Recently Bookmarked
</li>
<ProjectCard
v-for="project in recentBookmarkedProjects"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
<!-- Rest of Projects Section -->
<li
v-if="recentBookmarkedProjects.length > 0"
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
>
All Projects
</li>
<ProjectCard
v-for="project in remainingProjects"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
<!-- When searching: show filtered results normally -->
<template v-else>
<ProjectCard
v-for="project in displayedEntities as PlanData[]"
:key="project.handleId"
:project="project"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflicted="isProjectConflicted(project.handleId)"
:notify="notify"
:conflict-context="conflictContext"
@project-selected="handleProjectSelected"
/>
</template>
</template>
</ul>
</template>
@@ -164,6 +212,10 @@ import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { TIMEOUTS } from "@/utils/notify";
/**
* Constants for infinite scroll configuration
@@ -171,6 +223,7 @@ import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
const INITIAL_BATCH_SIZE = 20;
const INCREMENT_SIZE = 20;
const RECENT_CONTACTS_COUNT = 3;
const RECENT_BOOKMARKED_PROJECTS_COUNT = 10;
/**
* EntityGrid - Unified grid layout for displaying people or projects
@@ -191,6 +244,7 @@ const RECENT_CONTACTS_COUNT = 3;
ProjectCard,
SpecialEntityCard,
},
mixins: [PlatformServiceMixin],
})
export default class EntityGrid extends Vue {
/** Type of entities to display */
@@ -202,15 +256,39 @@ export default class EntityGrid extends Vue {
isSearching = false;
searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches
apiServer = "";
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE;
infiniteScrollReset?: () => void;
scrollContainer?: HTMLElement;
/** Array of entities to display */
@Prop({ required: true })
entities!: Contact[] | PlanData[];
// Starred projects state (for showing recently bookmarked projects)
starredPlanHandleIds: string[] = [];
/**
* Array of entities to display
*
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
* Use $contactsByDateAdded() to ensure all contacts are included.
* Client-side filtering assumes the complete list is available.
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
* (newest first) for the "Recently Added" section to display correctly.
*
* For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
* projects internally from the API server. If provided, uses the provided list.
*/
@Prop({ required: false })
entities?: Contact[] | PlanData[];
/** Active user's DID */
@Prop({ required: true })
@@ -282,6 +360,33 @@ export default class EntityGrid extends Vue {
return "text-xs text-slate-500 italic col-span-full";
}
/**
* Check if there are no entities to display
*/
get hasNoEntities(): boolean {
if (this.entityType === "projects") {
// For projects: check internal state if no entities prop, otherwise check prop
const projectsToCheck = this.entities || this.allProjects;
return projectsToCheck.length === 0;
} else {
// For people: entities prop is required
return !this.entities || this.entities.length === 0;
}
}
/**
* Get the entities array to use (prop or internal state)
*/
get entitiesToUse(): Contact[] | PlanData[] {
if (this.entityType === "projects") {
// For projects: use prop if provided, otherwise use internal state
return this.entities || this.allProjects;
} else {
// For people: entities prop is required
return this.entities || [];
}
}
/**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied
@@ -294,12 +399,12 @@ export default class EntityGrid extends Vue {
// If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType);
return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
}
// Default: projects use infinite scroll
if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount);
return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
}
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
@@ -307,36 +412,99 @@ export default class EntityGrid extends Vue {
}
/**
* Get the 3 most recently added contacts (when showing contacts and not searching)
* Get the most recently added contacts (when showing contacts and not searching)
*
* NOTE: This assumes entities are already sorted by date added (newest first).
* See the entities prop documentation for details on using $contactsByDateAdded().
*/
get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return [];
}
// Entities are already sorted by date added (newest first)
return (this.entities as Contact[]).slice(0, 3);
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
}
/**
* Get the remaining contacts sorted alphabetically (when showing contacts and not searching)
* Get all contacts sorted alphabetically (when showing contacts and not searching)
* Includes contacts shown in "Recently Added" section as well
* Uses infinite scroll to control how many are displayed
*/
get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) {
if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return [];
}
// Skip the first 3 (recent contacts) and sort the rest alphabetically
// Sort all contacts alphabetically (including recent ones)
// Create a copy to avoid mutating the original array
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
return sorted.slice(0, toShow);
const sorted = [...(this.entities as Contact[])].sort(
(a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
},
);
// Apply infinite scroll: show based on displayedCount
return sorted.slice(0, this.displayedCount);
}
/**
* Get the 3 most recently bookmarked projects (when showing projects and not searching)
* The starredPlanHandleIds array order represents bookmark order (newest at the end)
*/
get recentBookmarkedProjects(): PlanData[] {
if (
this.entityType !== "projects" ||
this.searchTerm.trim() ||
this.starredPlanHandleIds.length === 0
) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Get the last 3 starred IDs (most recently bookmarked)
const recentStarredIds = this.starredPlanHandleIds.slice(
-RECENT_BOOKMARKED_PROJECTS_COUNT,
);
// Find projects matching those IDs, sorting with newest first
const recentProjects = recentStarredIds
.map((id) => projects.find((p) => p.handleId === id))
.filter((p): p is PlanData => p !== undefined)
.reverse();
return recentProjects;
}
/**
* Get all projects (when showing projects and not searching)
* Includes projects shown in "Recently Bookmarked" section as well
* Uses infinite scroll to control how many are displayed
*/
get remainingProjects(): PlanData[] {
if (this.entityType !== "projects" || this.searchTerm.trim()) {
return [];
}
const projects = this.entitiesToUse as PlanData[];
if (projects.length === 0) {
return [];
}
// Apply infinite scroll: show based on displayedCount
return projects.slice(0, this.displayedCount);
}
/**
@@ -391,6 +559,13 @@ export default class EntityGrid extends Vue {
return this.conflictChecker(did);
}
/**
* Check if a project handleId is conflicted
*/
isProjectConflicted(handleId: string): boolean {
return this.conflictChecker(handleId);
}
/**
* Handle person selection from PersonCard
*/
@@ -443,47 +618,28 @@ export default class EntityGrid extends Vue {
/**
* Perform the actual search
* Routes to server-side search for projects or client-side filtering for contacts
*/
async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) {
this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
return;
}
this.isSearching = true;
this.searchBeforeId = undefined; // Reset pagination for new search
try {
// Simulate async search (in case we need to add API calls later)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
if (this.entityType === "people") {
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
if (this.entityType === "projects") {
// Server-side search for projects (initial load, no beforeId)
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(undefined, searchLower);
} else {
this.filteredEntities = (this.entities as PlanData[])
.filter((project: PlanData) => {
const name = project.name?.toLowerCase() || "";
const handleId = project.handleId.toLowerCase();
return name.includes(searchLower) || handleId.includes(searchLower);
})
.sort((a: PlanData, b: PlanData) => {
// Sort alphabetically by name
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
// Client-side filtering for contacts (complete list)
await this.performContactSearch();
}
// Reset displayed count when search completes
@@ -494,6 +650,194 @@ export default class EntityGrid extends Vue {
}
}
/**
* Fetch projects from API server
* Unified method for both loading all projects and searching projects.
* If claimContents is provided, performs search and updates filteredEntities.
* If claimContents is not provided, loads all projects and updates allProjects.
*
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
*/
async fetchProjects(
beforeId?: string,
claimContents?: string,
): Promise<void> {
if (!this.apiServer) {
if (claimContents) {
this.filteredEntities = [];
} else {
this.allProjects = [];
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "API server not configured",
},
TIMEOUTS.SHORT,
);
}
return;
}
const isSearch = !!claimContents;
let url = `${this.apiServer}/api/v2/report/plans`;
// Build query parameters
const params: string[] = [];
if (claimContents) {
params.push(
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
);
}
if (beforeId) {
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
}
if (params.length > 0) {
url += `?${params.join("&")}`;
}
try {
const response = await fetch(url, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error(
isSearch ? "Failed to search projects" : "Failed to load projects",
);
}
const results = await response.json();
if (results.data) {
const newProjects = results.data.map(
(plan: PlanData & { rowId?: string }) => ({
...plan,
rowId: plan.rowId,
}),
);
if (isSearch) {
// Search mode: update filteredEntities
if (beforeId) {
// Pagination: append new projects to existing search results
this.filteredEntities.push(...newProjects);
} else {
// Initial search: replace array
this.filteredEntities = newProjects;
}
// Update searchBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.searchBeforeId = lastProject.rowId || undefined;
} else {
this.searchBeforeId = undefined; // No more results
}
} else {
// Load mode: update allProjects
if (beforeId) {
// Pagination: append new projects
this.allProjects.push(...newProjects);
} else {
// Initial load: replace array
this.allProjects = newProjects;
}
// Update loadBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.loadBeforeId = lastProject.rowId || undefined;
} else {
this.loadBeforeId = undefined; // No more results
}
}
} else {
// No data in response
if (isSearch) {
if (!beforeId) {
// Only clear on initial search, not pagination
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load, not pagination
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
}
} catch (error) {
logger.error(
`Error ${isSearch ? "searching" : "loading"} projects:`,
error,
);
if (isSearch) {
if (!beforeId) {
// Only clear on initial search error, not pagination error
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load error, not pagination error
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: isSearch
? "Failed to search projects. Please try again."
: "Failed to load projects. Please try again.",
},
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Client-side contact search
* Assumes entities prop contains complete contact list from local database
*/
async performContactSearch(): Promise<void> {
if (!this.entities) {
this.filteredEntities = [];
return;
}
// Simulate async (for consistency with project search)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Contacts don't need pagination (complete list)
this.searchBeforeId = undefined;
}
/**
* Clear the search
*/
@@ -502,6 +846,7 @@ export default class EntityGrid extends Vue {
this.filteredEntities = [];
this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// Clear any pending timeout
@@ -521,35 +866,163 @@ export default class EntityGrid extends Vue {
}
if (this.searchTerm.trim()) {
// Search mode: check filtered entities
return this.displayedCount < this.filteredEntities.length;
// Search mode: check if more results available
if (this.entityType === "projects") {
// Projects: can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
const hasMoreLoaded =
this.displayedCount < this.filteredEntities.length;
const canLoadMoreFromServer =
this.displayedCount >= this.filteredEntities.length &&
!!this.searchBeforeId &&
!this.isLoadingSearchMore;
return hasMoreLoaded || canLoadMoreFromServer;
} else {
// Contacts: client-side filtering returns all results at once
return this.displayedCount < this.filteredEntities.length;
}
}
// Non-search mode
if (this.entityType === "projects") {
// Projects: check if more available
return this.displayedCount < this.entities.length;
// Projects: check internal state or prop
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// Can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
const canLoadMoreFromServer =
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
!!beforeId &&
!this.isLoadingProjects;
return hasMoreLoaded || canLoadMoreFromServer;
}
// People: check if more alphabetical contacts available
// Total available = 3 recent + all alphabetical
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
return this.displayedCount < totalAvailable;
// All contacts are shown alphabetically (recent ones appear in both sections)
if (!this.entities) {
return false;
}
return this.displayedCount < this.entities.length;
}
/**
* Initialize infinite scroll on mount
*/
mounted(): void {
async mounted(): Promise<void> {
// Load apiServer for project searches/loads
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load starred project IDs for showing recently bookmarked projects
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error("Error loading projects on mount:", error);
} finally {
this.isLoadingProjects = false;
}
}
}
// Validate entities prop for people
if (this.entityType === "people" && !this.entities) {
logger.error(
"EntityGrid: entities prop is required when entityType is 'people'",
);
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Contacts data is required but not provided.",
},
TIMEOUTS.SHORT,
);
}
}
this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) {
const { reset } = useInfiniteScroll(
container,
() => {
// Load more: increment displayedCount
this.displayedCount += INCREMENT_SIZE;
async () => {
// Search mode: handle search pagination
if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.fetchProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
},
{
distance: 50, // pixels from bottom
@@ -575,21 +1048,79 @@ export default class EntityGrid extends Vue {
}
/**
* Watch for changes in search term to reset displayed count
* Watch for changes in entityType to load projects when switching to projects
*/
@Watch("entityType")
async onEntityTypeChange(newType: "people" | "projects"): Promise<void> {
// Reset displayed count and clear search when switching types
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
// When switching to projects, load them if not provided via entities prop
if (newType === "projects" && !this.entities) {
// Ensure apiServer is loaded
if (!this.apiServer) {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
this.starredPlanHandleIds = settings.starredPlanHandleIds || [];
}
// Load projects if we have an API server
if (this.apiServer && this.allProjects.length === 0) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error(
"Error loading projects when switching to projects:",
error,
);
} finally {
this.isLoadingProjects = false;
}
}
}
// Clear project state when switching away from projects
if (newType === "people") {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**
* Watch for changes in search term to reset displayed count and pagination
*/
@Watch("searchTerm")
onSearchTermChange(): void {
// Reset displayed count and pagination when search term changes
this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.();
}
/**
* Watch for changes in entities prop to reset displayed count
* Watch for changes in entities prop to clear search and reset displayed count
*/
@Watch("entities")
onEntitiesChange(): void {
// Clear search when entities change (fresh dialog open)
if (this.searchTerm) {
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
}
this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.();
// For projects: clear internal state if entities prop is provided
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;
}
}
/**

View File

@@ -8,18 +8,27 @@ notifications for conflicted entities * - Template streamlined with computed CSS
properties * * @author Matthew Raymer */
<template>
<div id="sectionGiftedGiver">
<label class="block font-bold mb-4">
<label class="block font-bold mb-1">
{{ stepLabel }}
</label>
<!-- Toggle link for entity type selection -->
<div class="text-right mb-4">
<button
type="button"
class="text-sm text-blue-600 hover:text-blue-800 underline font-medium"
@click="handleToggleEntityType"
>
{{ toggleLinkText }}
</button>
</div>
<EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts"
:entities="shouldShowProjects ? projects || undefined : allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="conflictChecker"
:show-you-entity="shouldShowYouEntity"
:you-selectable="youSelectable"
:notify="notify"
:conflict-context="conflictContext"
@@ -90,13 +99,9 @@ export default class EntitySelectionStep extends Vue {
@Prop({ default: false })
showProjects!: boolean;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Array of available projects */
@Prop({ required: true })
projects!: PlanData[];
/** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: false })
projects?: PlanData[];
/** Array of available contacts */
@Prop({ required: true })
@@ -160,15 +165,19 @@ export default class EntitySelectionStep extends Vue {
*/
get stepLabel(): string {
if (this.stepType === "recipient") {
return "Choose who received the gift:";
} else if (this.stepType === "giver") {
if (this.shouldShowProjects) {
return "Choose a project benefitted from:";
return "Choose recipient project";
} else {
return "Choose a person received from:";
return "Choose recipient person";
}
} else {
// this.stepType === "giver"
if (this.shouldShowProjects) {
return "Choose giving project";
} else {
return "Choose giving person";
}
}
return "Choose entity:";
}
/**
@@ -195,16 +204,6 @@ export default class EntitySelectionStep extends Vue {
return false;
}
/**
* Whether to show the "You" entity
*/
get shouldShowYouEntity(): boolean {
return (
this.stepType === "recipient" ||
(this.stepType === "giver" && this.isFromProjectView)
);
}
/**
* Whether the "You" entity is selectable
*/
@@ -212,6 +211,17 @@ export default class EntitySelectionStep extends Vue {
return !this.conflictChecker(this.activeDid);
}
/**
* Text for the toggle link
*/
get toggleLinkText(): string {
if (this.shouldShowProjects) {
return "... or choose a person instead →";
} else {
return "... or choose a project instead →";
}
}
/**
* Handle entity selection from EntityGrid
*/
@@ -222,6 +232,13 @@ export default class EntitySelectionStep extends Vue {
});
}
/**
* Handle toggle entity type button click
*/
handleToggleEntityType(): void {
this.emitToggleEntityType();
}
/**
* Handle cancel button click
*/
@@ -242,6 +259,11 @@ export default class EntitySelectionStep extends Vue {
emitCancel(): void {
// No return value needed
}
@Emit("toggle-entity-type")
emitToggleEntityType(): void {
// No return value needed
}
}
</script>

View File

@@ -1,16 +1,6 @@
/** * EntitySummaryButton.vue - Displays selected entity with edit capability *
* Extracted from GiftedDialog.vue to handle entity summary display in the gift *
details step with edit functionality. * * Features: * - Shows entity avatar
(person or project) * - Displays entity name and role label * - Handles editable
vs locked states * - Function props for parent control over edit behavior * -
Supports both person and project entity types * - Template streamlined with
computed CSS properties * * @author Matthew Raymer */
/* EntitySummaryButton.vue - Displays selected entity with edit capability */
<template>
<component
:is="editable ? 'button' : 'div'"
:class="containerClasses"
@click="handleClick"
>
<button :class="containerClasses" @click="handleClick">
<!-- Entity Icon/Avatar -->
<div>
<template v-if="entityType === 'project'">
@@ -47,14 +37,11 @@ computed CSS properties * * @author Matthew Raymer */
</h3>
</div>
<!-- Edit/Lock Icon -->
<p class="ms-auto text-sm pe-1" :class="iconClasses">
<font-awesome
:icon="editable ? 'pen' : 'lock'"
:title="editable ? 'Change' : 'Can\'t be changed'"
/>
<!-- Edit Icon -->
<p class="ms-auto text-sm pe-1 text-blue-500">
<font-awesome icon="pen" title="Change" />
</p>
</component>
</button>
</template>
<script lang="ts">
@@ -75,12 +62,12 @@ interface EntityData {
}
/**
* EntitySummaryButton - Displays selected entity with optional edit capability
* EntitySummaryButton - Displays selected entity with edit capability
*
* Features:
* - Shows entity avatar (person or project)
* - Displays entity name and role label
* - Handles editable vs locked states
* - Always editable - click to change entity
* - Function props for parent control over edit behavior
* - Supports both person and project entity types
* - Template streamlined with computed CSS properties
@@ -104,13 +91,9 @@ export default class EntitySummaryButton extends Vue {
@Prop({ required: true })
label!: string;
/** Whether the entity can be edited */
@Prop({ default: true })
editable!: boolean;
/**
* Function prop for handling edit requests
* Called when the button is clicked and editable, allowing parent to control edit behavior
* Called when the button is clicked, allowing parent to control edit behavior
*/
@Prop({ type: Function, default: () => {} })
onEditRequested!: (data: {
@@ -132,13 +115,6 @@ export default class EntitySummaryButton extends Vue {
return this.entity !== null && "profileImageUrl" in this.entity;
}
/**
* Computed CSS classes for the edit/lock icon
*/
get iconClasses(): string {
return this.editable ? "text-blue-500" : "text-slate-400";
}
/**
* Computed CSS classes for the entity name
*/
@@ -172,16 +148,13 @@ export default class EntitySummaryButton extends Vue {
}
/**
* Handle click event - only call function prop if editable
* Allows parent to control edit behavior and validation
* Handle click event - call function prop to allow parent to control edit behavior
*/
handleClick(): void {
if (this.editable) {
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
this.onEditRequested({
entityType: this.entityType,
entity: this.entity,
});
}
}
</script>
@@ -195,8 +168,4 @@ button {
button:hover {
background-color: #f1f5f9; /* hover:bg-slate-100 */
}
div {
cursor: default;
}
</style>

View File

@@ -16,7 +16,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="giver"
:entity-type="giverEntityType"
:label="giverLabel"
:editable="canEditGiver"
:on-edit-requested="handleEditGiver"
/>
@@ -25,7 +24,6 @@ control over updates and validation * * @author Matthew Raymer */
:entity="receiver"
:entity-type="recipientEntityType"
:label="recipientLabel"
:editable="canEditRecipient"
:on-edit-requested="handleEditRecipient"
/>
</div>
@@ -172,10 +170,6 @@ export default class GiftDetailsStep extends Vue {
@Prop({ default: "" })
prompt!: string;
/** Whether this is from a project view */
@Prop({ default: false })
isFromProjectView!: boolean;
/** Whether there's a conflict between giver and receiver */
@Prop({ default: false })
hasConflict!: boolean;
@@ -277,20 +271,6 @@ export default class GiftDetailsStep extends Vue {
: "Given to:";
}
/**
* Whether the giver can be edited
*/
get canEditGiver(): boolean {
return !(this.isFromProjectView && this.giverEntityType === "project");
}
/**
* Whether the recipient can be edited
*/
get canEditRecipient(): boolean {
return this.recipientEntityType === "person";
}
/**
* Computed CSS classes for submit button
*/

View File

@@ -3,19 +3,18 @@
<div
class="dialog"
data-testid="gifted-dialog"
:data-recipient-entity-type="recipientEntityType"
:data-recipient-entity-type="currentRecipientEntityType"
>
<!-- Step 1: Entity Selection -->
<EntitySelectionStep
v-show="firstStep"
:step-type="stepType"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
:show-projects="
giverEntityType === 'project' || recipientEntityType === 'project'
currentGiverEntityType === 'project' ||
currentRecipientEntityType === 'project'
"
:is-from-project-view="isFromProjectView"
:projects="projects"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
@@ -30,6 +29,7 @@
:offer-id="offerId"
:notify="$notify"
@entity-selected="handleEntitySelected"
@toggle-entity-type="handleToggleEntityType"
@cancel="cancel"
/>
@@ -38,13 +38,12 @@
v-show="!firstStep"
:giver="giver"
:receiver="receiver"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:giver-entity-type="currentGiverEntityType"
:recipient-entity-type="currentRecipientEntityType"
:description="description"
:amount="parseFloat(amountInput) || 0"
:unit-code="unitCode"
:prompt="prompt"
:is-from-project-view="isFromProjectView"
:has-conflict="hasPersonConflict"
:offer-id="offerId"
:from-project-id="fromProjectId"
@@ -68,7 +67,6 @@ import {
createAndSubmitGive,
didInfo,
serverMessageForUser,
getHeaders,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { Contact } from "../db/tables/contacts";
@@ -115,11 +113,10 @@ export default class GiftedDialog extends Vue {
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop({ default: "person" }) giverEntityType = "person" as
@Prop({ default: "person" }) initialGiverEntityType = "person" as
| "person"
| "project";
@Prop({ default: "person" }) recipientEntityType = "person" as
@Prop({ default: "person" }) initialRecipientEntityType = "person" as
| "person"
| "project";
@@ -133,8 +130,9 @@ export default class GiftedDialog extends Vue {
description = "";
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
currentGiverEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
currentRecipientEntityType: "person" | "project" = "person"; // Mutable version (can be toggled)
offerId = "";
projects: PlanData[] = [];
prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo;
stepType = "giver";
@@ -145,20 +143,12 @@ export default class GiftedDialog extends Vue {
didInfo = didInfo;
// Computed property to help debug template logic
get shouldShowProjects() {
const result =
(this.stepType === "giver" && this.giverEntityType === "project") ||
(this.stepType === "recipient" && this.recipientEntityType === "project");
return result;
}
// Computed property to check if current selection would create a conflict
get hasPersonConflict() {
// Only check for conflicts when both entities are persons
if (
this.giverEntityType !== "person" ||
this.recipientEntityType !== "person"
this.currentGiverEntityType !== "person" ||
this.currentRecipientEntityType !== "person"
) {
return false;
}
@@ -175,22 +165,56 @@ export default class GiftedDialog extends Vue {
return false;
}
// Computed property to check if a contact would create a conflict when selected
wouldCreateConflict(contactDid: string) {
// Only check for conflicts when both entities are persons
// Computed property to check if current selection would create a project conflict
get hasProjectConflict() {
// Only check for conflicts when both entities are projects
if (
this.giverEntityType !== "person" ||
this.recipientEntityType !== "person"
this.currentGiverEntityType !== "project" ||
this.currentRecipientEntityType !== "project"
) {
return false;
}
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === contactDid;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === contactDid;
// Check if giver and recipient are the same project
if (
this.giver?.handleId &&
this.receiver?.handleId &&
this.giver.handleId === this.receiver.handleId
) {
return true;
}
return false;
}
// Computed property to check if a contact or project would create a conflict when selected
wouldCreateConflict(identifier: string) {
// Check for person conflicts when both entities are persons
if (
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "person"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.did === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.did === identifier;
}
}
// Check for project conflicts when both entities are projects
if (
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "project"
) {
if (this.stepType === "giver") {
// If selecting as giver, check if it conflicts with current recipient
return this.receiver?.handleId === identifier;
} else if (this.stepType === "recipient") {
// If selecting as recipient, check if it conflicts with current giver
return this.giver?.handleId === identifier;
}
}
return false;
@@ -214,8 +238,9 @@ export default class GiftedDialog extends Vue {
this.amountInput = amountInput || "0";
this.unitCode = unitCode || "HUR";
this.callbackOnSuccess = callbackOnSuccess;
this.firstStep = !giver;
this.stepType = "giver";
// Initialize current entity types from initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
this.currentRecipientEntityType = this.initialRecipientEntityType;
try {
const settings = await this.$accountSettings();
@@ -226,6 +251,14 @@ export default class GiftedDialog extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
// Skip Step 1 if both giver and receiver are provided
const hasGiver = giver && (!!giver.did || !!giver.handleId);
const hasReceiver = receiver && (!!receiver.did || !!receiver.handleId);
this.firstStep = !hasGiver || !hasReceiver;
if (this.firstStep) {
this.stepType = giver ? "receiver" : "giver";
}
logger.debug("[GiftedDialog] Settings received:", {
activeDid: this.activeDid,
apiServer: this.apiServer,
@@ -234,16 +267,6 @@ export default class GiftedDialog extends Vue {
this.allContacts = await this.$contactsByDateAdded();
this.allMyDids = await retrieveAccountDids();
if (
this.giverEntityType === "project" ||
this.recipientEntityType === "project"
) {
await this.loadProjects();
} else {
// Clear projects array when not needed
this.projects = [];
}
} catch (err: unknown) {
logger.error("Error retrieving settings from database:", err);
this.safeNotify.error(
@@ -291,6 +314,8 @@ export default class GiftedDialog extends Vue {
this.prompt = "";
this.unitCode = "HUR";
this.firstStep = true;
// Reset to initial prop values
this.currentGiverEntityType = this.initialGiverEntityType;
}
async confirm() {
@@ -328,6 +353,15 @@ export default class GiftedDialog extends Vue {
return;
}
// Check for project conflict
if (this.hasProjectConflict) {
this.safeNotify.error(
"You cannot select the same project as both giver and recipient.",
TIMEOUTS.STANDARD,
);
return;
}
this.close();
this.safeNotify.toast(
NOTIFY_GIFTED_DETAILS_RECORDING_GIVE.message,
@@ -369,8 +403,8 @@ export default class GiftedDialog extends Vue {
let providerPlanHandleId: string | undefined;
if (
this.giverEntityType === "project" &&
this.recipientEntityType === "person"
this.currentGiverEntityType === "project" &&
this.currentRecipientEntityType === "person"
) {
// Project-to-person gift
fromDid = undefined; // No person giver
@@ -378,8 +412,8 @@ export default class GiftedDialog extends Vue {
fulfillsProjectHandleId = undefined; // No project recipient
providerPlanHandleId = this.giver?.handleId; // Project giver
} else if (
this.giverEntityType === "person" &&
this.recipientEntityType === "project"
this.currentGiverEntityType === "person" &&
this.currentRecipientEntityType === "project"
) {
// Person-to-project gift
fromDid = giverDid as string; // Person giver
@@ -489,27 +523,6 @@ export default class GiftedDialog extends Vue {
this.firstStep = false;
}
async loadProjects() {
try {
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to load projects");
}
const results = await response.json();
if (results.data) {
this.projects = results.data;
}
} catch (error) {
logger.error("Error loading projects:", error);
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
}
}
selectProject(project: PlanData) {
this.giver = {
did: project.handleId,
@@ -517,10 +530,13 @@ export default class GiftedDialog extends Vue {
image: project.image,
handleId: project.handleId,
};
this.receiver = {
did: this.activeDid,
name: "You",
};
// Only set receiver to "You" if no receiver has been selected yet
if (!this.receiver || !this.receiver.did) {
this.receiver = {
did: this.activeDid,
name: "You",
};
}
this.firstStep = false;
}
@@ -557,17 +573,22 @@ export default class GiftedDialog extends Vue {
return {
amountInput: this.amountInput,
description: this.description,
giverDid: this.giverEntityType === "person" ? this.giver?.did : undefined,
giverDid:
this.currentGiverEntityType === "person" ? this.giver?.did : undefined,
giverName: this.giver?.name,
offerId: this.offerId,
fulfillsProjectId:
this.recipientEntityType === "project" ? this.toProjectId : undefined,
this.currentRecipientEntityType === "project"
? this.toProjectId
: undefined,
providerProjectId:
this.giverEntityType === "project"
this.currentGiverEntityType === "project"
? this.giver?.handleId
: this.fromProjectId,
recipientDid:
this.recipientEntityType === "person" ? this.receiver?.did : undefined,
this.currentRecipientEntityType === "person"
? this.receiver?.did
: undefined,
recipientName: this.receiver?.name,
unitCode: this.unitCode,
};
@@ -627,6 +648,7 @@ export default class GiftedDialog extends Vue {
entityType: string;
currentEntity: { did: string; name: string };
}) {
// Always allow editing - go back to Step 1 to select a new entity
this.goBackToStep1(data.entityType);
}
@@ -637,6 +659,24 @@ export default class GiftedDialog extends Vue {
this.confirm();
}
/**
* Handle toggle entity type request from EntitySelectionStep
*/
handleToggleEntityType() {
// Toggle the appropriate entity type based on current step
if (this.stepType === "giver") {
this.currentGiverEntityType =
this.currentGiverEntityType === "person" ? "project" : "person";
// Clear any selected giver when toggling
this.giver = undefined;
} else if (this.stepType === "recipient") {
this.currentRecipientEntityType =
this.currentRecipientEntityType === "person" ? "project" : "person";
// Clear any selected receiver when toggling
this.receiver = undefined;
}
}
/**
* Handle amount update from GiftDetailsStep
*/

View File

@@ -0,0 +1,130 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
<!-- EntityGrid for projects -->
<EntityGrid
:entity-type="'projects'"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'project'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
*
* Features:
* - EntityGrid integration for project selection
* - No special entities (You, Unnamed)
* - Immediate assignment on project selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected project and closes the dialog
*/
handleEntitySelected(event: {
type: "person" | "project";
data: Contact | PlanData;
}) {
const project = event.data as PlanData;
this.emitAssign(project);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
this.emitOpen();
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
this.emitClose();
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(project: PlanData): PlanData {
return project;
}
@Emit("open")
emitOpen(): void {
// Emit when dialog opens
}
@Emit("close")
emitClose(): void {
// Emit when dialog closes
}
}
</script>
<style scoped></style>

View File

@@ -1,21 +1,18 @@
/** * ProjectCard.vue - Individual project display component * * Extracted from
GiftedDialog.vue to handle project entity display * with selection states and
issuer information. * * @author Matthew Raymer */
GiftedDialog.vue to handle project entity display * with selection states,
conflict detection, and issuer information. * * @author Matthew Raymer */
<template>
<li
class="flex items-center gap-2 px-2 py-1.5 border-b border-slate-300 hover:bg-slate-50 cursor-pointer"
@click="handleClick"
>
<li :class="cardClasses" @click="handleClick">
<ProjectIcon
:entity-id="project.handleId"
:icon-size="48"
:icon-size="30"
:image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<div class="overflow-hidden">
<h3 class="text-sm font-semibold truncate">
{{ project.name || unnamedProject }}
<h3 :class="nameClasses">
{{ displayName }}
</h3>
<div class="text-xs text-slate-500 truncate">
@@ -33,6 +30,7 @@ import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
import { didInfo } from "../libs/endorserServer";
import { UNNAMED_PROJECT } from "@/constants/entities";
import { NotificationIface } from "../constants/app";
/**
* ProjectCard - Displays a project entity with selection capability
@@ -42,6 +40,8 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
* - Displays project name and issuer information
* - Handles click events for selection
* - Shows issuer name using didInfo utility
* - Selection states (selectable, conflicted, disabled)
* - Warning notifications for conflicted entities
*/
@Component({
components: {
@@ -65,6 +65,18 @@ export default class ProjectCard extends Vue {
@Prop({ required: true })
allContacts!: Contact[];
/** Whether this project would create a conflict if selected */
@Prop({ default: false })
conflicted!: boolean;
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/** Context for conflict messages (e.g., "giver", "recipient") */
@Prop({ default: "other party" })
conflictContext!: string;
/**
* Get the unnamed project constant
*/
@@ -72,6 +84,51 @@ export default class ProjectCard extends Vue {
return UNNAMED_PROJECT;
}
/**
* Computed CSS classes for the card
*/
get cardClasses(): string {
const baseCardClasses =
"flex items-center gap-2 px-2 py-1.5 border-b border-slate-300";
if (this.conflicted) {
return `${baseCardClasses} *:opacity-50 cursor-not-allowed`;
}
return `${baseCardClasses} cursor-pointer hover:bg-slate-50`;
}
/**
* Computed CSS classes for the project name
*/
get nameClasses(): string {
const baseNameClasses = "text-sm font-semibold truncate";
if (this.conflicted) {
return `${baseNameClasses} text-slate-500`;
}
// Add italic styling for entities without set names
if (!this.project.name) {
return `${baseNameClasses} italic text-slate-500`;
}
return baseNameClasses;
}
/**
* Computed display name for the project
*/
get displayName(): string {
// If the project has a set name, use that name
if (this.project.name) {
return this.project.name;
}
// If the project does not have a set name
return this.unnamedProject;
}
/**
* Computed display name for the project issuer
*/
@@ -85,10 +142,23 @@ export default class ProjectCard extends Vue {
}
/**
* Handle card click - emit project selection
* Handle card click - emit if not conflicted, show warning if conflicted
*/
handleClick(): void {
this.emitProjectSelected(this.project);
if (!this.conflicted) {
this.emitProjectSelected(this.project);
} else if (this.notify) {
// Show warning notification for conflicted entity
this.notify(
{
group: "alert",
type: "warning",
title: "Cannot Select",
text: `You cannot select "${this.displayName}" because it is already selected as the ${this.conflictContext}.`,
},
3000,
);
}
}
// Emit methods using @Emit decorator

View File

@@ -0,0 +1,117 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
Select Representative
</h2>
<!-- EntityGrid for contacts -->
<EntityGrid
:entity-type="'people'"
:entities="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'representative'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { NotificationIface } from "../constants/app";
/**
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
*
* Features:
* - EntityGrid integration for contact selection
* - No special entities (You, Unnamed)
* - Immediate assignment on contact selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class ProjectRepresentativeDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Array of available contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected contact and closes the dialog
*/
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
const contact = event.data as Contact;
this.emitAssign(contact);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(contact: Contact): Contact {
return contact;
}
}
</script>
<style scoped></style>

View File

@@ -47,6 +47,16 @@ export const DEFAULT_PARTNER_API_SERVER =
export const DEFAULT_PUSH_SERVER =
import.meta.env.VITE_DEFAULT_PUSH_SERVER || AppString.PROD_PUSH_SERVER;
/**
* WebAuthn server endpoint URL
* Defaults to localhost:3002 for development, or can be set via VITE_WEBAUTHN_SERVER_URL
*/
export const DEFAULT_WEBAUTHN_SERVER =
import.meta.env.VITE_WEBAUTHN_SERVER_URL ||
(import.meta.env.DEV || window.location.hostname === "localhost"
? "http://localhost:3002"
: window.location.origin);
export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED =

29
src/constants/contacts.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Constants for contact-related functionality
* Created: 2025-11-16
*/
/**
* Contact method types with user-friendly labels
* Used in: ContactEditView.vue, DIDView.vue
*/
export const CONTACT_METHOD_TYPES = [
{ value: "CELL", label: "Mobile" },
{ value: "EMAIL", label: "Email" },
{ value: "WHATSAPP", label: "WhatsApp" },
] as const;
/**
* Type for contact method type values
*/
export type ContactMethodType = (typeof CONTACT_METHOD_TYPES)[number]["value"];
/**
* Helper function to get label for a contact method type
* @param type - The contact method type value (e.g., "CELL", "EMAIL")
* @returns The user-friendly label or the original type if not found
*/
export function getContactMethodLabel(type: string): string {
const methodType = CONTACT_METHOD_TYPES.find((m) => m.value === type);
return methodType ? methodType.label : type;
}

View File

@@ -94,7 +94,7 @@ const MIGRATIONS = [
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
did TEXT NOT NULL UNIQUE, -- UNIQUE constraint ensures no duplicate DIDs
identityEncrBase64 TEXT, -- encrypted & base64-encoded
mnemonicEncrBase64 TEXT, -- encrypted & base64-encoded
passkeyCredIdHex TEXT,

View File

@@ -31,18 +31,7 @@ export async function updateDidSpecificSettings(
const platform = PlatformServiceFactory.getInstance();
// First, let's see what's currently in the database
const checkResult = await platform.dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[accountDid],
);
// Get the current values for comparison
const currentRecord = checkResult?.values?.length
? mapColumnsToValues(checkResult.columns, checkResult.values)[0]
: null;
// First try to update existing record
// Generate and execute the update statement
const { sql: updateSql, params: updateParams } = generateUpdateStatement(
settingsChanges,
"settings",
@@ -50,66 +39,13 @@ export async function updateDidSpecificSettings(
[accountDid],
);
await platform.dbExec(updateSql, updateParams);
// **WORKAROUND**: AbsurdSQL doesn't return changes count correctly
// Instead, check if the record was actually updated
const postUpdateResult = await platform.dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[accountDid],
);
const updatedRecord = postUpdateResult?.values?.length
? mapColumnsToValues(postUpdateResult.columns, postUpdateResult.values)[0]
: null;
// Note that we want to eliminate this check (and fix the above if it doesn't work).
// Check if any of the target fields were actually changed
let actuallyUpdated = false;
if (currentRecord && updatedRecord) {
for (const key of Object.keys(settingsChanges)) {
if (key !== "accountDid" && currentRecord[key] !== updatedRecord[key]) {
actuallyUpdated = true;
}
}
}
// If the standard update didn't work, try a different approach
if (
!actuallyUpdated &&
settingsChanges.firstName &&
settingsChanges.isRegistered !== undefined
) {
// Update firstName
await platform.dbExec(
"UPDATE settings SET firstName = ? WHERE accountDid = ?",
[settingsChanges.firstName, accountDid],
);
// Update isRegistered
await platform.dbExec(
"UPDATE settings SET isRegistered = ? WHERE accountDid = ?",
[settingsChanges.isRegistered ? 1 : 0, accountDid],
);
// Check if the individual updates worked
const finalCheckResult = await platform.dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[accountDid],
);
const finalRecord = finalCheckResult?.values?.length
? mapColumnsToValues(finalCheckResult.columns, finalCheckResult.values)[0]
: null;
if (finalRecord) {
actuallyUpdated =
finalRecord.firstName === settingsChanges.firstName &&
finalRecord.isRegistered === (settingsChanges.isRegistered ? 1 : 0);
}
}
return actuallyUpdated;
// dbExec() now returns reliable changes count across all platforms
// (normalized using SQLite's changes() function in Capacitor/Electron,
// and reliable from AbsurdSQL in web platform)
const result = await platform.dbExec(updateSql, updateParams);
// Return true if any rows were affected
return result.changes > 0;
}
const DEFAULT_SETTINGS: Settings = {

View File

@@ -0,0 +1,73 @@
/**
* Platform Diagnostics Interface
*
* Provides comprehensive diagnostic information about the current platform,
* database backend, worker status, and build information.
*
* @author Matthew Raymer
*/
import { PlatformCapabilities } from "@/services/PlatformService";
/**
* Database backend information
*/
export interface DatabaseDiagnostics {
/** Type of database backend in use */
kind: "absurd-sql" | "capacitor-sqlite" | "electron-sqlite" | "unknown";
/** SharedArrayBuffer availability status (web platform only) */
sharedArrayBuffer?: "available" | "fallback" | "unknown";
/** Worker thread status (web platform only) */
worker?: {
/** Whether the worker is ready to process messages */
ready: boolean;
/** Number of pending messages */
pending: number;
/** Time since last ping in milliseconds */
lastPingMs?: number;
};
/** Operation queue status (Capacitor/Electron platforms) */
queue?: {
/** Current queue length */
current: number;
/** Peak queue size reached */
maxReached: number;
/** Maximum queue size limit */
limit: number;
/** Whether queue is currently processing */
isProcessing: boolean;
};
/** Database initialization status */
initialized: boolean;
}
/**
* Build information
*/
export interface BuildDiagnostics {
/** Application version from package.json */
version?: string;
/** Git commit hash */
commit?: string;
/** Build mode (development, test, production) */
mode?: string;
/** Build timestamp */
timestamp?: string;
}
/**
* Complete platform diagnostics
*/
export interface PlatformDiagnostics {
/** Detected platform */
platform: "web" | "capacitor" | "electron" | "development" | string;
/** Platform capabilities */
capabilities: PlatformCapabilities;
/** Database diagnostics */
db: DatabaseDiagnostics;
/** Build information */
build: BuildDiagnostics;
/** Additional platform-specific diagnostics */
metadata?: Record<string, unknown>;
}

View File

@@ -57,7 +57,12 @@ export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string;
}
// a summary record; the VC is not currently part of this record
/**
* A summary record
* The VC is not currently part of this record.
*
* If you change this, you may want to update NewActivityView.vue to handle differences correctly.
*/
export interface PlanSummaryRecord {
agentDid?: string;
description: string;
@@ -76,7 +81,9 @@ export interface PlanSummaryRecord {
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
// This can be undefined, eg. if a project is starred after the stored last-seen-change-jwt ID.
// The endorser-ch test code shows some cases.
wrappedClaimBefore?: GenericCredWrapper<PlanActionClaim>;
}
/**

View File

@@ -0,0 +1,219 @@
/**
* Client-side WebAuthn Passkey Functions
*
* This module provides client-side WebAuthn operations using @simplewebauthn/browser.
* All verification is performed server-side via API endpoints.
*
* @author Matthew Raymer
*/
import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import type {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
PublicKeyCredentialJSON,
} from "@simplewebauthn/types";
import { AppString } from "../../../constants/app";
import { logger } from "../../../utils/logger";
/**
* WebAuthn server endpoint configuration
*/
const getWebAuthnServerUrl = (): string => {
// Check for custom endpoint in settings/env
const customUrl = import.meta.env.VITE_WEBAUTHN_SERVER_URL;
if (customUrl) {
return customUrl;
}
// Default to localhost:3002 for development (matches server default port)
// In production, this should point to your WebAuthn verification service
if (import.meta.env.DEV || window.location.hostname === "localhost") {
return "http://localhost:3002";
}
// Production: use same origin or configured endpoint
return window.location.origin;
};
/**
* Registration result from server verification
*/
export interface RegistrationVerificationResult {
verified: boolean;
credential: {
credentialID: string;
credentialPublicKey: Uint8Array;
counter: number;
};
}
/**
* Authentication result from server verification
*/
export interface AuthenticationVerificationResult {
verified: boolean;
counter?: number;
}
/**
* Register a new passkey credential
*
* Flow:
* 1. Request registration options from server
* 2. Start registration with browser API
* 3. Send attestation response to server for verification
* 4. Return verified credential info
*
* @param passkeyName - Optional name for the passkey
* @param userId - Optional user ID (if not provided, server generates)
* @returns Verified registration result with credential info
*/
export async function registerPasskey(
passkeyName?: string,
userId?: string
): Promise<RegistrationVerificationResult> {
const serverUrl = getWebAuthnServerUrl();
try {
// Step 1: Request registration options from server
const optionsResponse = await fetch(`${serverUrl}/webauthn/registration/options`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: passkeyName || AppString.APP_NAME + " User",
userId: userId,
}),
});
if (!optionsResponse.ok) {
throw new Error(
`Failed to get registration options: ${optionsResponse.statusText}`
);
}
const options: PublicKeyCredentialCreationOptionsJSON =
await optionsResponse.json();
// Step 2: Start registration with browser API
const attestationResponse = await startRegistration(options);
// Step 3: Send attestation response to server for verification
const verifyResponse = await fetch(`${serverUrl}/webauthn/registration/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
options: options,
attestationResponse: attestationResponse,
}),
});
if (!verifyResponse.ok) {
throw new Error(
`Registration verification failed: ${verifyResponse.statusText}`
);
}
const verification: RegistrationVerificationResult =
await verifyResponse.json();
if (!verification.verified) {
throw new Error("Registration verification failed on server");
}
logger.debug("[passkeyDidPeer.client] Registration successful", {
credentialID: verification.credential.credentialID,
});
return verification;
} catch (error) {
logger.error("[passkeyDidPeer.client] Registration failed:", error);
throw error;
}
}
/**
* Authenticate with an existing passkey credential
*
* Flow:
* 1. Request authentication options from server
* 2. Start authentication with browser API
* 3. Send assertion response to server for verification
* 4. Return verification result
*
* @param credentialId - Base64URL encoded credential ID
* @param userId - Optional user ID (if not provided, server looks up by credential)
* @returns Verification result
*/
export async function authenticatePasskey(
credentialId: string,
userId?: string
): Promise<AuthenticationVerificationResult> {
const serverUrl = getWebAuthnServerUrl();
try {
// Step 1: Request authentication options from server
const optionsResponse = await fetch(`${serverUrl}/webauthn/authentication/options`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
credentialId: credentialId,
userId: userId,
}),
});
if (!optionsResponse.ok) {
throw new Error(
`Failed to get authentication options: ${optionsResponse.statusText}`
);
}
const options: PublicKeyCredentialRequestOptionsJSON =
await optionsResponse.json();
// Step 2: Start authentication with browser API
const assertionResponse = await startAuthentication(options);
// Step 3: Send assertion response to server for verification
const verifyResponse = await fetch(`${serverUrl}/webauthn/authentication/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
options: options,
assertionResponse: assertionResponse,
}),
});
if (!verifyResponse.ok) {
throw new Error(
`Authentication verification failed: ${verifyResponse.statusText}`
);
}
const verification: AuthenticationVerificationResult =
await verifyResponse.json();
if (!verification.verified) {
throw new Error("Authentication verification failed on server");
}
logger.debug("[passkeyDidPeer.client] Authentication successful");
return verification;
} catch (error) {
logger.error("[passkeyDidPeer.client] Authentication failed:", error);
throw error;
}
}

View File

@@ -0,0 +1,123 @@
/**
* Offline WebAuthn Verification Module
*
* This module contains server-side WebAuthn verification functions that are
* only available when VITE_OFFLINE_WEBAUTHN_VERIFY is enabled.
*
* SECURITY WARNING: Client-side verification can be tampered with and should
* not be trusted for security-critical operations. This module is intended
* for offline-only mode where server-side verification is not available.
*
* For production deployments, verification should be performed on a server.
*/
import type {
Base64URLString,
VerifyAuthenticationResponseOpts,
} from "@simplewebauthn/types";
import { logger } from "../../../utils/logger";
/**
* Dynamically import server-side verification functions
* This prevents bundling @simplewebauthn/server in normal builds
*/
async function getServerVerification() {
// Check if offline verification is enabled
const offlineVerifyEnabled =
import.meta.env.VITE_OFFLINE_WEBAUTHN_VERIFY === "true";
if (!offlineVerifyEnabled) {
throw new Error(
"Offline WebAuthn verification is disabled. " +
"Set VITE_OFFLINE_WEBAUTHN_VERIFY=true to enable offline mode. " +
"For production, use server-side verification instead."
);
}
try {
// Dynamic import prevents bundling in normal builds
const serverModule = await import("@simplewebauthn/server");
return {
verifyRegistrationResponse: serverModule.verifyRegistrationResponse,
verifyAuthenticationResponse: serverModule.verifyAuthenticationResponse,
generateRegistrationOptions: serverModule.generateRegistrationOptions,
generateAuthenticationOptions: serverModule.generateAuthenticationOptions,
};
} catch (error) {
logger.error(
"[passkeyDidPeer.offlineVerify] Failed to load server verification module:",
error
);
throw new Error(
"Server-side WebAuthn verification module is not available. " +
"This feature requires VITE_OFFLINE_WEBAUTHN_VERIFY=true and @simplewebauthn/server to be installed."
);
}
}
/**
* Verify registration response (offline mode only)
*
* @throws Error if offline verification is not enabled
*/
export async function verifyRegistrationResponseOffline(
response: unknown,
expectedChallenge: string,
expectedOrigin: string,
expectedRPID: string
) {
const { verifyRegistrationResponse } = await getServerVerification();
return verifyRegistrationResponse({
response: response as any,
expectedChallenge,
expectedOrigin,
expectedRPID,
});
}
/**
* Verify authentication response (offline mode only)
*
* @throws Error if offline verification is not enabled
*/
export async function verifyAuthenticationResponseOffline(
opts: VerifyAuthenticationResponseOpts
) {
const { verifyAuthenticationResponse } = await getServerVerification();
return verifyAuthenticationResponse(opts);
}
/**
* Generate registration options (offline mode only)
*
* @throws Error if offline verification is not enabled
*/
export async function generateRegistrationOptionsOffline(opts: {
rpName: string;
rpID: string;
userName: string;
attestationType?: string;
authenticatorSelection?: {
residentKey?: string;
userVerification?: string;
authenticatorAttachment?: string;
};
}) {
const { generateRegistrationOptions } = await getServerVerification();
return generateRegistrationOptions(opts);
}
/**
* Generate authentication options (offline mode only)
*
* @throws Error if offline verification is not enabled
*/
export async function generateAuthenticationOptionsOffline(opts: {
challenge: Uint8Array;
rpID: string;
allowCredentials?: Array<{ id: string }>;
}) {
const { generateAuthenticationOptions } = await getServerVerification();
return generateAuthenticationOptions(opts);
}

View File

@@ -5,18 +5,21 @@ import {
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
VerifyAuthenticationResponseOpts,
} from "@simplewebauthn/server";
import {
/**
* Client-side WebAuthn Passkey Functions
*
* This module provides client-side WebAuthn operations using @simplewebauthn/browser.
* Server-side verification is isolated in passkeyDidPeer.offlineVerify.ts and only
* available when VITE_OFFLINE_WEBAUTHN_VERIFY=true.
*
* For production deployments, verification should be performed on a server endpoint.
*/
import type {
Base64URLString,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
AuthenticatorAssertionResponse,
VerifyAuthenticationResponseOpts,
} from "@simplewebauthn/types";
import { AppString } from "../../../constants/app";
@@ -36,32 +39,103 @@ export interface JWK {
y: string;
}
/**
* Register a new passkey credential
*
* This is a facade that routes to either:
* - Client module (server-side verification) - default/production
* - Offline verification module (client-side) - only if VITE_OFFLINE_WEBAUTHN_VERIFY=true
*
* @param passkeyName - Optional name for the passkey
* @returns Registration result with credential info
*/
export async function registerCredential(passkeyName?: string) {
const options: PublicKeyCredentialCreationOptionsJSON =
await generateRegistrationOptions({
const offlineVerifyEnabled =
import.meta.env.VITE_OFFLINE_WEBAUTHN_VERIFY === "true";
if (offlineVerifyEnabled) {
// Offline mode: use dynamic import for client-side verification
const {
generateRegistrationOptionsOffline,
verifyRegistrationResponseOffline,
} = await import("./passkeyDidPeer.offlineVerify");
const options = await generateRegistrationOptionsOffline({
rpName: AppString.APP_NAME,
rpID: window.location.hostname,
userName: passkeyName || AppString.APP_NAME + " User",
// Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX)
attestationType: "none",
authenticatorSelection: {
// Defaults
residentKey: "preferred",
userVerification: "preferred",
// Optional
authenticatorAttachment: "platform",
},
});
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
// with pubKeyCredParams: { type: "public-key", alg: -7 }
const attResp = await startRegistration(options);
const verification = await verifyRegistrationResponse({
response: attResp,
expectedChallenge: options.challenge,
expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname,
});
const attResp = await startRegistration(options);
const verification = await verifyRegistrationResponseOffline(
attResp,
options.challenge,
window.location.origin,
window.location.hostname
);
return extractCredentialInfo(attResp, verification);
} else {
// Production mode: use client module with server-side verification
const { registerPasskey } = await import("./passkeyDidPeer.client");
const verification = await registerPasskey(passkeyName);
// Convert server response to expected format
const credIdBase64Url = verification.credential.credentialID;
const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url),
).toString("hex");
const { publicKeyJwk } = cborToKeys(
verification.credential.credentialPublicKey
);
return {
authData: undefined, // Not available from server response
credIdHex: credIdHex,
publicKeyJwk: publicKeyJwk,
publicKeyBytes: verification.credential.credentialPublicKey,
};
}
}
/**
* Extract credential info from attestation response and verification result
* Used by offline mode
*/
function extractCredentialInfo(
attResp: any,
verification: any
): {
authData: unknown;
credIdHex: string;
publicKeyJwk: JWK;
publicKeyBytes: Uint8Array;
} {
const credIdBase64Url = verification.registrationInfo?.credentialID as string;
if (attResp.rawId !== credIdBase64Url) {
logger.warn("Warning! The raw ID does not match the credential ID.");
}
const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url),
).toString("hex");
const { publicKeyJwk } = cborToKeys(
verification.registrationInfo?.credentialPublicKey as Uint8Array,
);
return {
authData: verification.registrationInfo?.attestationObject,
credIdHex: credIdHex,
publicKeyJwk: publicKeyJwk,
publicKeyBytes: verification.registrationInfo
?.credentialPublicKey as Uint8Array,
};
}
// references for parsing auth data and getting the public key
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
@@ -113,12 +187,32 @@ export class PeerSetup {
};
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)));
// const payloadHash: Uint8Array = sha256(this.challenge);
const options: PublicKeyCredentialRequestOptionsJSON =
await generateAuthenticationOptions({
// Use offline verification if enabled
const offlineVerifyEnabled =
import.meta.env.VITE_OFFLINE_WEBAUTHN_VERIFY === "true";
let options: PublicKeyCredentialRequestOptionsJSON;
if (offlineVerifyEnabled) {
const { generateAuthenticationOptionsOffline } = await import(
"./passkeyDidPeer.offlineVerify"
);
options = await generateAuthenticationOptionsOffline({
challenge: this.challenge,
rpID: window.location.hostname,
allowCredentials: [{ id: credentialId }],
});
} else {
// Production mode: should use server endpoint
// For now, fall back to direct navigator.credentials.get
// TODO: Implement server endpoint for authentication options
options = {
challenge: arrayBufferToBase64URLString(this.challenge.buffer),
rpId: window.location.hostname,
allowCredentials: [{ id: credentialId, type: "public-key" }],
userVerification: "preferred",
} as PublicKeyCredentialRequestOptionsJSON;
}
// console.log("simple authentication options", options);
const clientAuth = await startAuthentication(options);
@@ -345,6 +439,22 @@ export async function verifyJwtSimplewebauthn(
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
// Only allow offline verification if explicitly enabled
const offlineVerifyEnabled =
import.meta.env.VITE_OFFLINE_WEBAUTHN_VERIFY === "true";
if (!offlineVerifyEnabled) {
throw new Error(
"Client-side WebAuthn verification is disabled for security. " +
"Please use server-side verification endpoint or enable offline mode " +
"with VITE_OFFLINE_WEBAUTHN_VERIFY=true (not recommended for production)."
);
}
const { verifyAuthenticationResponseOffline } = await import(
"./passkeyDidPeer.offlineVerify"
);
const authData = arrayToBase64Url(Buffer.from(authenticatorData));
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
const credId = arrayBufferToBase64URLString(
@@ -372,7 +482,7 @@ export async function verifyJwtSimplewebauthn(
type: "public-key",
},
};
const verification = await verifyAuthenticationResponse(authOpts);
const verification = await verifyAuthenticationResponseOffline(authOpts);
return verification.verified;
}

View File

@@ -43,6 +43,7 @@ import {
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -101,6 +102,9 @@ import {
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
// Brand icons
import { faWhatsapp } from "@fortawesome/free-brands-svg-icons";
// Initialize Font Awesome library with all required icons
library.add(
faArrowDown,
@@ -140,6 +144,7 @@ library.add(
faDownload,
faEllipsis,
faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText,
faEraser,
faEye,
@@ -193,6 +198,7 @@ library.add(
faTriangleExclamation,
faUser,
faUsers,
faWhatsapp,
faXmark,
);

View File

@@ -472,7 +472,7 @@ export function offerGiverDid(
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
}
return giver;
return undefined;
}
/**

View File

@@ -30,11 +30,15 @@
import { initializeApp } from "./main.common";
import { App as CapacitorApp } from "@capacitor/app";
import { Capacitor } from "@capacitor/core";
import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logger, safeStringify } from "./utils/logger";
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
import { SHARED_PHOTO_BASE64_KEY } from "./libs/util";
import { SharedImage } from "./plugins/SharedImagePlugin";
import "./utils/safeAreaInset";
logger.log("[Capacitor] 🚀 Starting initialization");
@@ -51,6 +55,55 @@ window.addEventListener("unhandledrejection", (event) => {
const deepLinkHandler = new DeepLinkHandler(router);
// Lock to prevent duplicate processing of shared images
let isProcessingSharedImage = false;
/**
* Stores shared image data in temp database
* Handles clearing old data, converting base64 to data URL, and storing
*
* @param base64 - Raw base64 string of the image
* @param fileName - Optional filename for logging
* @returns Promise<void>
*/
async function storeSharedImageInTempDB(
base64: string,
fileName?: string,
): Promise<void> {
const platformService = PlatformServiceFactory.getInstance();
// Clear old image from temp DB first to ensure we get the new one
try {
await platformService.dbExec("DELETE FROM temp WHERE id = ?", [
SHARED_PHOTO_BASE64_KEY,
]);
logger.debug("[Main] Cleared old shared image from temp DB");
} catch (clearError) {
logger.debug(
"[Main] No old image to clear (or error clearing):",
clearError,
);
}
// Convert raw base64 to data URL format that base64ToBlob expects
// base64ToBlob expects format: "data:image/jpeg;base64,/9j/4AAQ..."
// Try to detect image type from base64 or default to jpeg
let mimeType = "image/jpeg"; // default
if (base64.startsWith("/9j/") || base64.startsWith("iVBORw0KGgo")) {
// JPEG or PNG
mimeType = base64.startsWith("/9j/") ? "image/jpeg" : "image/png";
}
const dataUrl = `data:${mimeType};base64,${base64}`;
// Use INSERT OR REPLACE to handle existing records
await platformService.dbExec(
"INSERT OR REPLACE INTO temp (id, blobB64) VALUES (?, ?)",
[SHARED_PHOTO_BASE64_KEY, dataUrl],
);
logger.info(`[Main] Stored shared image: ${fileName || "unknown"}`);
}
/**
* Handles deep link routing for the application
* Processes URLs in the format timesafari://<route>/<param>
@@ -67,11 +120,142 @@ const deepLinkHandler = new DeepLinkHandler(router);
*
* @throws {Error} If URL format is invalid
*/
/**
* Check for native shared image using SharedImage plugin
* Reads from native layer (App Group UserDefaults on iOS, SharedPreferences on Android)
* and stores in temp database before routing to shared-photo view
*/
async function checkAndStoreNativeSharedImage(): Promise<{
success: boolean;
fileName?: string;
}> {
// Prevent duplicate processing
if (isProcessingSharedImage) {
logger.debug(
"[Main] ⏸️ Shared image processing already in progress, skipping",
);
return { success: false };
}
isProcessingSharedImage = true;
try {
if (
!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" &&
Capacitor.getPlatform() !== "android")
) {
isProcessingSharedImage = false;
return { success: false };
}
// Use SharedImage plugin to get shared image data directly from native layer
// No file I/O or polling needed - direct native-to-JS communication
let result;
try {
result = await SharedImage.getSharedImage();
} catch (error) {
logger.error("[Main] Error calling SharedImage.getSharedImage():", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
isProcessingSharedImage = false;
return { success: false };
}
// Check if we have valid image data (base64 must be non-null and non-empty)
if (result && result.base64 && result.base64.trim().length > 0) {
const fileName = result.fileName || "shared-image.jpg";
// Store in temp database using extracted method
logger.info(
"[Main] Native shared image found (via plugin), storing in temp DB",
);
await storeSharedImageInTempDB(result.base64, fileName);
isProcessingSharedImage = false;
return { success: true, fileName };
}
// No shared image found
logger.debug("[Main] No shared image found via plugin");
isProcessingSharedImage = false;
return { success: false };
} catch (error) {
logger.error("[Main] Error checking for native shared image:", error);
isProcessingSharedImage = false;
return { success: false };
}
}
const handleDeepLink = async (data: { url: string }) => {
const { url } = data;
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
try {
// Handle empty path URLs from share extension (timesafari://)
// These are used to open the app, and we should check for shared images
const isEmptyPathUrl = url === "timesafari://" || url === "timesafari:///";
if (
isEmptyPathUrl &&
Capacitor.isNativePlatform() &&
Capacitor.getPlatform() === "ios"
) {
logger.debug(
"[Main] 📸 Empty path URL from share extension, checking for native shared image",
);
// Try to get shared image from App Group and store in temp database
// AppDelegate writes the file when the deep link is received, so we may need to retry
// The checkAndStoreNativeSharedImage function now uses polling internally, so we just call it once
try {
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Native shared image found, navigating to shared-photo",
);
// Wait for router to be ready
await router.isReady();
// Navigate directly to shared-photo route
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
await router.push({
path: "/shared-photo",
query: { fileName },
});
}
logger.info(
`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`,
);
return; // Exit early, don't process as deep link
} else {
logger.debug(
"[Main] No native shared image found, ignoring empty path URL",
);
return; // Exit early, don't process empty path as deep link
}
} catch (error) {
logger.error("[Main] Error processing native shared image:", error);
// If check fails, don't process as deep link (empty path would fail validation anyway)
return;
}
}
// Wait for router to be ready
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
await router.isReady();
@@ -159,10 +343,96 @@ const registerDeepLinkListener = async () => {
}
};
/**
* Check for shared image and navigate to shared-photo route if found
* This is called when app becomes active (from share extension or app launch)
*/
async function checkForSharedImageAndNavigate() {
if (
!Capacitor.isNativePlatform() ||
(Capacitor.getPlatform() !== "ios" && Capacitor.getPlatform() !== "android")
) {
return;
}
try {
logger.debug("[Main] 🔍 Checking for shared image on app activation");
const imageResult = await checkAndStoreNativeSharedImage();
if (imageResult.success) {
logger.info(
"[Main] ✅ Shared image found, navigating to shared-photo route",
);
// Wait for router to be ready
await router.isReady();
// Navigate to shared-photo route with fileName if available
// Use replace if already on shared-photo to force refresh, otherwise push
const fileName = imageResult.fileName || "shared-image.jpg";
const isAlreadyOnSharedPhoto =
router.currentRoute.value.path === "/shared-photo";
if (isAlreadyOnSharedPhoto) {
// Force refresh by replacing the route
await router.replace({
path: "/shared-photo",
query: { fileName, _refresh: Date.now().toString() }, // Add timestamp to force update
});
} else {
const route = imageResult.fileName
? `/shared-photo?fileName=${encodeURIComponent(imageResult.fileName)}`
: "/shared-photo";
await router.push(route);
}
logger.info(`[Main] ✅ Navigated to /shared-photo?fileName=${fileName}`);
} else {
logger.debug("[Main] No shared image found");
}
} catch (error) {
logger.error("[Main] ❌ Error checking for shared image:", error);
}
}
logger.log("[Capacitor] 🚀 Mounting app");
app.mount("#app");
logger.info(`[Main] ✅ App mounted successfully`);
// Check for shared image on initial load (in case app was launched from share sheet)
// On Android, share intents are processed in MainActivity.onCreate, so we need to check
// after a delay to ensure the native code has finished processing
if (
Capacitor.isNativePlatform() &&
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
) {
// Use multiple checks with increasing delays to handle timing issues
// Android share intent processing happens in onCreate, which may complete after JS loads
const checkDelays =
Capacitor.getPlatform() === "android"
? [500, 1500, 3000] // Android needs more time for share intent processing
: [1000]; // iOS is faster
checkDelays.forEach((delay) => {
setTimeout(async () => {
await checkForSharedImageAndNavigate();
}, delay);
});
}
// Listen for app state changes to detect when app becomes active
if (
Capacitor.isNativePlatform() &&
(Capacitor.getPlatform() === "ios" || Capacitor.getPlatform() === "android")
) {
CapacitorApp.addListener("appStateChange", async ({ isActive }) => {
if (isActive) {
logger.debug("[Main] 📱 App became active, checking for shared image");
await checkForSharedImageAndNavigate();
}
});
}
// Register deeplink listener after app is mounted
setTimeout(async () => {
try {

View File

@@ -3,7 +3,8 @@ import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
// PWA service worker is automatically registered by VitePWA plugin
// Note: PWA functionality is currently not implemented.
// Service worker registration would be handled here when PWA support is added.
const app = initializeApp();

View File

@@ -0,0 +1,15 @@
/**
* SharedImage Capacitor Plugin
* Provides access to shared image data from native Share Extension/Intent
*/
import { registerPlugin } from "@capacitor/core";
import type { SharedImagePlugin } from "./definitions";
const SharedImage = registerPlugin<SharedImagePlugin>("SharedImage", {
web: () =>
import("./SharedImagePlugin.web").then((m) => new m.SharedImagePluginWeb()),
});
export * from "./definitions";
export { SharedImage };

View File

@@ -0,0 +1,21 @@
/**
* Web implementation of SharedImagePlugin
* Returns null/false for web platform (no native sharing support)
*/
import { WebPlugin } from "@capacitor/core";
import type { SharedImagePlugin, SharedImageResult } from "./definitions";
export class SharedImagePluginWeb
extends WebPlugin
implements SharedImagePlugin
{
async getSharedImage(): Promise<SharedImageResult | null> {
// Web platform doesn't support native sharing
return null;
}
async hasSharedImage(): Promise<{ hasImage: boolean }> {
return { hasImage: false };
}
}

View File

@@ -0,0 +1,23 @@
/**
* Type definitions for SharedImage plugin
*/
export interface SharedImageResult {
base64: string;
fileName: string;
}
export interface SharedImagePlugin {
/**
* Get shared image data from native layer
* Returns base64 string and fileName, or null if no image exists
* Clears the data after reading to prevent re-reading
*/
getSharedImage(): Promise<SharedImageResult | null>;
/**
* Check if shared image exists without reading it
* Useful for quick checks before calling getSharedImage()
*/
hasSharedImage(): Promise<{ hasImage: boolean }>;
}

View File

@@ -5,31 +5,21 @@ if (typeof window === "undefined") {
globalThis.window = globalThis;
// Enhanced crypto polyfill
// SECURITY: Never use Math.random() for cryptographic operations
// If crypto is missing, fail fast rather than silently using insecure randomness
if (typeof crypto === "undefined") {
globalThis.crypto = {
getRandomValues: (array) => {
// Simple fallback for worker context
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
},
subtle: {
generateKey: async () => ({ type: "secret" }),
sign: async () => new Uint8Array(32),
verify: async () => true,
digest: async () => new Uint8Array(32),
},
};
throw new Error(
"[SQLWorker] crypto API is not available in worker context. " +
"This is required for secure database operations. " +
"Please ensure the worker is running in a secure context with crypto support."
);
} else if (!crypto.getRandomValues) {
// Crypto exists but doesn't have getRandomValues - extend it
crypto.getRandomValues = (array) => {
// Simple fallback for worker context
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
};
// Crypto exists but doesn't have getRandomValues - fail fast
throw new Error(
"[SQLWorker] crypto.getRandomValues is not available. " +
"This is required for secure database operations. " +
"Please ensure the worker environment supports the Web Crypto API."
);
}
}

View File

@@ -280,6 +280,17 @@ const routes: Array<RouteRecordRaw> = [
name: "test",
component: () => import("../views/TestView.vue"),
},
{
path: "/debug/diagnostics",
name: "debug-diagnostics",
component: () => import("../views/debug/PlatformDiagnosticsView.vue"),
meta: {
title: "Platform Diagnostics",
requiresAuth: false,
// Only show in dev mode or if explicitly enabled in settings
devOnly: true,
},
},
{
path: "/user-profile/:id?",
name: "user-profile",

View File

@@ -1,17 +1,13 @@
// **WORKER-COMPATIBLE CRYPTO POLYFILL**: Must be at the very top
// This prevents "crypto is not defined" errors when running in worker context
// **SECURITY**: Crypto API is required for secure database operations
// This service runs in a worker context where crypto should be available via Web Crypto API
// If crypto is missing, fail fast rather than silently using insecure Math.random()
// This matches the fail-fast approach in registerSQLWorker.js
if (typeof window === "undefined" && typeof crypto === "undefined") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).crypto = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getRandomValues: (array: any) => {
// Simple fallback for worker context
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256);
}
return array;
},
};
throw new Error(
"[AbsurdSqlDatabaseService] crypto API is not available. " +
"This is required for secure database operations. " +
"Please ensure the worker is running in a secure context with crypto support."
);
}
import initSqlJs from "@jlongster/sql.js";

View File

@@ -0,0 +1,169 @@
/**
* Diagnostic Export Service
*
* Provides functionality to export comprehensive diagnostic information
* including platform diagnostics, settings, logs, and build information.
*
* @author Matthew Raymer
*/
import { PlatformServiceFactory } from "./PlatformServiceFactory";
import { retrieveSettingsForActiveAccount } from "@/db/databaseUtil";
import type { PlatformDiagnostics } from "@/interfaces/diagnostics";
import { logger, getMemoryLogs } from "@/utils/logger";
/**
* Redacts sensitive information from diagnostic data
*/
function redactSensitive(data: unknown): unknown {
if (typeof data !== "object" || data === null) {
return data;
}
if (Array.isArray(data)) {
return data.map(redactSensitive);
}
const redacted: Record<string, unknown> = {};
const sensitiveKeys = [
"privateKey",
"privateKeyHex",
"mnemonic",
"secret",
"password",
"token",
"apiKey",
"identityEncrBase64",
"mnemonicEncrBase64",
];
for (const [key, value] of Object.entries(data)) {
if (sensitiveKeys.some((sk) => key.toLowerCase().includes(sk.toLowerCase()))) {
redacted[key] = "[REDACTED]";
} else if (typeof value === "object" && value !== null) {
redacted[key] = redactSensitive(value);
} else {
redacted[key] = value;
}
}
return redacted;
}
/**
* Exports comprehensive diagnostic bundle
*
* @returns Promise resolving to diagnostic bundle as JSON string
*/
export async function exportDiagnostics(): Promise<string> {
const platform = PlatformServiceFactory.getInstance();
const timestamp = new Date().toISOString();
// Collect diagnostics
const diagnostics: PlatformDiagnostics | null = platform.getDiagnostics
? await platform.getDiagnostics()
: null;
// Collect settings
let settingsDefault = null;
let settingsActive = null;
try {
// Note: retrieveSettingsForDefaultAccount might not exist, handle gracefully
settingsActive = await retrieveSettingsForActiveAccount();
} catch (error) {
logger.debug("[DiagnosticExport] Could not retrieve settings:", error);
}
// Collect recent logs from memory
let memoryLogs: string[] = [];
try {
memoryLogs = getMemoryLogs(1000); // Get last 1000 log entries
} catch (error) {
logger.debug("[DiagnosticExport] Could not retrieve memory logs:", error);
}
// Collect recent logs from database (if logs table exists)
let dbLogs: unknown[] = [];
try {
const logsResult = await platform.dbQuery(
"SELECT * FROM logs ORDER BY date DESC LIMIT 100"
);
if (logsResult?.values) {
dbLogs = logsResult.values.map((row) => {
const logEntry: Record<string, unknown> = {};
if (logsResult.columns && row) {
logsResult.columns.forEach((col, idx) => {
logEntry[col] = row[idx];
});
}
return logEntry;
});
}
} catch (error) {
logger.debug("[DiagnosticExport] Could not retrieve DB logs:", error);
}
// Get build info
let packageJson: { version?: string } = {};
try {
packageJson = await import("../../../package.json");
} catch (error) {
logger.debug("[DiagnosticExport] Could not load package.json:", error);
}
// Get git commit hash if available
let commitHash: string | undefined;
try {
// This would need to be set at build time via Vite define
commitHash = import.meta.env.VITE_GIT_HASH;
} catch (error) {
// Ignore
}
// Assemble diagnostic bundle
const bundle = {
timestamp,
version: "1.0",
diagnostics: diagnostics ? redactSensitive(diagnostics) : null,
settings: {
active: redactSensitive(settingsActive),
default: redactSensitive(settingsDefault),
},
logs: {
memory: redactSensitive(memoryLogs),
database: redactSensitive(dbLogs),
},
build: {
version: packageJson.version,
commit: commitHash,
mode: import.meta.env.MODE,
platform: import.meta.env.VITE_PLATFORM,
},
};
return JSON.stringify(bundle, null, 2);
}
/**
* Exports diagnostic bundle to file
*
* @param fileName - Optional custom filename (default: diagnostics-{timestamp}.json)
* @returns Promise that resolves when file is exported
*/
export async function exportDiagnosticsToFile(
fileName?: string
): Promise<void> {
const platform = PlatformServiceFactory.getInstance();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const defaultFileName = fileName || `diagnostics-${timestamp}.json`;
try {
const bundle = await exportDiagnostics();
await platform.writeAndShareFile(defaultFileName, bundle);
logger.log(`[DiagnosticExport] Diagnostic bundle exported: ${defaultFileName}`);
} catch (error) {
logger.error("[DiagnosticExport] Failed to export diagnostics:", error);
throw error;
}
}

View File

@@ -215,7 +215,10 @@ export interface PlatformService {
*/
registerServiceWorker?(): void;
// --- Diagnostics (optional, for debugging) ---
/**
* Returns true if PWA is enabled (web only)
* Gets comprehensive diagnostic information about the platform
* @returns Promise resolving to platform diagnostics
*/
getDiagnostics?(): Promise<import("@/interfaces/diagnostics").PlatformDiagnostics>;
}

View File

@@ -39,7 +39,9 @@ export class PlatformServiceFactory {
}
// Only log when actually creating the instance
const platform = process.env.VITE_PLATFORM || "web";
// Use import.meta.env for Vite environment variables (standard Vite pattern)
// process.env.VITE_PLATFORM is defined via Vite's define config, but import.meta.env is preferred
const platform = (import.meta.env?.VITE_PLATFORM || process.env.VITE_PLATFORM || "web") as string;
if (!PlatformServiceFactory.creationLogged) {
// Use console for critical startup message to avoid circular dependency

View File

@@ -0,0 +1,155 @@
/**
* Database Result Normalizer
*
* Provides shared logic to normalize database execution results across
* platforms, ensuring reliable changes count and last insert row ID.
*
* This addresses platform-specific inconsistencies where plugins may not
* return reliable change counts. The normalizer queries SQLite's connection
* state directly when plugin-provided values are missing or unreliable.
*
* @author Matthew Raymer
*/
import { logger } from "@/utils/logger";
/**
* Result from a database run operation
*/
interface RunResult {
changes?: {
changes?: number;
lastId?: number;
};
lastId?: number;
changes?: number;
}
/**
* Normalized database execution result
*/
export interface NormalizedRunResult {
changes: number;
lastId?: number;
}
/**
* Query function type for fallback queries
* Must use the same database connection to ensure changes() is accurate
*/
type QueryFunction = (
sql: string,
params?: unknown[]
) => Promise<{
values?: Array<Record<string, unknown>>;
columns?: string[];
}>;
/**
* Normalizes a database run result to ensure reliable changes count
*
* Strategy:
* 1. Prefer plugin-provided values if present and numeric
* 2. Fall back to querying SQLite connection state (changes(), last_insert_rowid())
* 3. Return normalized result with guaranteed numeric changes count
*
* @param runResult - Raw result from database plugin
* @param queryFn - Optional query function for fallback (must use same connection)
* @returns Promise resolving to normalized result with reliable changes count
*/
export async function normalizeRunResult(
runResult: RunResult,
queryFn?: QueryFunction
): Promise<NormalizedRunResult> {
let changes = 0;
let lastId: number | undefined;
// Extract plugin-provided values (handle different plugin response shapes)
if (runResult.changes?.changes !== undefined) {
changes = Number(runResult.changes.changes) || 0;
lastId = runResult.changes.lastId
? Number(runResult.changes.lastId)
: undefined;
} else if (runResult.changes !== undefined) {
changes = Number(runResult.changes) || 0;
}
if (runResult.lastId !== undefined && !lastId) {
lastId = Number(runResult.lastId);
}
// If we have a query function and changes is 0 (or missing), query SQLite directly
// This ensures correctness even if plugin doesn't return reliable counts
if (queryFn && (changes === 0 || runResult.changes === undefined)) {
try {
// Query SQLite's changes() function for the actual number of rows affected
// This must use the same connection to get accurate results
const changesResult = await queryFn("SELECT changes() AS changes");
if (
changesResult.values &&
changesResult.values.length > 0 &&
changesResult.values[0]
) {
const changesValue = Object.values(changesResult.values[0])[0];
if (typeof changesValue === "number") {
changes = changesValue;
}
}
// Query last_insert_rowid() for INSERT statements
const lastIdResult = await queryFn("SELECT last_insert_rowid() AS lastId");
if (
lastIdResult.values &&
lastIdResult.values.length > 0 &&
lastIdResult.values[0]
) {
const lastIdValue = Object.values(lastIdResult.values[0])[0];
if (typeof lastIdValue === "number" && lastIdValue > 0) {
lastId = lastIdValue;
}
}
} catch (error) {
// If querying SQLite state fails, log but don't fail the operation
// Fall back to plugin-provided values (which may be 0)
logger.debug(
"[dbResultNormalizer] Failed to query SQLite state, using plugin values:",
error
);
}
}
return {
changes: Math.max(0, changes), // Ensure non-negative
lastId: lastId && lastId > 0 ? lastId : undefined,
};
}
/**
* Synchronous version that uses provided values only
* Use this when query function is not available or not needed
*/
export function normalizeRunResultSync(
runResult: RunResult
): NormalizedRunResult {
let changes = 0;
let lastId: number | undefined;
if (runResult.changes?.changes !== undefined) {
changes = Number(runResult.changes.changes) || 0;
lastId = runResult.changes.lastId
? Number(runResult.changes.lastId)
: undefined;
} else if (runResult.changes !== undefined) {
changes = Number(runResult.changes) || 0;
}
if (runResult.lastId !== undefined && !lastId) {
lastId = Number(runResult.lastId);
}
return {
changes: Math.max(0, changes),
lastId: lastId && lastId > 0 ? lastId : undefined,
};
}

View File

@@ -23,6 +23,8 @@ import {
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService";
import type { PlatformDiagnostics } from "@/interfaces/diagnostics";
import { normalizeRunResult } from "../dbResultNormalizer";
interface QueuedOperation {
type: "run" | "query" | "rawQuery";
@@ -54,6 +56,8 @@ export class CapacitorPlatformService
private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false;
private readonly MAX_QUEUE_SIZE = 1000; // Maximum queue size before failing
private maxQueueSizeReached = 0; // Track peak queue size for telemetry
constructor() {
super();
@@ -217,14 +221,34 @@ export class CapacitorPlatformService
let result: unknown;
switch (operation.type) {
case "run": {
// Execute the statement
const runResult = await this.db.run(
operation.sql,
operation.params,
);
result = {
changes: runResult.changes?.changes || 0,
lastId: runResult.changes?.lastId,
};
// Normalize using shared normalizer with query fallback
// The query function uses the same connection to ensure changes() is accurate
const normalized = await normalizeRunResult(
runResult,
async (sql: string, params?: unknown[]) => {
const queryResult = await this.db.query(sql, params || []);
return {
values: queryResult.values?.map((row) => {
const obj: Record<string, unknown> = {};
if (queryResult.columns) {
queryResult.columns.forEach((col, idx) => {
obj[col] = row[idx];
});
}
return obj;
}),
columns: queryResult.columns,
};
}
);
result = normalized;
break;
}
case "query": {
@@ -371,6 +395,31 @@ export class CapacitorPlatformService
});
return new Promise<R>((resolve, reject) => {
// Queue size guard: prevent memory exhaustion from unbounded queue growth
if (this.operationQueue.length >= this.MAX_QUEUE_SIZE) {
const error = new Error(
`Database operation queue is full (${this.MAX_QUEUE_SIZE} operations). ` +
`This usually indicates the database is not initializing properly or operations are being queued too quickly.`
);
logger.error(
`[CapacitorPlatformService] Queue size limit reached: ${this.operationQueue.length}/${this.MAX_QUEUE_SIZE}`,
);
reject(error);
return;
}
// Track peak queue size for telemetry
if (this.operationQueue.length > this.maxQueueSizeReached) {
this.maxQueueSizeReached = this.operationQueue.length;
}
// Log warning if queue is getting large (but not at limit yet)
if (this.operationQueue.length > this.MAX_QUEUE_SIZE * 0.8) {
logger.warn(
`[CapacitorPlatformService] Queue size is high: ${this.operationQueue.length}/${this.MAX_QUEUE_SIZE}`,
);
}
// Create completely plain objects that Vue cannot make reactive
// Step 1: Deep clone the converted params to ensure they're plain objects
const plainParams = JSON.parse(JSON.stringify(convertedParams));
@@ -865,6 +914,27 @@ export class CapacitorPlatformService
};
}
/**
* Gets telemetry information about the database operation queue.
* Useful for debugging and monitoring queue health.
* @returns Queue telemetry data
*/
getQueueTelemetry(): {
currentSize: number;
maxSize: number;
peakSize: number;
isProcessing: boolean;
initialized: boolean;
} {
return {
currentSize: this.operationQueue.length,
maxSize: this.MAX_QUEUE_SIZE,
peakSize: this.maxQueueSizeReached,
isProcessing: this.isProcessingQueue,
initialized: this.initialized,
};
}
/**
* Checks and requests storage permissions if needed
* @returns Promise that resolves when permissions are granted
@@ -1409,6 +1479,38 @@ export class CapacitorPlatformService
// --- PWA/Web-only methods (no-op for Capacitor) ---
public registerServiceWorker(): void {}
/**
* Gets comprehensive diagnostic information about the Capacitor platform
* @returns Promise resolving to platform diagnostics
*/
async getDiagnostics(): Promise<PlatformDiagnostics> {
const platform = Capacitor.getPlatform();
const queueTelemetry = this.getQueueTelemetry();
return {
platform: "capacitor",
capabilities: this.getCapabilities(),
db: {
kind: "capacitor-sqlite",
queue: {
current: queueTelemetry.currentSize,
maxReached: queueTelemetry.peakSize,
limit: queueTelemetry.maxSize,
isProcessing: queueTelemetry.isProcessing,
},
initialized: this.initialized,
},
build: {
appVersion: import.meta.env.VITE_APP_VERSION,
mode: import.meta.env.MODE,
gitHash: import.meta.env.VITE_GIT_HASH,
},
metadata: {
nativePlatform: platform,
},
};
}
// Database utility methods - inherited from BaseDatabaseService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,

View File

@@ -22,6 +22,7 @@
import { CapacitorPlatformService } from "./CapacitorPlatformService";
import { logger } from "../../utils/logger";
import type { PlatformDiagnostics } from "@/interfaces/diagnostics";
/**
* Electron-specific platform service implementation.
@@ -164,6 +165,24 @@ export class ElectronPlatformService extends CapacitorPlatformService {
return false;
}
/**
* Gets comprehensive diagnostic information about the Electron platform
* @returns Promise resolving to platform diagnostics
*/
async getDiagnostics(): Promise<PlatformDiagnostics> {
const baseDiagnostics = await super.getDiagnostics();
return {
...baseDiagnostics,
platform: "electron",
capabilities: this.getCapabilities(),
metadata: {
...baseDiagnostics.metadata,
electronVersion: process.versions?.electron,
nodeVersion: process.versions?.node,
},
};
}
// --- PWA/Web-only methods (no-op for Electron) ---
public registerServiceWorker(): void {}
}

View File

@@ -6,6 +6,7 @@ import {
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
import { BaseDatabaseService } from "./BaseDatabaseService";
import type { PlatformDiagnostics } from "@/interfaces/diagnostics";
// Dynamic import of initBackend to prevent worker context errors
import type {
WorkerRequest,
@@ -673,6 +674,42 @@ export class WebPlatformService
// SharedArrayBuffer initialization is handled by initBackend call in initializeWorker
}
/**
* Gets comprehensive diagnostic information about the web platform
* @returns Promise resolving to platform diagnostics
*/
async getDiagnostics(): Promise<PlatformDiagnostics> {
const platform = (import.meta.env?.VITE_PLATFORM || process.env.VITE_PLATFORM || "web") as string;
const sabAvailable = typeof SharedArrayBuffer !== "undefined";
// Get version from build-time env var if available
let version: string | undefined;
try {
version = import.meta.env.VITE_APP_VERSION;
} catch {
// Ignore
}
return {
platform,
capabilities: this.getCapabilities(),
db: {
kind: "absurd-sql",
sharedArrayBuffer: sabAvailable ? "available" : "fallback",
worker: {
ready: this.workerReady,
pending: this.pendingMessages.size,
},
initialized: this.workerReady,
},
build: {
appVersion: version,
mode: import.meta.env.MODE,
gitHash: import.meta.env.VITE_GIT_HASH,
},
};
}
// Database utility methods - inherited from BaseDatabaseService
// generateInsertStatement, updateDefaultSettings, updateActiveDid,
// getActiveIdentity, insertNewDidIntoSettings, updateDidSpecificSettings,

View File

@@ -1367,6 +1367,9 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined
? contact.profileImageUrl
: null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
contactMethods:
contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods)
@@ -1377,8 +1380,8 @@ export const PlatformServiceMixin = {
await this.$dbExec(
`INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
safeContact.did,
safeContact.name,
@@ -1387,6 +1390,8 @@ export const PlatformServiceMixin = {
safeContact.registered,
safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.contactMethods,
],
);

View File

@@ -375,45 +375,6 @@
Switch Identifier
</router-link>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="ml-4 mt-2">
<input type="file" class="ml-2" @change="uploadImportFile" />
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-4">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</div>
</transition>
</div>
</div>
<label
for="toggleShowAmounts"
class="flex items-center justify-between cursor-pointer my-4"
@@ -770,9 +731,7 @@ import "dexie-export-import";
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
@@ -799,7 +758,6 @@ import {
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox,
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
AccountSettings,
isApiError,
ImportContent,
} from "@/interfaces/accountView";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
// Profile data interface (inlined from ProfileService)
interface ProfileData {
description: string;
@@ -836,8 +790,6 @@ interface ProfileData {
includeLocation: boolean;
}
const inputImportFileNameRef = ref<Blob>();
interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void;
}
@@ -1369,65 +1321,6 @@ export default class AccountViewView extends Vue {
);
}
async uploadImportFile(event: Event): Promise<void> {
inputImportFileNameRef.value = (
event.target as HTMLInputElement
).files?.[0];
}
showContactImport(): boolean {
return !!inputImportFileNameRef.value;
}
confirmSubmitImportFile(): void {
if (inputImportFileNameRef.value != null) {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
this.submitImportFile,
);
}
}
/**
* Asynchronously imports the database from a downloadable JSON file.
*
* @throws Will notify the user if there is an export error.
*/
async submitImportFile(): Promise<void> {
if (inputImportFileNameRef.value != null) {
// TODO: implement this for SQLite
}
}
async checkContactImports(): Promise<void> {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress): boolean {
logger.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,

View File

@@ -223,21 +223,30 @@
</div>
<div class="mt-8">
<button
v-if="libsUtil.canFulfillOffer(veriClaim, isRegistered)"
class="col-span-1 block w-fit text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="openFulfillGiftDialog()"
v-if="veriClaim.claimType === 'Offer'"
class="col-span-1 block w-fit text-center text-md px-1.5 py-2 rounded-md"
:class="
libsUtil.canFulfillOffer(veriClaim, isRegistered)
? 'bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white'
: 'bg-gray-300 text-gray-500 cursor-pointer opacity-50'
"
@click="handleAffirmDeliveryClick()"
>
Affirm Delivery
<font-awesome
icon="hand-holding-heart"
class="ml-2 text-white cursor-pointer"
:class="
libsUtil.canFulfillOffer(veriClaim, isRegistered)
? 'ml-2 text-white cursor-pointer'
: 'ml-2 text-gray-500 cursor-pointer'
"
/>
</button>
</div>
<GiftedDialog
ref="customGiveDialog"
:giver-entity-type="'person'"
:recipient-entity-type="projectInfo ? 'project' : 'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="projectInfo ? 'project' : 'person'"
:to-project-id="
detailsForGive?.fulfillsPlanHandleId ||
detailsForOffer?.fulfillsPlanHandleId ||
@@ -1103,11 +1112,29 @@ export default class ClaimView extends Vue {
});
}
handleAffirmDeliveryClick() {
if (!this.isRegistered) {
this.notify.error("You must be registered to affirm delivery.");
return;
}
if (!libsUtil.canFulfillOffer(this.veriClaim, this.isRegistered)) {
this.notify.error(
"You cannot see all the information in this offer so you cannot affirm delivery.",
);
return;
}
this.openFulfillGiftDialog();
}
openFulfillGiftDialog() {
const giverDid = libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferClaim>,
);
// Look up the giver contact to get their name
const giverContact = serverUtil.contactForDid(giverDid, this.allContacts);
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferClaim>,
),
did: giverDid,
name: giverContact?.name || giverDid, // Use contact name if available, otherwise DID
};
// Determine recipient based on whether it's a project or person

View File

@@ -55,66 +55,70 @@
<!-- Contact Methods -->
<div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2>
<div
v-for="(method, index) in contactMethods"
:key="index"
class="flex mt-2"
>
<input
v-model="method.label"
type="text"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
v-model="method.type"
type="text"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
class="px-2 py-1 bg-gray-200 rounded-md"
@click="toggleDropdown(index)"
>
<font-awesome icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'CELL')"
<div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
<!-- Type and Value Row -->
<div class="flex gap-2">
<div class="flex-none w-32">
<label class="block text-xs font-medium text-gray-700 mb-1">
Type
</label>
<select
v-model="method.type"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
>
CELL
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'EMAIL')"
>
EMAIL
</div>
<div
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, 'WHATSAPP')"
>
WHATSAPP
</div>
<option value=""></option>
<option
v-for="methodType in contactMethodTypes"
:key="methodType.value"
:value="methodType.value"
>
{{ methodType.label }}
</option>
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 mb-1">
Value
</label>
<input
v-model="method.value"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
</div>
<button
class="self-end pb-0.5 text-red-500"
@click="removeContactMethod(index)"
>
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<!-- WhatsApp Help Text -->
<div
v-if="method.type === 'WHATSAPP'"
class="mt-1 ml-[calc(8rem+0.5rem)] text-xs text-gray-600 italic"
>
Must include country code and only numbers (e.g., 12225551234)
</div>
<!-- Label Row -->
<div class="mt-2 flex justify-end">
<div class="flex-1 ml-[calc(8rem+0.5rem)]">
<label class="block text-xs font-medium text-gray-700 mb-1">
</label>
<input
v-model="method.label"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label / Note"
/>
</div>
<div class="w-[2.5rem]"></div>
</div>
<input
v-model="method.value"
type="text"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button class="ml-2 text-red-500" @click="removeContactMethod(index)">
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<button class="mt-2" @click="addContactMethod">
<button class="mt-4" @click="addContactMethod">
<font-awesome
icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
@@ -157,6 +161,7 @@ import {
} from "../constants/notifications";
import { Contact, ContactMethod } from "../db/tables/contacts";
import { AppString } from "../constants/app";
import { CONTACT_METHOD_TYPES } from "../constants/contacts";
/**
* Contact Edit View Component
@@ -219,11 +224,11 @@ export default class ContactEditView extends Vue {
contactNotes = "";
/** Array of editable contact methods */
contactMethods: Array<ContactMethod> = [];
/** Currently open dropdown index, null if none open */
dropdownIndex: number | null = null;
/** App string constants */
AppString = AppString;
/** Contact method types for datalist suggestions */
contactMethodTypes = CONTACT_METHOD_TYPES;
/**
* Component lifecycle hook that initializes the contact edit form
@@ -280,29 +285,6 @@ export default class ContactEditView extends Vue {
this.contactMethods.splice(index, 1);
}
/**
* Toggles the type selection dropdown for a contact method
*
* If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one.
*
* @param index The array index of the method whose dropdown to toggle
*/
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
/**
* Sets the type for a contact method and closes the dropdown
*
* @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP)
*/
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
/**
* Saves the edited contact information to the database
*
@@ -338,9 +320,10 @@ export default class ContactEditView extends Vue {
}
// Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", {
name: this.contactName,
notes: this.contactNotes,
name: this.contactName?.trim() || null,
notes: this.contactNotes?.trim() || null,
contactMethods: contactMethods,
});

View File

@@ -105,11 +105,10 @@
<GiftedDialog
ref="giftedDialog"
:giver-entity-type="giverEntityType"
:recipient-entity-type="recipientEntityType"
:initial-giver-entity-type="giverEntityType"
:initial-recipient-entity-type="recipientEntityType"
:from-project-id="fromProjectId"
:to-project-id="toProjectId"
:is-from-project-view="isFromProjectView"
:hide-show-all="true"
/>
</section>
@@ -165,7 +164,6 @@ export default class ContactGiftingView extends Vue {
fromProjectId = "";
toProjectId = "";
showProjects = false;
isFromProjectView = false;
offerId = "";
async created() {
@@ -217,8 +215,6 @@ export default class ContactGiftingView extends Vue {
this.toProjectId = (this.$route.query["toProjectId"] as string) || "";
this.showProjects =
(this.$route.query["showProjects"] as string) === "true";
this.isFromProjectView =
(this.$route.query["isFromProjectView"] as string) === "true";
this.offerId = (this.$route.query["offerId"] as string) || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -120,8 +120,8 @@
<GiftedDialog
ref="customGivenDialog"
:giver-entity-type="'person'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'person'"
/>
<OfferDialog ref="customOfferDialog" />
<ContactNameDialog ref="contactNameDialog" />
@@ -1088,7 +1088,7 @@ export default class ContactsView extends Vue {
{
group: "modal",
type: "confirm",
title: "Delete",
title: "Confirm First?",
text: message,
onNo: async () => {
this.showGiftedDialog(giverDid, recipientDid);

View File

@@ -20,12 +20,12 @@
</button>
<!-- Help button -->
<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"
@click="goToHelp()"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</button>
</router-link>
</div>
<!-- Identity Details -->
@@ -42,6 +42,58 @@
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</router-link>
</h2>
<!-- Notes -->
<div v-if="contactFromDid.notes" class="mt-3">
<p class="text-sm text-slate-700 whitespace-pre-wrap">
{{ contactFromDid.notes }}
</p>
</div>
<!-- Contact Methods -->
<div v-if="contactFromDid.contactMethods?.length" class="mt-3">
<div class="flex flex-col gap-2">
<div
v-for="(method, index) in contactFromDid.contactMethods"
:key="index"
class="flex items-center gap-2 text-sm"
>
<span class="font-semibold text-slate-600"
>{{
getContactMethodLabel(method.type) || "(unspecified)"
}}:</span
>
<span class="text-slate-700">{{ method.label }}</span>
<span class="text-slate-600">{{ method.value }}</span>
<a
v-if="method.type === 'CELL'"
:href="`sms:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send text message"
>
<font-awesome icon="message" class="text-base" />
</a>
<a
v-if="method.type === 'EMAIL'"
:href="`mailto:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send email"
>
<font-awesome icon="envelope" class="text-base" />
</a>
<a
v-if="method.type === 'WHATSAPP'"
:href="`https://wa.me/${method.value.replace(/\D/g, '')}`"
target="_blank"
class="ml-2 text-blue-700"
title="Send WhatsApp message"
>
<font-awesome :icon="['fab', 'whatsapp']" class="text-base" />
</a>
</div>
</div>
</div>
<button class="ml-2 mr-2 mt-4" @click="toggleDidDetails">
Details
<font-awesome
@@ -302,6 +354,7 @@ import {
NOTIFY_CONTACT_INVALID_DID,
} from "@/constants/notifications";
import { THAT_UNNAMED_PERSON } from "@/constants/entities";
import { getContactMethodLabel } from "@/constants/contacts";
/**
* DIDView Component
@@ -352,6 +405,7 @@ export default class DIDView extends Vue {
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
didInfoForContact = didInfoForContact;
displayAmount = displayAmount;
getContactMethodLabel = getContactMethodLabel;
/**
* Initializes notification helpers

View File

@@ -465,6 +465,8 @@ export default class DiscoverView extends Vue {
async mounted() {
this.searchTerms = this.$route.query["searchText"]?.toString() || "";
const hideOnboarding =
this.$route.query["hideOnboarding"]?.toString() === "true";
const searchPeople = !!this.$route.query["searchPeople"];
@@ -483,7 +485,7 @@ export default class DiscoverView extends Vue {
this.allMyDids = await retrieveAccountDids();
if (!settings.finishedOnboarding) {
if (!settings.finishedOnboarding && !hideOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Discover,
);

View File

@@ -111,7 +111,34 @@ Raymer * @version 1.0.0 */
<!-- Record Quick-Action -->
<div class="mb-6">
<div class="flex gap-2 items-center mb-2">
<h2 class="font-bold">Record something given by:</h2>
<!-- Thank button - always visible and unchanged -->
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white flex items-center justify-center gap-2 px-4 py-3 rounded-full"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
<span>Thank</span>
</button>
<!-- Plus button - appears when scrolled, positioned over house-chimney icon -->
<transition
enter-active-class="transition-all duration-1000 ease-out"
leave-active-class="transition-all duration-1000 ease-in"
enter-from-class="scale-0"
enter-to-class="scale-100"
leave-from-class="scale-100"
leave-to-class="scale-0"
>
<button
v-if="isScrolled"
type="button"
class="fixed bottom-10 p-4 w-14 h-14 z-50 text-center bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-full flex items-center justify-center"
:style="getButtonPosition()"
@click="openPersonDialog()"
>
<font-awesome icon="plus" />
</button>
</transition>
<button
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="openGiftedPrompts()"
@@ -122,25 +149,6 @@ Raymer * @version 1.0.0 */
/>
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openPersonDialog()"
>
<font-awesome icon="user" />
Person
</button>
<button
type="button"
class="text-center text-base uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-lg"
@click="openProjectDialog()"
>
<font-awesome icon="folder-open" />
Project
</button>
</div>
</div>
</div>
</div>
@@ -148,8 +156,8 @@ Raymer * @version 1.0.0 */
<GiftedDialog
ref="giftedDialog"
:giver-entity-type="showProjectsDialog ? 'project' : 'person'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'person'"
/>
<GiftedPrompts ref="giftedPrompts" />
<FeedFilters ref="feedFilters" />
@@ -446,7 +454,8 @@ export default class HomeView extends Vue {
userAgentInfo = new UAParser(); // see https://docs.uaparser.js.org/v2/api/ua-parser-js/get-os.html
selectedImage = "";
isImageViewerOpen = false;
showProjectsDialog = false;
isScrolled = false;
scrollHandler?: () => void;
/**
* CRITICAL VUE REACTIVITY BUG WORKAROUND
@@ -547,11 +556,44 @@ export default class HomeView extends Vue {
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
},
});
// Add scroll listener for button collapse
// Note: Scrolling happens on #app element, not window (see tailwind.css)
const appElement = document.getElementById("app");
const scrollElement = appElement || window;
this.scrollHandler = () => {
const scrollTop = appElement
? appElement.scrollTop
: window.pageYOffset || document.documentElement.scrollTop || 0;
const shouldBeScrolled = scrollTop > 100;
if (this.isScrolled !== shouldBeScrolled) {
this.isScrolled = shouldBeScrolled;
}
};
// Set initial state
this.scrollHandler();
// Listen on scroll element (prefer #app, fallback to window)
scrollElement.addEventListener("scroll", this.scrollHandler, {
passive: true,
});
} catch (err: unknown) {
this.handleError(err);
}
}
/**
* Cleanup scroll listener on component unmount
*/
beforeUnmount() {
if (this.scrollHandler) {
const appElement = document.getElementById("app");
const scrollElement = appElement || window;
scrollElement.removeEventListener("scroll", this.scrollHandler);
}
}
/**
* Watch for changes in the current activeDid
* Reload settings when user switches identities
@@ -898,7 +940,13 @@ export default class HomeView extends Vue {
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
// filter out any data elements where there is no wrappedClaimBefore
const filteredNewStarredProjectChanges =
starredProjectChanges.data.filter(
(change) => change.wrappedClaimBefore !== undefined,
);
this.numNewStarredProjectChanges =
filteredNewStarredProjectChanges.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
// Don't show errors for starred project changes as it's a secondary feature
@@ -1805,17 +1853,19 @@ export default class HomeView extends Vue {
* - this.activeDid
*
* @param giver Optional contact info for giver
* @param description Optional gift description
* @param prompt Optional gift prompt
*/
openDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
// Determine the giver entity based on DID logic
const giverEntity = this.createGiverEntity(giver);
// In HomeView, "You" is the default recipient but it's not locked
// User can still change it in Step 1 if they want
(this.$refs.giftedDialog as GiftedDialog).open(
giverEntity,
{
did: this.activeDid,
name: "You", // In HomeView, we always use "You" as the giver
name: "You",
} as GiverReceiverInputInfo,
undefined,
prompt,
@@ -1913,15 +1963,9 @@ export default class HomeView extends Vue {
}
openPersonDialog(giver?: GiverReceiverInputInfo, prompt?: string) {
this.showProjectsDialog = false;
this.openDialog(giver, prompt);
}
openProjectDialog() {
this.showProjectsDialog = true;
(this.$refs.giftedDialog as GiftedDialog).open();
}
/**
* Computed property for registration status
*
@@ -1931,5 +1975,39 @@ export default class HomeView extends Vue {
get isUserRegistered() {
return this.isRegistered;
}
/**
* Calculates the horizontal position for the button to align with house button center
*/
getButtonPosition() {
const quickNav = document.getElementById("QuickNav");
if (!quickNav) {
return { left: "1.5rem" }; // Fallback to left-6
}
const navList = quickNav.querySelector("ul");
if (!navList) {
return { left: "1.5rem" };
}
// Get the first nav item (house button)
const firstItem = navList.querySelector("li:first-child");
if (!firstItem) {
return { left: "1.5rem" };
}
const itemRect = firstItem.getBoundingClientRect();
const buttonWidth = 56; // w-14 = 3.5rem = 56px
// Calculate center of house button
const houseButtonCenter = itemRect.left + itemRect.width / 2;
// Position button so its center aligns with house button center
const buttonLeft = houseButtonCenter - buttonWidth / 2;
return {
left: `${buttonLeft}px`,
};
}
}
</script>

View File

@@ -284,7 +284,10 @@
</table>
</div>
</div>
<div v-else>The changes did not affect essential project data.</div>
<div v-else>
The changes are not important, like it was saved by accident or
you've seen it all before.
</div>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@@ -589,13 +592,13 @@ export default class NewActivityView extends Vue {
for (const planChange of planChanges) {
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
const wrappedClaim: GenericCredWrapper<PlanActionClaim> | undefined =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
let previousClaim: PlanActionClaim | undefined;
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
const embeddedClaim: PlanActionClaim | undefined = wrappedClaim?.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
@@ -609,7 +612,9 @@ export default class NewActivityView extends Vue {
previousClaim = embeddedClaim;
}
if (!previousClaim || !currentPlan.handleId) {
if (!previousClaim) {
// Can happen when a project is starred after the stored last-seen-change-jwt ID
// so we'll just leave the message saying there are no important differences.
continue;
}

View File

@@ -60,12 +60,60 @@
</div>
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
<input
v-model="agentDid"
type="text"
placeholder="Other Authorized Representative"
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
<!-- Authorized Representative 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="openRepresentativeDialog"
>
<div>
<EntityIcon
v-if="selectedRepresentative"
:contact="selectedRepresentative"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome v-else icon="user" class="text-slate-400" />
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedRepresentative,
'text-slate-400': !selectedRepresentative,
}"
class="truncate"
>
{{
selectedRepresentative
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
: "Assign Authorized Representative…"
}}
</div>
<div
v-if="selectedRepresentative"
class="text-xs text-slate-500 truncate"
>
{{ agentDid }}
</div>
</div>
</div>
<button
v-if="selectedRepresentative"
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="unsetRepresentative"
>
<font-awesome icon="trash-can" />
</button>
</div>
<ProjectRepresentativeDialog
ref="representativeDialog"
:all-contacts="allContacts"
:active-did="activeDid"
:all-my-dids="allMyDids"
:notify="$notify"
@assign="handleRepresentativeAssigned"
/>
<div class="mb-4">
<p v-if="shouldShowOwnershipWarning">
<span class="text-red-500">Beware!</span>
@@ -232,9 +280,12 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { LeafletMouseEvent } from "leaflet";
import EntityIcon from "../components/EntityIcon.vue";
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import {
AppString,
DEFAULT_IMAGE_API_SERVER,
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
@@ -268,6 +319,7 @@ import {
retrieveAccountCount,
retrieveFullyDecryptedAccount,
} from "../libs/util";
import { Contact } from "../db/tables/contacts";
import {
EventTemplate,
@@ -323,7 +375,15 @@ import { logger } from "../utils/logger";
*/
@Component({
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
components: {
EntityIcon,
ImageMethodDialog,
ProjectRepresentativeDialog,
LMap,
LMarker,
LTileLayer,
QuickNav,
},
mixins: [PlatformServiceMixin],
})
export default class NewEditProjectView extends Vue {
@@ -334,6 +394,9 @@ export default class NewEditProjectView extends Vue {
// Notification helpers
private notify!: ReturnType<typeof createNotifyHelpers>;
// Constants
AppString = AppString;
/**
* Display error notification to user
* Provides consistent error messaging with 5-second timeout
@@ -346,6 +409,8 @@ export default class NewEditProjectView extends Vue {
// Component state properties
activeDid = "";
agentDid = "";
allContacts: Array<Contact> = [];
allMyDids: string[] = [];
apiServer = "";
endDateInput?: string;
endTimeInput?: string;
@@ -392,16 +457,24 @@ export default class NewEditProjectView extends Vue {
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
// Get all user's DIDs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.projectId = (this.$route.query["projectId"] as string) || "";
if (this.projectId) {
if (this.isSavedProject()) {
if (this.numAccounts === 0) {
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
} else {
this.loadProject(this.activeDid);
this.loadProject(this.activeDid, this.projectId);
}
}
}
@@ -411,11 +484,9 @@ export default class NewEditProjectView extends Vue {
* Retrieves project information from the API and populates form fields
* @param userDid - User's decentralized identifier
*/
async loadProject(userDid: string) {
async loadProject(userDid: string, projectId: string) {
const url =
this.apiServer +
"/api/claim/byHandle/" +
encodeURIComponent(this.projectId);
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
const headers = await getHeaders(userDid);
try {
@@ -432,6 +503,12 @@ export default class NewEditProjectView extends Vue {
}
if (this.fullClaim?.agent?.identifier) {
this.agentDid = this.fullClaim.agent.identifier;
if (this.activeDid !== this.projectIssuerDid) {
this.agentDid = this.projectIssuerDid;
this.notify.warning(
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
);
}
}
if (this.fullClaim.startTime) {
const localDateTime = DateTime.fromISO(
@@ -536,7 +613,7 @@ export default class NewEditProjectView extends Vue {
private async saveProject() {
// Make a claim
const vcClaim: PlanActionClaim = this.fullClaim;
if (this.projectId) {
if (this.isSavedProject()) {
vcClaim.lastClaimId = this.lastClaimJwtId;
}
if (this.agentDid) {
@@ -870,6 +947,10 @@ export default class NewEditProjectView extends Vue {
this.longitude = event.latlng.lng;
}
private isSavedProject(): boolean {
return !!this.projectId;
}
/**
* Computed property for character count display
* Shows current description length and maximum character limit
@@ -885,6 +966,7 @@ export default class NewEditProjectView extends Vue {
*/
get shouldShowOwnershipWarning(): boolean {
return (
this.isSavedProject() &&
this.activeDid !== this.projectIssuerDid &&
this.agentDid !== this.projectIssuerDid
);
@@ -961,5 +1043,37 @@ export default class NewEditProjectView extends Vue {
get shouldShowSpinner(): boolean {
return !this.isHiddenSpinner;
}
/**
* Computed property for selected representative contact
* Derives the contact from agentDid by finding it in allContacts
*/
get selectedRepresentative(): Contact | null {
if (!this.agentDid) {
return null;
}
return this.allContacts.find((c) => c.did === this.agentDid) || null;
}
/**
* Open the representative selection dialog
*/
openRepresentativeDialog(): void {
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
}
/**
* Handle representative assignment from dialog
*/
handleRepresentativeAssigned(contact: Contact): void {
this.agentDid = contact.did;
}
/**
* Unset the representative and revert to initial state
*/
unsetRepresentative(): void {
this.agentDid = "";
}
}
</script>

View File

@@ -331,7 +331,7 @@ export default class OfferDetailsView extends Vue {
get recipientAssignmentLabel() {
return this.recipientDid
? `This is offered to ${this.recipientName}`
: "No recipient was chosen.";
: "No named individual recipient was chosen.";
}
/**

View File

@@ -186,16 +186,59 @@
<div>
<label
for="projectLink"
class="block text-sm font-medium text-gray-700"
class="block text-sm font-medium text-gray-700 mb-1"
>Project Link</label
>
<input
id="projectLink"
v-model="newOrUpdatedMeetingInputs.projectLink"
type="text"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
placeholder="Project ID"
/>
<div class="w-full flex items-stretch">
<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="openProjectLinkDialog"
>
<div>
<ProjectIcon
v-if="selectedProject"
:entity-id="selectedProject.handleId"
:icon-size="30"
:image-url="selectedProject.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="folder-open"
class="text-slate-400"
/>
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedProject,
'text-slate-400': !selectedProject,
}"
class="truncate"
>
{{
selectedProject
? selectedProject.name || "Unnamed Project"
: "Select Project…"
}}
</div>
<div
v-if="selectedProject"
class="text-xs text-slate-500 truncate"
>
<font-awesome icon="user" class="text-slate-400" />
{{ selectedProjectIssuerName }}
</div>
</div>
</div>
<button
v-if="selectedProject"
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="unsetProjectLink"
>
<font-awesome icon="trash-can" />
</button>
</div>
</div>
<button
@@ -224,6 +267,17 @@
</form>
</div>
<MeetingProjectDialog
ref="meetingProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleProjectLinkAssigned"
@open="handleDialogOpen"
@close="handleDialogClose"
/>
<!-- Members Section -->
<div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@@ -254,6 +308,7 @@
</ul>
<MembersList
ref="membersList"
:password="currentMeeting.password || ''"
:show-organizer-tools="true"
class="mt-4"
@@ -292,10 +347,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import {
errorStringForLog,
getHeaders,
serverMessageForUser,
didInfo,
} from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -309,6 +367,8 @@ import {
NOTIFY_MEETING_DELETED,
NOTIFY_MEETING_LINK_COPIED,
} from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
interface ServerMeeting {
groupId: number; // from the server
name: string; // to & from the server
@@ -331,6 +391,8 @@ interface MeetingSetupInputs {
QuickNav,
TopMessage,
MembersList,
MeetingProjectDialog,
ProjectIcon,
},
mixins: [PlatformServiceMixin],
})
@@ -354,6 +416,9 @@ export default class OnboardMeetingView extends Vue {
isRegistered = false;
showDeleteConfirm = false;
fullName = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
selectedProjectData: PlanData | null = null;
get minDateTime() {
const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@@ -370,7 +435,17 @@ export default class OnboardMeetingView extends Vue {
this.fullName = settings?.firstName || "";
this.isRegistered = !!settings?.isRegistered;
// Load contacts and DIDs for dialog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
await this.fetchCurrentMeeting();
// Ensure selected project is loaded if projectLink exists
await this.ensureSelectedProjectLoaded();
this.isLoading = false;
}
@@ -442,6 +517,54 @@ export default class OnboardMeetingView extends Vue {
}
}
/**
* Ensure the selected project is loaded if projectLink exists
*/
async ensureSelectedProjectLoaded(): Promise<void> {
const projectLink =
this.currentMeeting?.projectLink ||
this.newOrUpdatedMeetingInputs?.projectLink;
if (!projectLink) {
this.selectedProjectData = null;
return;
}
await this.fetchProjectByHandleId(projectLink);
}
/**
* Fetch a single project by handleId
* @param handleId - The project handleId to fetch
*/
async fetchProjectByHandleId(handleId: string): Promise<void> {
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
const project = resp.data.data[0];
this.selectedProjectData = {
name: project.name,
description: project.description,
image: project.image,
handleId: project.handleId,
issuerDid: project.issuerDid,
rowId: project.rowId,
};
} else {
this.selectedProjectData = null;
}
} catch (error) {
this.$logAndConsole(
"Error fetching project by handleId: " + errorStringForLog(error),
true,
);
this.selectedProjectData = null;
}
}
async createMeeting() {
this.isLoading = true;
@@ -576,7 +699,7 @@ export default class OnboardMeetingView extends Vue {
}
}
startEditing() {
async startEditing() {
// Populate form with existing meeting data
if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
@@ -587,6 +710,10 @@ export default class OnboardMeetingView extends Vue {
password: this.currentMeeting.password || "",
projectLink: this.currentMeeting.projectLink || "",
};
// Ensure selected project is loaded if projectLink exists
if (this.currentMeeting.projectLink) {
await this.ensureSelectedProjectLoaded();
}
} else {
this.$logError(
"There is no current meeting to edit. We should never get here.",
@@ -594,9 +721,15 @@ export default class OnboardMeetingView extends Vue {
}
}
cancelEditing() {
async cancelEditing() {
// Reset form data
this.newOrUpdatedMeetingInputs = null;
// Restore selected project from currentMeeting if it exists
if (this.currentMeeting?.projectLink) {
await this.ensureSelectedProjectLoaded();
} else {
this.selectedProjectData = null;
}
}
async updateMeeting() {
@@ -710,5 +843,78 @@ export default class OnboardMeetingView extends Vue {
this.notify.error("Failed to copy meeting link to clipboard.");
}
}
/**
* Computed property for selected project
* Returns the separately stored selected project data
*/
get selectedProject(): PlanData | null {
return this.selectedProjectData;
}
/**
* Computed property for selected project issuer display name
* Uses didInfo to format the issuer name similar to ProjectCard
*/
get selectedProjectIssuerName(): string {
if (!this.selectedProject) {
return "";
}
return didInfo(
this.selectedProject.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
}
/**
* Handle project assignment from dialog
*/
handleProjectLinkAssigned(project: PlanData): void {
// Store the selected project directly
this.selectedProjectData = project;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
}
}
/**
* Unset the project link and revert to initial state
*/
unsetProjectLink(): void {
this.selectedProjectData = null;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = "";
}
}
/**
* Handle dialog open event - stop auto-refresh in MembersList
*/
handleDialogOpen(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.stopAutoRefresh();
}
}
/**
* Handle dialog close event - start auto-refresh in MembersList
*/
handleDialogClose(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.startAutoRefresh();
}
}
}
</script>

View File

@@ -238,10 +238,9 @@
<GiftedDialog
ref="giveDialogToThis"
:giver-entity-type="'person'"
:recipient-entity-type="'project'"
:initial-giver-entity-type="'person'"
:initial-recipient-entity-type="'project'"
:to-project-id="projectId"
:is-from-project-view="true"
/>
<!-- Offers & Gifts to & from this -->
@@ -521,10 +520,9 @@
</div>
<GiftedDialog
ref="giveDialogFromThis"
:giver-entity-type="'project'"
:recipient-entity-type="'person'"
:initial-giver-entity-type="'project'"
:initial-recipient-entity-type="'person'"
:from-project-id="projectId"
:is-from-project-view="true"
/>
<h3 class="text-lg font-bold leading-tight mb-3">
@@ -1299,9 +1297,15 @@ export default class ProjectViewView extends Vue {
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giverDid = libsUtil.offerGiverDid(offerClaimCred);
// Look up the giver contact to get their name
const giverContact = serverUtil.contactForDid(giverDid, this.allContacts);
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerClaimCred),
did: giverDid,
name: giverContact?.name || giverDid, // Use contact name if available, otherwise DID
};
(this.$refs.giveDialogToThis as GiftedDialog).open(
giver,
{

View File

@@ -120,7 +120,7 @@
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { Component, Vue, Watch } from "vue-facing-decorator";
import {
RouteLocationNormalizedLoaded,
RouteLocationRaw,
@@ -191,7 +191,30 @@ export default class SharedPhotoView extends Vue {
*/
async mounted() {
this.notify = createNotifyHelpers(this.$notify);
await this.loadSharedImage();
}
/**
* Watches for route query changes to reload image when navigating
* to the same route with different query parameters (e.g., new fileName or _refresh)
* This handles both new navigations and refreshes when already on the route
*/
@Watch("$route.query", { deep: true })
async onRouteQueryChange() {
// Reload image when any query parameter changes (fileName or _refresh)
// This ensures the image refreshes when a new image is shared while already on this view
await this.loadSharedImage();
}
/**
* Loads the shared image from temporary storage
*
* Retrieves the shared image data from the temp database, converts it to a blob,
* and updates component state. Cleans up temporary storage after successful loading.
*
* @async
*/
private async loadSharedImage() {
try {
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -209,6 +232,9 @@ export default class SharedPhotoView extends Vue {
this.imageFileName = this.$route.query["fileName"] as string;
} else {
logger.error("No appropriate image found in temp storage.", temp);
// Clear image state if no temp data found
this.imageBlob = undefined;
this.imageFileName = undefined;
}
} catch (err: unknown) {
logger.error("Got an error loading an identifier:", err);
@@ -216,6 +242,9 @@ export default class SharedPhotoView extends Vue {
NOTIFY_SHARED_PHOTO_LOAD_ERROR.message,
TIMEOUTS.STANDARD,
);
// Clear image state on error
this.imageBlob = undefined;
this.imageFileName = undefined;
}
}

View File

@@ -57,6 +57,9 @@
<button :class="sqlLinkClasses" @click="setAccountsQuery">
Accounts
</button>
<button :class="sqlLinkClasses" @click="setActiveIdentityQuery">
Active DID
</button>
<button :class="sqlLinkClasses" @click="setContactsQuery">
Contacts
</button>
@@ -525,6 +528,11 @@ export default class Help extends Vue {
this.executeSql();
}
setActiveIdentityQuery() {
this.sqlQuery = "SELECT * FROM active_identity;";
this.executeSql();
}
setContactsQuery() {
this.sqlQuery = "SELECT * FROM contacts;";
this.executeSql();

View File

@@ -54,6 +54,109 @@
</p>
</div>
<!-- Nearest Neighbors Section -->
<div
v-if="
profile.issuerDid !== activeDid && // only show neighbors if they're not current user
profile.issuerDid !== neighbors?.[0]?.did // and they're not directly connected
// (which we know because there is no neighbor in-between them)
"
class="mt-6"
>
<h2 class="text-lg font-semibold mb-3">Network Connections</h2>
<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>
<div 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 profile 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="{ name: 'did', params: { did: neighbor.did } }"
class="text-blue-600 hover:text-blue-800 font-medium underline"
>
Go to contact info
</router-link>
and send them the link in your clipboard and ask for an
introduction to this person.
<div
v-if="
getNeighborDisplayName(neighbor.did) === '' ||
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="onCopyDidClick(neighbor.did)"
>
<font-awesome icon="copy" class="text-sm" />
</button>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map for first coordinates -->
<div v-if="hasFirstLocation" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2>
@@ -159,7 +262,11 @@ export default class UserProfileView extends Vue {
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
expandedNeighborDid: string | null = null;
isLoading = true;
loadingNeighbors = false;
neighbors: Array<{ did: string; relation: string }> = [];
neighborsError = "";
partnerApiServer = DEFAULT_PARTNER_API_SERVER;
profile: UserProfile | null = null;
@@ -183,8 +290,8 @@ export default class UserProfileView extends Vue {
*/
async mounted() {
await this.initializeSettings();
await this.loadContacts();
await this.loadProfile();
await this.loadNeighbors();
}
/**
@@ -199,12 +306,7 @@ export default class UserProfileView extends Vue {
this.activeDid = activeIdentity.activeDid || "";
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
}
/**
* Loads all contacts from database
*/
private async loadContacts() {
this.allContacts = await this.$getAllContacts();
this.allMyDids = await retrieveAccountDids();
}
@@ -249,23 +351,100 @@ export default class UserProfileView extends Vue {
}
/**
* Copies profile link to clipboard
* Loads nearest neighbors from partner API
*
* Creates a deep link to the profile and copies it to the clipboard
* Shows success notification when completed
* Fetches network connections for the profile and displays them
* with appropriate relation labels
*/
async loadNeighbors() {
const profileId: string = this.$route.params.id as string;
if (!profileId) {
return;
}
this.loadingNeighbors = true;
this.neighborsError = "";
try {
const response = await fetch(
`${this.partnerApiServer}/api/partner/userProfileNearestNeighbors/${encodeURIComponent(profileId)}`,
{
method: "GET",
headers: await getHeaders(this.activeDid),
},
);
if (response.status === 200) {
const result = await response.json();
this.neighbors = result.data;
this.neighborsError = "";
} else {
logger.warn("Failed to load neighbors:", response.status);
this.neighbors = [];
this.neighborsError = "Failed to load network connections.";
}
} catch (error) {
logger.error("Error loading neighbors:", error);
this.neighbors = [];
this.neighborsError =
"An error occurred while loading network connections.";
} finally {
this.loadingNeighbors = false;
}
}
/**
* Copies a deep link to the profile to the clipboard
*/
async onCopyLinkClick() {
// Use production URL for sharing to avoid localhost issues in development
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("profile link", TIMEOUTS.STANDARD);
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
}
/**
* Copies a deep link to the provided DID to the clipboard
*/
async onCopyDidClick(did: string) {
const deepLink = `${APP_SERVER}/deep-link/did/${encodeURIComponent(did)}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("DID link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying DID link: ${error}`, true);
this.notify.error("Failed to copy DID link.");
}
}
/**
* Handles clicking the expand button next to a neighbor's name
* Copies the profile link to clipboard and toggles the expanded section
*/
async onNeighborExpandClick(did: string) {
if (this.expandedNeighborDid === did) {
this.expandedNeighborDid = null;
// don't copy the link
return;
}
// Copy the profile link
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("Profile link", TIMEOUTS.STANDARD);
} catch (error) {
this.$logAndConsole(`Error copying profile link: ${error}`, true);
this.notify.error("Failed to copy profile link.");
}
// Toggle the expanded section
this.expandedNeighborDid = did;
}
/**
* Computed properties for template logic streamlining
*/
@@ -330,5 +509,64 @@ export default class UserProfileView extends Vue {
get tileLayerUrl() {
return "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png";
}
/**
* Gets display name for a neighbor's DID
* Uses didInfo utility to show contact name if available, otherwise DID
* @param did - The DID to get display name for
* @returns Formatted display name
*/
getNeighborDisplayName(did: string): string {
return this.didInfo(did, this.activeDid, this.allMyDids, this.allContacts);
}
neighborIsNotInContacts(did: string) {
return !this.allContacts.some((contact) => contact.did === did);
}
noNeighborsAreInContacts() {
return this.neighbors.every(
(neighbor) =>
!this.allContacts.some((contact) => contact.did === neighbor.did),
);
}
/**
* Gets human-readable label for relation type
* @param relation - The relation type from API
* @returns Display label for the relation
*/
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
* @param relation - The relation type from API
* @returns CSS class string for badge
*/
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`;
}
}
}
</script>

View File

@@ -0,0 +1,269 @@
<template>
<div class="platform-diagnostics-view p-6 max-w-6xl mx-auto">
<div class="mb-6">
<h1 class="text-3xl font-bold mb-2">Platform Diagnostics</h1>
<p class="text-gray-600">
Comprehensive diagnostic information about the current platform, database,
and build configuration.
</p>
</div>
<div class="mb-4 flex gap-4">
<button
@click="refreshDiagnostics"
:disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{{ loading ? "Loading..." : "Refresh" }}
</button>
<button
@click="exportDiagnostics"
:disabled="loading || !diagnostics"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
Export Diagnostics Bundle
</button>
</div>
<div v-if="error" class="mb-4 p-4 bg-red-100 border border-red-400 text-red-700 rounded">
{{ error }}
</div>
<div v-if="diagnostics" class="space-y-6">
<!-- Platform Info -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4">Platform Information</h2>
<dl class="grid grid-cols-2 gap-4">
<div>
<dt class="font-medium text-gray-700">Platform</dt>
<dd class="text-gray-900">{{ diagnostics.platform }}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Build Mode</dt>
<dd class="text-gray-900">{{ diagnostics.build.mode || "N/A" }}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">App Version</dt>
<dd class="text-gray-900">{{ diagnostics.build.appVersion || "N/A" }}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Git Hash</dt>
<dd class="text-gray-900 font-mono text-sm">
{{ diagnostics.build.gitHash || "N/A" }}
</dd>
</div>
</dl>
</div>
<!-- Capabilities -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4">Platform Capabilities</h2>
<dl class="grid grid-cols-2 gap-4">
<div v-for="(value, key) in diagnostics.capabilities" :key="key">
<dt class="font-medium text-gray-700">{{ formatKey(key) }}</dt>
<dd class="text-gray-900">
<span
:class="
value
? 'text-green-600 font-semibold'
: 'text-gray-400'
"
>
{{ value ? "✓ Yes" : "✗ No" }}
</span>
</dd>
</div>
</dl>
</div>
<!-- Database Info -->
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4">Database Information</h2>
<dl class="grid grid-cols-2 gap-4">
<div>
<dt class="font-medium text-gray-700">Backend Type</dt>
<dd class="text-gray-900">{{ diagnostics.db.kind }}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Initialized</dt>
<dd class="text-gray-900">
<span
:class="
diagnostics.db.initialized
? 'text-green-600 font-semibold'
: 'text-red-600 font-semibold'
"
>
{{ diagnostics.db.initialized ? "✓ Yes" : "✗ No" }}
</span>
</dd>
</div>
<div v-if="diagnostics.db.sharedArrayBuffer">
<dt class="font-medium text-gray-700">SharedArrayBuffer</dt>
<dd class="text-gray-900">
<span
:class="
diagnostics.db.sharedArrayBuffer === 'available'
? 'text-green-600 font-semibold'
: 'text-yellow-600 font-semibold'
"
>
{{ diagnostics.db.sharedArrayBuffer }}
</span>
</dd>
</div>
</dl>
<!-- Worker Status (Web Platform) -->
<div v-if="diagnostics.db.worker" class="mt-4 pt-4 border-t">
<h3 class="font-semibold mb-2">Worker Status</h3>
<dl class="grid grid-cols-2 gap-4">
<div>
<dt class="font-medium text-gray-700">Ready</dt>
<dd class="text-gray-900">
<span
:class="
diagnostics.db.worker.ready
? 'text-green-600 font-semibold'
: 'text-red-600 font-semibold'
"
>
{{ diagnostics.db.worker.ready ? "✓ Yes" : "✗ No" }}
</span>
</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Pending Messages</dt>
<dd class="text-gray-900">{{ diagnostics.db.worker.pending }}</dd>
</div>
<div v-if="diagnostics.db.worker.lastPingMs">
<dt class="font-medium text-gray-700">Last Ping</dt>
<dd class="text-gray-900">{{ diagnostics.db.worker.lastPingMs }}ms ago</dd>
</div>
</dl>
</div>
<!-- Queue Status (Capacitor/Electron) -->
<div v-if="diagnostics.db.queue" class="mt-4 pt-4 border-t">
<h3 class="font-semibold mb-2">Operation Queue</h3>
<dl class="grid grid-cols-2 gap-4">
<div>
<dt class="font-medium text-gray-700">Current Size</dt>
<dd class="text-gray-900">{{ diagnostics.db.queue.current }}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Limit</dt>
<dd class="text-gray-900">{{ diagnostics.db.queue.limit }}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Peak Reached</dt>
<dd class="text-gray-900">{{ diagnostics.db.queue.maxReached }}</dd>
</div>
<div>
<dt class="font-medium text-gray-700">Processing</dt>
<dd class="text-gray-900">
<span
:class="
diagnostics.db.queue.isProcessing
? 'text-green-600 font-semibold'
: 'text-gray-400'
"
>
{{ diagnostics.db.queue.isProcessing ? "✓ Yes" : "✗ No" }}
</span>
</dd>
</div>
</dl>
<div class="mt-2">
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all"
:style="{
width: `${Math.min(
(diagnostics.db.queue.current / diagnostics.db.queue.limit) * 100,
100
)}%`,
}"
></div>
</div>
<p class="text-xs text-gray-500 mt-1">
{{ Math.round((diagnostics.db.queue.current / diagnostics.db.queue.limit) * 100) }}%
capacity
</p>
</div>
</div>
</div>
<!-- Metadata -->
<div v-if="diagnostics.metadata && Object.keys(diagnostics.metadata).length > 0" class="bg-white rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-4">Additional Metadata</h2>
<pre class="bg-gray-100 p-4 rounded text-sm overflow-auto">{{ JSON.stringify(diagnostics.metadata, null, 2) }}</pre>
</div>
</div>
<div v-else-if="!loading" class="text-center py-12 text-gray-500">
No diagnostics available. Click "Refresh" to load.
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { exportDiagnosticsToFile } from "@/services/DiagnosticExportService";
import type { PlatformDiagnostics } from "@/interfaces/diagnostics";
import { logger } from "@/utils/logger";
const diagnostics = ref<PlatformDiagnostics | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const formatKey = (key: string): string => {
return key
.replace(/([A-Z])/g, " $1")
.replace(/^./, (str) => str.toUpperCase())
.trim();
};
const refreshDiagnostics = async () => {
loading.value = true;
error.value = null;
try {
const platform = PlatformServiceFactory.getInstance();
if (platform.getDiagnostics) {
diagnostics.value = await platform.getDiagnostics();
logger.debug("[PlatformDiagnosticsView] Diagnostics refreshed", diagnostics.value);
} else {
error.value = "Diagnostics not available on this platform";
}
} catch (err) {
error.value = `Failed to load diagnostics: ${err}`;
logger.error("[PlatformDiagnosticsView] Failed to refresh diagnostics:", err);
} finally {
loading.value = false;
}
};
const exportDiagnostics = async () => {
try {
await exportDiagnosticsToFile();
logger.log("[PlatformDiagnosticsView] Diagnostics exported successfully");
} catch (err) {
error.value = `Failed to export diagnostics: ${err}`;
logger.error("[PlatformDiagnosticsView] Failed to export diagnostics:", err);
}
};
onMounted(() => {
refreshDiagnostics();
});
</script>
<style scoped>
.platform-diagnostics-view {
min-height: 100vh;
background-color: #f5f5f5;
}
</style>

View File

@@ -282,9 +282,9 @@ test('Check User 0 can register a random person', async ({ page }) => {
} catch (error) {
console.log('Could not force close dialog, continuing...');
}
// Wait for Person button to be ready - simplified approach
await page.waitForSelector('button:has-text("Person")', { timeout: 10000 });
await page.getByRole('button', { name: 'Person' }).click();
// Wait for Thank button to be ready - simplified approach
await page.waitForSelector('button:has-text("Thank")', { timeout: 10000 });
await page.getByRole('button', { name: 'Thank' }).click();
await page.getByRole('listitem').filter({ hasText: UNNAMED_ENTITY_NAME }).locator('svg').click();
await page.getByPlaceholder('What was given').fill('Gave me access!');
await page.getByRole('button', { name: 'Sign & Send' }).click();

Some files were not shown because too many files have changed in this diff Show More