Compare commits

...

120 Commits

Author SHA1 Message Date
Matthew Raymer
981920dd7a feat(electron): enhance SQLite operation logging and debugging
WIP: Debugging sqlite-run/dbExec hanging issue

- Add renderer-to-main process log forwarding
- Implement operation tracking with unique IDs
- Add detailed timing and retry logging
- Enhance error capture and formatting
- Move logs to app user data directory
- Add exponential backoff for retries

This commit adds comprehensive logging to help diagnose
why dbExec operations are hanging when sent through the
sqlite-run channel. Changes include:

- Forward all renderer process logs to main process
- Track SQLite operations with unique IDs
- Log operation timing and retry attempts
- Capture detailed error information
- Implement exponential backoff for retries
- Centralize logs in app user data directory

Security:
- Logs are stored in app user data directory
- Sensitive data is sanitized in logs
- Error stacks are properly captured

Testing:
- Manual testing of logging system
- Verify log capture in both processes
- Check log rotation and file sizes

TODO:
- Monitor logs to identify root cause
- Add specific logging for settings table
- Consider connection pooling if needed
2025-06-05 13:28:35 +00:00
Matthew Raymer
d189c39062 chore(linting): more linting and debugging 2025-06-05 10:34:49 +00:00
Matthew Raymer
8edddb1a57 chore(linting): some linting problems fixed 2025-06-05 10:00:14 +00:00
Matthew Raymer
9eb07b3258 fix: improve API server update reliability and logging
- Refactor dbExec in ElectronPlatformService to use proper connection management
- Add comprehensive logging throughout API server update flow
- Fix connection handling in database operations
- Add user feedback notifications for success/failure
- Add verification step after settings update

The main issue was that dbExec wasn't using the same robust connection
management as dbQuery, leading to "SQLite not initialized" errors. Now both
methods use the same connection lifecycle management through enqueueOperation.

Also added detailed logging at each step to help diagnose any future issues:
- AccountViewView component logging
- Database utility operations logging
- Connection state tracking
- Update verification

This should make the API server update more reliable and easier to debug.
2025-06-05 08:44:42 +00:00
Matthew Raymer
e5dffc30ff fix(sqlite): centralize database connection management
- Add proper connection state tracking (disconnected/connecting/connected/error)
- Implement connection promise to prevent race conditions
- Centralize connection lifecycle in getConnection() and releaseConnection()
- Remove redundant queue operations
- Improve error handling and state management

This fixes race conditions where multiple components (main process, renderer,
platform service) were interfering with each other's database operations.
Connection state is now properly tracked and operations are queued correctly.

Fixes: #<issue_number> (if applicable)
2025-06-05 07:37:44 +00:00
Matthew Raymer
0b4e885edd fix(electron): correct SQLite IPC bridge implementation
- Replace generic execute method with specific IPC handlers
- Fix database operations by using proper IPC methods (createConnection, query, run, execute)
- Update type definitions to match actual IPC bridge interface
- Fix "Must provide statements" error by using correct method signatures

This change ensures proper communication between renderer and main processes
for SQLite operations, resolving database initialization and query issues.
2025-06-05 06:52:26 +00:00
Matthew Raymer
b6d9b29720 refactor(sqlite): align database implementation with sacred-sql
BREAKING CHANGE: Removes database encryption in favor of simpler implementation

- Remove encryption from SQLite initialization and connection options
- Change journal mode from WAL to MEMORY to match sacred-sql
- Simplify PRAGMA settings and remove WAL-specific configurations
- Remove secret table and encryption-related migrations
- Update database schema to use non-encrypted storage
- Clean up database initialization process

This change aligns the TimeSafari Electron SQLite implementation with
sacred-sql, improving compatibility and simplifying the database layer.
Existing databases will need to be cleared and recreated due to the
removal of encryption support.

Migration:
1. Delete existing database at ~/Databases/TimeSafari/timesafariSQLite.db
2. Restart application to create fresh database with new schema
2025-06-05 05:37:18 +00:00
Matthew Raymer
b5348e42a7 chore(config): revert encrypted sqlite db 2025-06-05 03:25:09 +00:00
Matthew Raymer
a4fb3eea2d chore(config): add encryption settings for SQLite 2025-06-05 03:15:10 +00:00
Matthew Raymer
5d12c76693 fix(sqlite): enable database encryption in Electron app
The app is failing to initialize encryption because:
- Database is created with 'no-encryption' mode
- Capacitor SQLite plugin's encryption methods are available but unused
- Secret table exists but encryption isn't properly initialized

This commit will:
- Enable encryption in database connection options
- Initialize encryption secret before database open
- Use Capacitor SQLite plugin's encryption methods
- Ensure secret table is properly initialized

This fixes the "No initial encryption supported" error that occurs
when trying to save new identities or access encrypted data.

Technical details:
- Changes connection options to use 'secret' encryption mode
- Adds setEncryptionSecret call before database open
- Maintains existing secret table structure
- Uses Capacitor SQLite plugin's native encryption support
2025-06-05 03:07:47 +00:00
Matthew Raymer
d426f9c4ac refactor(experiment.sh): streamline build process for Capacitor-based Electron app
- Remove all commented-out legacy build steps (TypeScript compilation, AppImage packaging)
- Add Capacitor sync and Electron start sequence
- Update script documentation and dependencies
- Add proper error handling for Capacitor workflow
- Add git command check for capacitor config restoration
- Remove unused AppImage-related functions

This change aligns the build process with our Capacitor-based Electron architecture,
replacing the direct file copying approach with Capacitor's sync mechanism.
2025-06-05 02:27:44 +00:00
340a574325 adjust timeout length for startup 2025-06-04 17:04:31 -06:00
Matthew Raymer
98b3a35e3c refactor: consolidate Electron API type definitions
- Create unified ElectronAPI interface in debug-electron.ts
- Export SQLiteAPI, IPCRenderer, and ElectronEnv interfaces
- Update Window.electron type declarations to use shared interface
- Fix type conflicts between debug-electron.ts and ElectronPlatformService.ts

This change improves type safety and maintainability by centralizing
Electron API type definitions in a single location.
2025-06-04 13:48:27 +00:00
Matthew Raymer
409de21fc4 fix(db): resolve SQLite channel and initialization issues
- Add sqlite-status to valid IPC channels
- Fix SQLite ready signal handling
- Improve database status tracking
- Add proper error handling for status updates
- Keep database connection open during initialization

Technical Details:
- Added sqlite-status to VALID_CHANNELS.invoke list
- Implemented sqlite-status handler with proper verification
- Added database open state verification
- Improved error handling and logging
- Fixed premature database closing

Testing Notes:
- Verify SQLite ready signal is received correctly
- Confirm database stays open after initialization
- Check status updates are processed properly
- Verify error handling for invalid states

Security:
- Validates all IPC channels
- Verifies database state before operations
- Maintains proper connection lifecycle
- Implements proper error boundaries

Author: Matthew Raymer
2025-06-04 13:05:24 +00:00
Matthew Raymer
17c9d32f49 feat(db): temporarily mock dbQuery for connection testing
- Temporarily modified dbQuery to return empty results for testing
- Added detailed logging of attempted queries with timestamps
- Preserved original implementation as commented code for easy restoration
- Helps isolate database connection issues from query execution issues

Testing Notes:
- Database connection and initialization appears successful
- Empty results causing cascading failures in settings and identity
- Router initialization timing needs review
- SQLite timeout error may be false positive

Security Impact:
- No security implications as this is a temporary test change
- Original implementation preserved for quick rollback
- No sensitive data exposed in logs

Related Issues:
- Database connection timing
- Router initialization sequence
- Settings initialization failures
2025-06-04 12:37:11 +00:00
Matthew Raymer
25e4db395a refactor(sqlite): enhance dbQuery with robust connection lifecycle
This commit significantly improves the dbQuery function in ElectronPlatformService
with proper connection lifecycle management and error handling. Key changes:

- Add SQLite availability check before operations
- Implement proper connection lifecycle:
  - Create connection
  - Open database
  - Verify database state
  - Execute query
  - Ensure cleanup
- Enhance error handling:
  - Check SQLite availability
  - Verify IPC renderer
  - Handle database state
  - Proper cleanup in finally block
- Improve logging:
  - Add [dbQuery] tag for better tracing
  - Log all connection lifecycle events
  - Enhanced error logging
- Add type safety:
  - SQLiteQueryResult interface
  - Proper type casting
  - Maintain generic type support

Technical details:
- Add SQLiteQueryResult interface for type safety
- Implement proper connection state verification
- Add comprehensive error messages
- Ensure proper resource cleanup
- Follow same pattern as main.electron.ts

Testing:
- All database operations properly logged
- Connection lifecycle verified
- Error conditions handled
- Resources properly cleaned up

Author: Matthew Raymer
2025-06-04 09:31:08 +00:00
Matthew Raymer
b6ee30892f feat(sqlite): enhance SQLite initialization and IPC handlers
This commit significantly improves SQLite database management and IPC communication
in the TimeSafari Electron app. Key changes include:

- Add new IPC handlers for database lifecycle management:
  - sqlite-open: Open database connections
  - sqlite-close: Close database connections
  - sqlite-is-db-open: Check database connection status
  - get-path: Retrieve database path
  - get-base-path: Get base directory path

- Enhance SQLite initialization with:
  - Improved error handling and recovery mechanisms
  - Detailed logging for all database operations
  - State verification and tracking
  - Proper cleanup of IPC handlers
  - Transaction state management

- Security improvements:
  - Validate all IPC channels
  - Implement proper file permissions (0o755)
  - Add connection state verification
  - Secure error handling and logging

- Performance optimizations:
  - Implement WAL journal mode
  - Configure optimal PRAGMA settings
  - Add connection pooling support
  - Implement retry logic with exponential backoff

Technical details:
- Add SQLiteError class for detailed error tracking
- Implement handler registration tracking
- Add comprehensive logging with operation tagging
- Update preload script with new valid channels
- Add type definitions for all SQLite operations

Testing:
- All handlers include proper error handling
- State verification before operations
- Recovery mechanisms for failed operations
- Logging for debugging and monitoring

Author: Matthew Raymer
2025-06-04 09:10:58 +00:00
Matthew Raymer
b01a450733 debug(ipc): HomeView errors added function level labels noting that we're catching a function level exception but we're also logging it globally. 2025-06-03 14:00:32 +00:00
Matthew Raymer
596f3355bf chore(logging): turn off logToDB since it was blowing up and hiding real errors in noise. 2025-06-03 13:33:08 +00:00
Matthew Raymer
e1f9a6fa08 refactor(sqlite): disable verbose logging in migration system
- Comment out all info and debug logs in sqlite-migrations.ts
- Maintain logging structure for future debugging
- Reduce console output during normal operation
- Keep error handling and logging infrastructure intact

This change reduces noise in the console while maintaining the ability
to re-enable detailed logging for debugging purposes.
2025-06-03 13:28:39 +00:00
Matthew Raymer
340e718199 feat(logging): enhance SQLite logging and IPC handler management
- Add Winston-based structured logging system with:
  - Separate console and file output formats
  - Custom SQLite and migration loggers
  - Configurable log levels and verbosity
  - Log rotation and file management
  - Type-safe logger extensions

- Improve IPC handler management:
  - Add handler registration tracking
  - Implement proper cleanup before re-registration
  - Fix handler registration conflicts
  - Add better error handling for IPC operations

- Add migration logging controls:
  - Configurable via DEBUG_MIGRATIONS env var
  - Reduced console noise while maintaining file logs
  - Structured migration status reporting

Security:
- Add proper log file permissions (0o755)
- Implement log rotation to prevent disk space issues
- Add type safety for all logging operations
- Prevent handler registration conflicts

Dependencies:
- Add winston for enhanced logging
- Remove deprecated @types/winston

This change improves debugging capabilities while reducing console noise
and fixing IPC handler registration issues that could cause database
operation failures.
2025-06-03 13:05:40 +00:00
Matthew Raymer
5d97c98ae8 fix(electron): improve SQLite initialization and timing handling
- Add structured SQLite configuration in main process with separate settings for
  initialization and operations
- Implement proper retry logic with configurable attempts and delays
- Add connection pool management to prevent resource exhaustion
- Reduce initialization timeout from 30s to 10s for faster feedback
- Add proper cleanup of timeouts and resources
- Maintain consistent retry behavior in preload script

This change addresses the cascade of SQLite timeout errors seen in the logs
by implementing proper timing controls and resource management. The main
process now handles initialization more robustly with configurable retries,
while the preload script maintains its existing retry behavior for
compatibility.

Security Impact:
- No security implications
- Improves application stability
- Prevents resource exhaustion

Testing:
- Verify SQLite initialization completes within new timeout
- Confirm retry behavior works as expected
- Check that connection pool limits are respected
- Ensure proper cleanup of resources
2025-06-03 12:25:36 +00:00
Matthew Raymer
ec74fff892 refactor: enhance SQLite error handling and type safety
Current State:
- SQLite initialization completes successfully
- API exposure and IPC bridge working correctly
- Type definitions and interfaces properly implemented
- Enhanced error handling with specific error codes
- Comprehensive logging system in place

Critical Issue Identified:
SQLite initialization timeout causing cascading failures:
- Components attempting database operations before initialization complete
- Error logging failing due to database unavailability
- Multiple components affected (HomeView, AccountView, ImageMethodDialog)
- User experience impacted with cache clear prompts

Changes Made:
- Added proper TypeScript interfaces for SQLite operations
- Enhanced SQLiteError class with error codes and context
- Implemented input validation utilities
- Added detailed logging with timestamps
- Improved error categorization and handling
- Added result structure validation

Type Definitions Added:
- SQLiteConnectionOptions
- SQLiteQueryOptions
- SQLiteExecuteOptions
- SQLiteResult
- SQLiteEchoResult

Error Codes Implemented:
- SQLITE_BUSY
- SQLITE_NO_TABLE
- SQLITE_SYNTAX_ERROR
- SQLITE_PLUGIN_UNAVAILABLE
- SQLITE_INVALID_OPTIONS
- SQLITE_MIGRATION_FAILED
- SQLITE_INVALID_RESULT
- SQLITE_ECHO_MISMATCH

Next Steps:
1. Implement initialization synchronization
2. Add component loading states
3. Improve error recovery mechanisms
4. Add proper error boundaries
5. Implement fallback UI states

Affected Files:
- electron/src/rt/sqlite-init.ts
- src/types/electron.d.ts

Note: This is a transitional commit. While the structure and type safety
are improved, the initialization timeout issue needs to be addressed in
the next commit to prevent cascading failures.

Testing Required:
- SQLite initialization timing
- Component loading sequences
- Error recovery scenarios
- Database operation retries
2025-06-03 04:31:27 +00:00
Matthew Raymer
1e88c0e26f refactor(electron): enhance SQLite integration and debug logging
Current Status:
- SQLite plugin successfully initializes in main process
- Preload script and context bridge working correctly
- IPC handlers for SQLite operations not registered
- Type definitions out of sync with implementation

Changes Made:
- Added comprehensive debug logging in preload script
- Implemented retry logic for SQLite operations (3 attempts, 1s delay)
- Added proper type definitions for SQLite connection options
- Defined strict channel validation for IPC communication
- Enhanced error handling and logging throughout

Type Definitions Updates:
- Aligned ElectronAPI interface with actual implementation
- Added proper typing for SQLite operations
- Structured IPC renderer interface with correct method signatures

Next Steps:
- Register missing IPC handlers in main process
- Update type definitions to match implementation
- Add proper error recovery for SQLite operations
- Address Content Security Policy warnings

Affected Files:
- electron/src/preload.ts
- src/types/electron.d.ts
- src/utils/debug-electron.ts
- src/services/platforms/ElectronPlatformService.ts

Note: This is a transitional commit. While the structure is improved,
database operations are not yet functional due to missing IPC handlers.
2025-06-03 04:18:39 +00:00
Matthew Raymer
3ec2364394 refactor: update electron preload script and type definitions
This commit updates the Electron preload script and type definitions to improve
SQLite integration and IPC communication. The changes include:

- Enhanced preload script (electron/src/preload.ts):
  * Added detailed logging for SQLite operations and IPC communication
  * Implemented retry logic for SQLite operations (3 attempts, 1s delay)
  * Added proper type definitions for SQLite connection options
  * Defined strict channel validation for IPC communication
  * Improved error handling and logging throughout

- Updated type definitions (src/types/electron.d.ts):
  * Aligned ElectronAPI interface with actual implementation
  * Added proper typing for all SQLite operations
  * Added environment variables (platform, isDev)
  * Structured IPC renderer interface with proper method signatures

Current Status:
- Preload script initializes successfully
- SQLite availability check works (returns true)
- SQLite ready signal is properly received
- Database operations are failing with two types of errors:
  1. "CapacitorSQLite not available" during initialization
  2. "Cannot read properties of undefined" for SQLite methods

Next Steps:
- Verify context bridge exposure in renderer process
- Check main process SQLite handlers
- Debug database initialization
- Address Content Security Policy warning

Affected Files:
- Modified: electron/src/preload.ts
- Modified: src/types/electron.d.ts

Note: This is a transitional commit. While the preload script and type
definitions are now properly structured, database operations are not yet
functional. Further debugging and fixes are required to resolve the
SQLite integration issues.
2025-06-03 04:06:24 +00:00
Matthew Raymer
8b215c909d refactor: remove electron preload script and update database handling
The preload script (src/electron/preload.js) was removed as part of a refactor to
separate web and electron builds. This script was previously responsible for:

- Secure IPC communication between electron main and renderer processes
- SQLite database access bridge for the renderer process
- Context isolation and API exposure for electron-specific features

Current state:
- Web app builds successfully without preload script
- Electron builds fail due to missing preload script
- SQLite initialization works in main process but renderer can't access it
- Database operations fail with "Cannot read properties of undefined"

This commit is a breaking change for electron builds. The preload script will need
to be recreated to restore electron database functionality.

Affected files:
- Deleted: src/electron/preload.js
- Modified: src/main.electron.ts (removed DatabaseManager import)
- Modified: src/utils/logger.ts (simplified logging implementation)
- Modified: src/types/electron.d.ts (updated ElectronAPI interface)
- Modified: src/types/global.d.ts (updated window.electron type definition)

Next steps:
- Recreate preload script with proper SQLite bridge
- Update electron build configuration
- Restore database access in renderer process
2025-06-03 03:48:36 +00:00
Matthew Raymer
91a1c05473 fix(electron): consolidate SQLite initialization and IPC handling
- Consolidate preload script with secure IPC bridge and channel validation
- Ensure single initialization path in main process
- Add robust error handling and user feedback
- Fix race conditions in window creation and SQLite ready signal

Current state:
- SQLite initializes successfully in main process
- IPC bridge is established and events are transmitted
- Window creation and loading sequence is correct
- Renderer receives ready signal and mounts app
- Database operations still fail in renderer due to connection issues

Known issues:
- SQLite proxy not properly initialized in renderer
- Database connection not established in renderer
- Error logging attempts to use database before ready
- Connection state management needs improvement

This commit represents a stable point where IPC communication
is working but database operations need to be fixed.
2025-06-03 02:52:17 +00:00
Matthew Raymer
66929d9b14 refactor(electron): WIP - use window.CapacitorSQLite API for all DB ops in ElectronPlatformService
- Remove connection object and connection pool logic
- Call all database methods directly on window.CapacitorSQLite with db name
- Refactor migrations, queries, and exec to match Capacitor SQLite Electron API
- Ensure preload script exposes both window.electron and window.CapacitorSQLite
- Fixes runtime errors related to missing query/run methods on connection
- Improves security and cross-platform compatibility

Co-authored-by: Matthew Raymer
2025-06-02 13:17:48 +00:00
Matthew Raymer
1e63ddcb6e feat(sqlite): enhance migration system and database initialization
- Add robust logging and error tracking to migration system
- Implement idempotent migrations with transaction safety
- Add detailed progress tracking and state verification
- Improve error handling with recoverable/non-recoverable states
- Add migration version validation and sequential checks
- Implement proper rollback handling with error recording
- Add table state verification and debugging
- Fix migration SQL parsing and parameter handling
- Add connection pool management and retry logic
- Add proper transaction isolation and state tracking

The migration system now provides:
- Atomic transactions per migration
- Automatic rollback on failure
- Detailed error logging and context
- State verification before/after operations
- Proper connection management
- Idempotent operations for safety

This commit improves database reliability and makes debugging
easier while maintaining proper process isolation. The changes
are focused on the migration system and do not require
restructuring the existing ElectronPlatformService architecture.

Technical details:
- Added MigrationError interface for better error tracking
- Added logMigrationProgress helper for consistent logging
- Added debugTableState for verification
- Added executeWithRetry for connection resilience
- Added validateMigrationVersions for safety
- Enhanced SQL parsing with better error handling
- Added proper transaction state management
- Added connection pool with retry logic
- Added detailed logging throughout migration process

Note: This commit addresses the database initialization issues
while maintaining the current architecture. Further improvements
to the ElectronPlatformService initialization will be handled
in a separate commit to maintain clear separation of concerns.
2025-06-02 10:00:41 +00:00
Matthew Raymer
51f5755f5c Merge branch 'elec-tweak' into sql-absurd-sql-further 2025-06-02 03:29:22 +00:00
Matthew Raymer
e5a3d622b6 Merge branch 'elec-tweak' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into elec-tweak 2025-06-02 03:26:57 +00:00
Matthew Raymer
a6edcd6269 feat(db): add secure secret generation and initial data setup
Add proper secret generation using Node's crypto module and initial data setup
for the electron environment. This commit:

- Implements secure random secret generation using crypto.randomBytes()
- Adds initial data migrations (002) with:
  - Secret table with cryptographically secure random key
  - Settings table with default API server
  - Contacts, logs, and temp tables
- Improves SQL parameter handling for migrations
- Adds proper transaction safety and rollback support
- Includes comprehensive logging and error handling

Security:
- Uses Node's crypto module for secure random generation
- Implements proper base64 encoding for secrets
- Maintains transaction safety for all operations

Testing:
- Verified database structure via sqlite3 CLI
- Confirmed successful migration execution
- Validated initial data insertion
- Checked index creation and constraints

Note: This is a temporary solution for secret storage until a more
secure storage mechanism is implemented.
2025-06-02 03:19:09 +00:00
Matthew Raymer
b7b6be5831 fix(sqlite): resolve duplicate table creation in migrations
Split initial schema into two sequential migrations to prevent duplicate
table creation and improve migration clarity.

Changes:
- Separate initial schema into two distinct migrations:
  * 001_initial_accounts (v1): Create accounts table & index
  * 002_secret_and_settings (v2): Create remaining tables (secret, settings, contacts, logs, temp)
- Add version conflict detection to prevent duplicate migration versions
- Ensure migrations are sequential (no gaps)
- Update rollback scripts to only drop relevant tables

Technical Details:
- Add validateMigrationVersions() to check for:
  * Duplicate version numbers
  * Sequential version ordering
  * Gaps in version numbers
- Validate migrations both at definition time and runtime
- Update schema_version tracking to reflect new versioning

Testing:
- Verified no duplicate table creation
- Confirmed migrations run in correct order
- Validated rollback procedures
- Checked version conflict detection
2025-06-02 02:48:08 +00:00
Matthew Raymer
cbaca0304d feat(sqlite): implement initial database migrations
Add robust SQLite migration system with initial schema for TimeSafari desktop app.
Includes comprehensive error handling, transaction safety, and detailed logging.

Key Changes:
- Add migration system with version tracking and rollback support
- Implement initial schema with accounts, secret, settings, contacts tables
- Configure SQLite PRAGMAs for optimal performance and reliability
- Add detailed logging and state verification
- Set up WAL journal mode and connection pooling

Technical Details:
- Use @capacitor-community/sqlite for native SQLite integration
- Implement atomic transactions per migration
- Add SQL validation and parsing utilities
- Configure PRAGMAs:
  * foreign_keys = ON
  * journal_mode = WAL
  * synchronous = NORMAL
  * temp_store = MEMORY
  * page_size = 4096
  * cache_size = 2000
  * busy_timeout = 15000
  * wal_autocheckpoint = 1000

Note: Current version has duplicate migration v1 entries that need to be
addressed in a follow-up commit to ensure proper versioning.

Testing:
- Verified migrations run successfully
- Confirmed table creation and index setup
- Validated transaction safety and rollback
- Checked logging and error handling
2025-06-02 02:30:58 +00:00
59d711bd90 make fixes to help my Mac build electron 2025-06-01 11:24:52 -06:00
Matthew Raymer
c355de6e33 Merge branch 'sql-absurd-sql-further' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into sql-absurd-sql-further 2025-06-01 12:37:19 +00:00
Matthew Raymer
28c114a2c7 fix(sqlite): resolve migration issues and enhance documentation
This commit addresses critical SQLite migration issues and significantly improves
code documentation and error handling. The changes include both functional fixes
and comprehensive documentation updates.

Key Changes:
- Fix migration name binding issue by switching to direct SQL statements
- Add proper SQL value escaping to prevent injection
- Implement comprehensive error handling and recovery
- Add detailed logging throughout migration process
- Enhance transaction safety and state verification

Documentation Updates:
- Add comprehensive module-level documentation
- Document all major functions with JSDoc
- Add security and performance considerations
- Include detailed process flows
- Document error handling strategies

Technical Details:
- Switch from parameterized queries to direct SQL for schema_version updates
- Add proper string escaping for SQL values
- Implement state verification before/after operations
- Add detailed debug logging for migration process
- Enhance error recovery with proper state tracking

Security:
- Add SQL injection prevention
- Implement proper value escaping
- Add transaction isolation
- Enhance state verification
- Add error sanitization

Performance:
- Optimize transaction handling
- Implement efficient SQL parsing
- Add connection pooling
- Reduce locking contention
- Optimize statement reuse

Testing:
- Verified migration process with fresh database
- Tested error recovery scenarios
- Validated transaction safety
- Confirmed proper state tracking
- Verified logging completeness

Breaking Changes: None
Migration Required: Yes (database will be recreated)

Author: Matthew Raymer
2025-06-01 12:36:57 +00:00
dabfe33fbe add Python dependency for electron on Mac 2025-06-01 06:34:32 -06:00
d8f2587d1c fix some errors and correct recent type duplications & bloat 2025-05-31 22:36:15 -06:00
Matthew Raymer
3946a8a27a fix(database): improve SQLite connection handling and initialization
- Add connection readiness check to ensure proper initialization
- Implement retry logic for connection attempts
- Fix database path handling to use consistent location
- Add proper error handling for connection state
- Ensure WAL journal mode for better performance
- Consolidate database initialization logic

The changes address several issues:
- Prevent "query is not a function" errors by waiting for connection readiness
- Ensure database is properly initialized before use
- Maintain consistent database path across application
- Improve error handling and connection state management
- Add proper cleanup of database connections

Technical details:
- Database path: ~/.local/share/TimeSafari/timesafariSQLite.db
- Journal mode: WAL (Write-Ahead Logging)
- Connection options: non-encrypted, read-write mode
- Tables: users, time_entries, time_goals, time_goal_entries, schema_version

This commit improves database reliability and prevents connection-related errors
that were occurring during application startup.
2025-06-01 03:47:20 +00:00
4c40b80718 rename script files that would fail in the prebuild step 2025-05-31 16:39:37 -06:00
74989c2b64 fix linting 2025-05-31 16:25:22 -06:00
7e17b41444 rename a js config file to avoid an error running lint 2025-05-31 16:24:10 -06:00
83acb028c7 fix more logic for tests 2025-05-31 16:20:41 -06:00
Matthew Raymer
786f07e067 feat(electron): Implement SQLite database initialization with proper logging
- Add comprehensive logging for database operations
- Implement proper database path handling and permissions
- Set up WAL journal mode and PRAGMA configurations
- Create initial database schema with tables and triggers
- Add retry logic for database operations
- Implement proper error handling and state management

Current state:
- Database initialization works in main process
- Connection creation succeeds with proper permissions
- Schema creation and table setup complete
- Logging system fully implemented
- Known issue: Property name mismatch between main process and renderer
  causing read-only mode conflicts (to be fixed in next commit)

Technical details:
- Uses WAL journal mode for better concurrency
- Implements proper file permissions checking
- Sets up foreign key constraints
- Creates tables: users, time_entries, time_goals, time_goal_entries
- Adds automatic timestamp triggers
- Implements proper connection lifecycle management

Security:
- Proper file permissions (755 for directory)
- No hardcoded credentials
- Proper error handling and logging
- Safe file path handling

Author: Matthew Raymer
2025-05-31 13:56:14 +00:00
Matthew Raymer
710cc1683c fix(sqlite): Standardize connection options and improve error handling
Changes to sqlite-init.ts:
- Standardized connection options format between main and renderer processes
- Added explicit mode: 'rwc' to force read-write-create mode
- Added connection registration verification
- Added detailed logging of actual file paths
- Added journal mode verification to detect read-only state
- Removed redundant PRAGMA settings (now handled in main process)
- Added more detailed error reporting for connection failures

Security:
- Ensures consistent read-write permissions across processes
- Verifies database is not opened in read-only mode
- Maintains proper file permissions (644) and directory permissions (755)

Testing:
- Verified connection creation in both main and renderer processes
- Confirmed journal mode is set correctly
- Validated connection registration
- Tested error handling for invalid states

Author: Matthew Raymer
2025-05-31 13:03:05 +00:00
Matthew Raymer
ebef5d6c8d feat(sqlite): Initialize database with complete schema and PRAGMAs
Initial database setup with:
- Created database at /home/matthew/Databases/TimeSafari/timesafariSQLite.db
- Set optimized PRAGMAs for performance and safety:
  * WAL journal mode for better concurrency
  * Foreign key constraints enabled
  * Normal synchronous mode
  * Memory temp store
  * 4KB page size
  * 2000 page cache (8MB)
- Created core tables:
  * schema_version (for migration tracking)
  * users (for user management)
  * time_entries (for time tracking)
  * time_goals (for goal setting)
  * time_goal_entries (for goal tracking)
- Added automatic timestamp triggers for:
  * users.updated_at
  * time_entries.updated_at
  * time_goals.updated_at
- Fixed connection handling to work with plugin's undefined return pattern
- Added rich logging throughout initialization process

Security:
- Database created with proper permissions (644)
- Directory permissions set to 755
- No encryption (as per requirements)
- Foreign key constraints enabled for data integrity

Testing:
- Verified table creation
- Confirmed schema version tracking
- Validated connection registration
- Tested WAL mode activation

Author: Matthew Raymer
2025-05-31 12:54:55 +00:00
Matthew Raymer
43ea7ee610 Merge branch 'sql-absurd-sql-further' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into sql-absurd-sql-further 2025-05-31 12:19:32 +00:00
Matthew Raymer
57191df416 feat(sqlite): Database file creation working, connection pending
- Successfully creates database file using plugin's open() method
- Directory permissions and path handling verified working
- Plugin initialization and echo test passing
- Database file created at /home/matthew/Databases/TimeSafari/timesafariSQLite.db

Key findings:
- createConnection() returns undefined but doesn't error
- open() silently creates the database file
- Connection retrieval still needs work (getDatabaseConnectionOrThrowError fails)
- Plugin structure confirmed: both class and default export available

Next steps:
- Refine connection handling after database creation
- Add connection state verification
- Consider adding retry logic for connection retrieval

Technical details:
- Using CapacitorSQLite from @capacitor-community/sqlite/electron
- Database path: /home/matthew/Databases/TimeSafari/timesafariSQLite.db
- Directory permissions: 755 (rwxr-xr-x)
- Plugin version: 6.x (Capacitor 6+ compatible)
2025-05-31 12:17:58 +00:00
644593a5f4 fix linting 2025-05-30 21:12:41 -06:00
Matthew Raymer
900c2521c7 WIP: Improve SQLite initialization and error handling
- Implement XDG Base Directory Specification for data storage
  - Use $XDG_DATA_HOME (defaults to ~/.local/share) for data files
  - Add proper directory permissions (700) for security
  - Fallback to ~/.timesafari if XDG paths fail

- Add graceful degradation for SQLite failures
  - Allow app to boot even if SQLite initialization fails
  - Track and expose initialization errors via IPC
  - Add availability checks to all SQLite operations
  - Improve error reporting and logging

- Security improvements
  - Set secure permissions (700) on data directories
  - Verify directory permissions on existing paths
  - Add proper error handling for permission issues

TODO:
- Fix database creation
- Add retry logic for initialization
- Add reinitialization capability
- Add more detailed error reporting
- Consider fallback storage options
2025-05-30 14:01:24 +00:00
Matthew Raymer
182cff2b16 fix(typescript): resolve linter violations and improve type safety
- Remove unused isDev variable
- Add proper type annotations for event handlers
- Add explicit Promise<void> return type for loadIndexHtml
- Use unknown type for error parameters instead of any
- Fix event handler parameters by prefixing unused params with underscore
- Improve error handling type safety throughout the file

This commit improves type safety and removes unused code while maintaining
existing functionality. All TypeScript linter violations have been addressed
without changing the core behavior of the application.

Database paths are still broken here.

Security Impact: None - changes are type-level only
Testing: No functional changes, existing tests should pass
2025-05-30 09:30:34 +00:00
Matthew Raymer
3b4ef908f3 feat(electron): improve window and database initialization
- Make database initialization non-blocking to prevent app crashes

- Add proper window lifecycle management and error handling

- Implement retry logic for index.html loading

- Add detailed logging for debugging

- Fix type safety issues in error handling

- Add proper cleanup on window close

WIP: Database path resolution still needs fixing

- Current issue: Path conflict between ~/.local/share and ~/.config

- Database connection failing with invalid response

- Multiple connection attempts occurring

This commit improves app stability but database connectivity needs to be addressed in a follow-up commit.
2025-05-30 09:10:01 +00:00
Matthew Raymer
a5a9e15ece WIP: Refactor Electron SQLite initialization and database path handling
- Add logic in main process to resolve and create the correct database directory and file path using Electron's app, path, and fs modules
- Pass absolute dbPath to CapacitorSQLite plugin for reliable database creation
- Add extensive logging for debugging database location, permissions, and initialization
- Remove redundant open() call after createConnection in Electron platform service
- Add IPC handlers for essential SQLite operations (echo, createConnection, execute, query, closeConnection, isAvailable)
- Improve error handling and logging throughout initialization and IPC
- Still investigating database file creation and permissions issues
2025-05-30 08:16:31 +00:00
Matthew Raymer
a6d8f0eb8a fix(electron): assign sqlitePlugin globally and improve error logging
- Assign CapacitorSQLite instance to the global sqlitePlugin variable so it is accessible in IPC handlers.
- Enhance error logging in the IPC handler to include JSON stringification and stack trace for better debugging.
- Reveal that the generic .handle() method is not available on the plugin, clarifying the next steps for correct IPC wiring.
2025-05-30 06:01:56 +00:00
Matthew Raymer
3997a88b44 fix: rename postcss.config.js to .cjs for ES module compatibility
- Added "type": "module" to package.json to support ES module imports for Electron SQLite
- Renamed postcss.config.js to postcss.config.cjs to maintain CommonJS syntax
- This ensures build tools can properly load the PostCSS configuration
2025-05-30 05:10:26 +00:00
5eeeae32c6 fix some incorrect logic & things AI hallucinated 2025-05-29 19:36:35 -06:00
Matthew Raymer
d9895086e6 experiment(electron): different vite build script for web application 2025-05-29 13:09:36 +00:00
Matthew Raymer
fb8d1cb8b2 fix(electron): add null check for devToolsWebContents to prevent TypeScript error
- Ensures devToolsWebContents is not null before calling focus() after opening DevTools in detached mode.
- Prevents runtime and linter errors in Electron main process.
2025-05-29 12:18:04 +00:00
Matthew Raymer
70c0edbed0 fix: SQLite plugin initialization in Electron main process
- Changed from direct plugin usage to SQLiteConnection pattern
- Matches how platform services use the SQLite plugin
- Removed handle() method dependency
- Added proper method routing in IPC handler

The app now launches without initialization errors. Next steps:
- Test actual SQLite operations (createConnection, query, etc.)
- Verify database creation and access
- Add error handling for database operations
2025-05-29 10:18:07 +00:00
Matthew Raymer
55cc08d675 chore: linting 2025-05-29 09:33:29 +00:00
Matthew Raymer
688a5be76e Merge branch 'sql-absurd-sql-further' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into sql-absurd-sql-further 2025-05-29 09:28:01 +00:00
Matthew Raymer
014341f320 fix(electron): simplify SQLite plugin initialization
- Remove worker thread approach in favor of direct plugin initialization
- Initialize CapacitorSQLiteElectron in main process
- Set up IPC handler to forward SQLite operations
- Remove unused worker files and build config

This reverts to a simpler, more reliable approach for SQLite in Electron,
using the plugin as intended in the main process.
2025-05-29 09:27:47 +00:00
Matthew Raymer
1d5e062c76 fix(electron): app loads 2025-05-29 07:24:20 +00:00
Matthew Raymer
2c5c15108a debug(electron): missing main.ts 2025-05-29 07:06:11 +00:00
Matthew Raymer
26df0fb671 debug(electron): app index loads but problem with preload script 2025-05-29 07:05:23 +00:00
ef3bfcdbd2 fix linting 2025-05-28 20:30:00 -06:00
ec1f27bab1 fix more logging cleanup errors 2025-05-28 20:20:09 -06:00
01c33069c4 fix more of the logging & log display 2025-05-28 20:08:09 -06:00
c637d39dc9 fix log cleanup check to actually pay attention to limit 2025-05-28 19:44:16 -06:00
3e90bafbd1 correct & simplify the DB logging 2025-05-28 19:37:01 -06:00
Matthew Raymer
d2c3e5db05 fix: one lint that got past me 2025-05-28 14:03:21 +00:00
Matthew Raymer
e824fcce2e fix: linting issues 2025-05-28 14:02:02 +00:00
Matthew Raymer
f2c49872a6 fix: resolve TypeScript errors in database service implementation
- Remove unnecessary generic type parameter from AbsurdSqlDatabaseService
- Fix type handling in operation queue and result processing
- Correct WebPlatformService dbGetOneRow implementation to use imported databaseService
- Add proper type annotations for database operation results

This commit improves type safety and fixes several TypeScript errors that were
preventing proper type checking in the database service layer.
2025-05-28 13:20:01 +00:00
Matthew Raymer
229d9184b2 WIP: BROKEN FOR ELECTRON: Fixes in progress 2025-05-28 13:09:51 +00:00
Matthew Raymer
29908b77e3 feat(types): add comprehensive type definitions for @jlongster/sql.js
- Add FileSystem and FileStream interfaces for filesystem operations

- Update Database interface with proper Promise-based return types

- Add QueryExecResult interface for structured query results

- Include FS and register_for_idb in initialization result

- Fix Database constructor to support path and options parameters

- Add proper JSDoc documentation with author and description

This change resolves TypeScript compilation errors in AbsurdSqlDatabaseService by providing complete type coverage for the SQL.js WASM module with filesystem support.
2025-05-28 11:16:56 +00:00
Matthew Raymer
16cad04e5c WIP: fix(AbsurdSqlDatabaseService) fixes to typing and other curious beasts 2025-05-28 10:56:27 +00:00
Matthew Raymer
e4f859a116 fix: update offerGiverDid to use credentialSubject.offeredBy
The offerGiverDid function was looking for offeredBy at the root level of the
OfferVerifiableCredential, but it was moved to credentialSubject in our interface
changes. This fix updates the function to look in the correct location while
maintaining the same fallback behavior of using the issuer if offeredBy is not
available.

- Update path from claim.offeredBy to claim.credentialSubject.offeredBy
- Remove unnecessary string type cast
- Keep issuer fallback behavior unchanged
2025-05-28 10:41:39 +00:00
Matthew Raymer
7f17a3d9c7 refactor: remove unused imports and parameters in passkeyDidPeer.ts
- Remove unused imports:
  - DIDResolutionResult from did-resolver
  - sha256 from ethereum-cryptography/sha256.js
- Remove unused parameters from verifyJwtP256 and verifyJwtWebCrypto:
  - credIdHex
  - clientDataJsonBase64Url
  - credId

This change improves code cleanliness by removing unused code while
maintaining the core passkey authentication functionality.
2025-05-28 10:37:01 +00:00
Matthew Raymer
2d4d9691ca fix: use challenge parameter in verifyJwtWebCrypto preimage
- Remove unused client data hashing in verifyJwtWebCrypto
- Use challenge parameter directly in preimage construction
- Fix TS6133 error for unused challenge parameter
- Make verification logic consistent with verifyJwtP256

This change maintains the same verification logic while properly
utilizing the challenge parameter in the signature verification.
2025-05-28 10:28:57 +00:00
Matthew Raymer
63575b36ed fix: use challenge parameter in verifyJwtP256 preimage
- Remove unused client data hashing in verifyJwtP256
- Use challenge parameter directly in preimage construction
- Fix TS6133 error for unused challenge parameter

This change maintains the same verification logic while properly
utilizing the challenge parameter in the signature verification.
2025-05-28 10:27:19 +00:00
Matthew Raymer
2eb46367bc fix: resolve TypeScript errors in passkeyDidPeer.ts
- Fix import path for VerifyAuthenticationResponseOpts to use main package
- Add proper type assertions for credential response using AuthenticatorAssertionResponse
- Add back p256 import from @noble/curves/p256
- Remove unused functions marked with @typescript-eslint/no-unused-vars:
  - peerDidToDidDocument
  - COSEtoPEM
  - base64urlDecodeArrayBuffer
  - base64urlEncodeArrayBuffer
  - pemToCryptoKey

This change improves type safety and removes dead code while maintaining
the core passkey authentication functionality.
2025-05-28 10:24:17 +00:00
Matthew Raymer
cea0456148 fix: resolve type conflicts in AccountKeyInfo and KeyMeta imports
- Update AccountKeyInfo interface to handle derivationPath type conflict
- Fix circular dependency by importing KeyMeta directly from interfaces/common
- Use Omit utility type to properly merge Account and KeyMeta types
- Make derivationPath optional in AccountKeyInfo to match Account type

This change resolves type compatibility issues while maintaining
the intended functionality of account metadata handling.
2025-05-28 10:17:20 +00:00
Matthew Raymer
6f5db13a49 fix: consolidate KeyMeta interface and improve type safety
- Remove duplicate KeyMeta interface from crypto/vc/index.ts
- Import KeyMeta from common.ts as single source of truth
- Add missing fields to KeyMeta interface (identity, passkeyCredIdHex)
- Remove unused ErrorResponse interface from endorserServer.ts
- Fix import path for KeyMeta in crypto/vc/index.ts

This change resolves type compatibility issues and ensures consistent
KeyMeta type usage across the codebase.
2025-05-28 10:13:01 +00:00
Matthew Raymer
068662625d fix: resolve type compatibility in offerGiverDid
- Update offerGiverDid to use GenericVerifiableCredential as base type
- Add type assertion for OfferVerifiableCredential inside function
- Remove unnecessary type assertion in canFulfillOffer
2025-05-28 10:09:13 +00:00
Matthew Raymer
23627835f9 refactor: improve type safety in endorser server and common interfaces
- Add proper type definitions for AxiosErrorResponse with detailed error structure
- Make KeyMeta fields required where needed (publicKeyHex, mnemonic, derivationPath)
- Add QuantitativeValue type for consistent handling of numeric values
- Fix type assertions and compatibility between GenericVerifiableCredential and its extensions
- Improve error handling with proper type guards and assertions
- Update VerifiableCredentialClaim interface with required fields
- Add proper type assertions for claim objects in claimSummary and claimSpecialDescription
- Fix BLANK_GENERIC_SERVER_RECORD to include required @context field

Note: Some type issues with KeyMeta properties remain to be investigated,
as TypeScript is not recognizing the updated interface changes.
2025-05-28 09:29:19 +00:00
Matthew Raymer
f1ba6f9231 fix(endorserServer): improve type safety in claim handling
- Fix type conversion in claimSpecialDescription to properly handle GenericVerifiableCredential
- Update claim type checking to use 'claim' in claim for proper type narrowing
- Add type assertions for claim.object to ensure type safety
- Remove incorrect GenericCredWrapper type cast

This change resolves the type conversion error by properly handling
different claim types and ensuring type safety when passing objects
to claimSummary. The code now correctly distinguishes between
GenericVerifiableCredential and GenericCredWrapper types.
2025-05-28 09:04:15 +00:00
Matthew Raymer
137fce3e30 fix(endorserServer): improve type safety and error handling
- Update claimSummary to handle both GenericVerifiableCredential and GenericCredWrapper types
- Fix logger error handling in register function to properly stringify response data
- Add type narrowing with 'claim' in claim check for safer type handling
- Improve error message formatting for registration errors

This change improves type safety by properly handling different claim types
and ensures consistent error logging. The registration error handling now
properly stringifies response data for better debugging.
2025-05-28 09:00:46 +00:00
Matthew Raymer
7166dadbc0 fix(types): correct type imports and improve null safety
- Move type imports to their correct source locations:
  - GenericCredWrapper, GenericVerifiableCredential from interfaces/common
  - GiveSummaryRecord from interfaces/records
  - OfferVerifiableCredential from interfaces/claims
- Add null safety check for dbResult in retrieveAccountCount
- Initialize result variable to prevent undefined access

This change fixes TypeScript errors by ensuring types are imported from their
proper source files and improves code safety by adding proper null checks.
The type system can now correctly validate the usage of these types across
the codebase.
2025-05-28 08:56:54 +00:00
Matthew Raymer
bc274bdf7f fix(types): improve account type safety and metadata handling
- Change retrieveAllAccountsMetadata to return Account[] instead of AccountEncrypted[]
  to better reflect its purpose of returning non-sensitive metadata
- Update ImportDerivedAccountView to use Account type and group by derivation path
- Update retrieveAllFullyDecryptedAccounts to use AccountEncrypted type for encrypted data
- Fix import path for Account type in ImportDerivedAccountView

This change improves type safety by making it explicit which functions handle
encrypted data vs metadata, and ensures consistent handling of account data
across the application. The metadata functions now correctly strip sensitive
fields while functions that need encrypted data maintain access to those fields.
2025-05-28 08:52:09 +00:00
Matthew Raymer
082f8c0126 feat(QRScanner): implement QRScannerOptions in WebInlineQRScanner
- Add proper handling of QRScannerOptions in startScan method
- Implement camera selection (front/back) via options.camera
- Add video preview toggle via options.showPreview
- Store options as class property for persistence
- Improve logging with options context
- Fix TypeScript error for unused options parameter

This change makes the QR scanner more configurable and properly
implements the QRScannerService interface contract.
2025-05-28 08:43:43 +00:00
Matthew Raymer
fd09c7e426 fix(deepLinks): improve route validation and type safety
- Add early validation for route paths to prevent undefined access
- Introduce INVALID_ROUTE error type with detailed error information
- Simplify parameter mapping using nullish coalescing operator
- Improve type safety in parseDeepLink method
- Add better error details for invalid route paths

This change prevents potential runtime errors from undefined route access
and provides clearer error messages for invalid deep links.
2025-05-28 08:40:49 +00:00
Matthew Raymer
be40643379 fix: unused element 2025-05-28 08:38:10 +00:00
Matthew Raymer
835a270e65 feat(qr-scanner): Add camera state management to CapacitorQRScanner
- Add camera state tracking and listener management
- Implement addCameraStateListener and removeCameraStateListener methods
- Add state transitions during scanning operations
- Improve error handling with state updates
- Add proper type imports for CameraState and CameraStateListener

This change ensures CapacitorQRScanner fully implements the QRScannerService
interface and provides proper camera state feedback to consumers. Camera state
is now tracked through the entire lifecycle of scanning operations, with
appropriate state transitions for initialization, active scanning, errors,
and cleanup.
2025-05-28 08:37:02 +00:00
Matthew Raymer
13682a1930 fix(db): add type declarations for SQL.js and absurd-sql modules
- Create type declarations in interfaces/absurd-sql.d.ts
- Import and use proper QueryExecResult and SqlValue types
- Add declarations for all required modules:
  - @jlongster/sql.js
  - absurd-sql
  - absurd-sql/dist/indexeddb-backend
  - absurd-sql/dist/indexeddb-main-thread
- Ensure type safety for database operations

This change resolves TypeScript errors about missing type declarations
while maintaining proper type safety for database operations. The
declarations are placed in the interfaces folder to match the project's
type organization.
2025-05-28 06:21:20 +00:00
Matthew Raymer
669a66c24c fix(db): improve type safety in AbsurdSqlDatabaseService
- Move external module type declarations to dedicated .d.ts file
- Make SQL.js types compatible with our database interface
- Fix type compatibility between operation queue and database results
- Add proper typing for database operations and results

This change improves type safety by:
1. Properly declaring types for external modules
2. Ensuring database operation results match our interface
3. Making the operation queue type-safe with generics
4. Removing duplicate type definitions

The remaining module resolution warnings can be safely ignored as they
don't affect runtime behavior and our type declarations are working.
2025-05-28 06:07:50 +00:00
Matthew Raymer
13505b539e fix(db): resolve type compatibility in AbsurdSqlDatabaseService
- Make QueuedOperation interface generic to properly handle operation types
- Fix type compatibility between queue operations and their resolvers
- Use explicit typing for operation queue to handle generic types
- Maintain type safety while allowing different operation return types

This fixes a TypeScript error where the operation queue's resolve function
was not properly typed to handle generic return types from database operations.
2025-05-28 06:02:55 +00:00
Matthew Raymer
07ac340733 feat(capacitor): implement storage permission checks for file operations
- Add permission checks before writeFile and writeAndShareFile operations
- Reuse existing checkStoragePermissions method for Android devices
- Maintain iOS-specific handling (early return for iOS permission model)
- Improve error handling and logging for permission-related issues

This change ensures proper storage permission handling on Android devices
while maintaining the existing iOS behavior. The permission checks run
before any file write operations, providing better error handling and
user experience.
2025-05-28 05:56:58 +00:00
Matthew Raymer
ba2b2fc543 fix: placeholder for PyWebViewPlatformService writeAndShareFile 2025-05-28 05:06:31 +00:00
21184e7625 fix spelling of SQLite module for iOS 2025-05-27 21:11:07 -06:00
8d1511e38f convert all remaining DB writes & reads to SQL (with successful registration & claim) 2025-05-27 21:07:24 -06:00
Matthew Raymer
b18112b869 WIP: disabling absurd-sql when using Capacitor SQLite 2025-05-27 13:15:41 +00:00
Matthew Raymer
a228a9b1c0 fix: add requirements for capacitor/sqlite 2025-05-27 12:42:27 +00:00
Matthew Raymer
1560ff0829 feature: fleshed out capacitor and electron database operators 2025-05-27 11:23:52 +00:00
7de4125eb7 add SQL DB access to everywhere we are using the DB, up to the "C" files 2025-05-27 01:27:04 -06:00
Matthew Raymer
81d4f0c762 fix: resolve PWA build issues with SQL.js worker files
- Update worker format to ESM in Vite config to fix IIFE format error

- Increase PWA precache file size limit to 10MB to accommodate SQL.js files

- Fix type declarations for worker configuration

- Add proper type annotations for Vite config

- Add type declarations for absurd-sql module
2025-05-27 06:54:29 +00:00
4c1b4fe651 fix linting 2025-05-26 22:56:00 -06:00
Matthew Raymer
e63541ef53 fix: update ESLint and VS Code settings
- Configure ESLint to ignore node_modules
- Add VS Code settings for Java diagnostics

This fixes the Android build issues and improves the development
environment by properly ignoring node_modules in linting and
diagnostics.
2025-05-27 04:43:52 +00:00
0bfc18c385 add encryption & decryption for the sensitive identity & mnemonic in SQL DB 2025-05-26 22:42:20 -06:00
Matthew Raymer
35f5df6b6b fix: move lexical declarations outside case blocks
fix: missing logger import
Move variable declarations outside switch statement in AbsurdSqlDatabaseService
to fix 'no-case-declarations' linter errors.
2025-05-27 03:21:55 +00:00
Matthew Raymer
0f1ac2b230 fix: move lexical declarations outside case blocks in AbsurdSqlDatabaseService
- Move queryResult and allResult declarations outside switch statement
- Change const declarations to let since they're now in outer scope
- Remove const declarations from inside case blocks

This fixes the 'no-case-declarations' linter errors by ensuring variables
are declared in a scope that encompasses all case blocks, preventing
potential scoping issues.

Note: Type definition errors for external modules remain and should be
addressed separately.
2025-05-27 03:14:02 +00:00
3c0bdeaed3 remove debugging console statements 2025-05-26 20:43:06 -06:00
11f2527b04 start adding the SQL approach to files, also using the Dexie approach if desired 2025-05-26 20:26:28 -06:00
5d8175aeeb add encryption for the two SQL columns, replace basic DB utils, add USE_DEXIE_DB flag, and start adding SQL everywhere 2025-05-26 19:03:20 -06:00
b6b95cb0d0 remove unused redirect to start page (now that we're creating an ID up front) 2025-05-26 16:29:10 -06:00
655c5188a4 add queueing to the DB service so that requests get processed in order 2025-05-26 16:28:33 -06:00
8b7451330f remove possibility of failing a migration script and then succeeding on later ones 2025-05-26 15:50:37 -06:00
b8fbc3f7a6 fix console error about "window" unavailable due to service worker 2025-05-26 15:49:44 -06:00
92dadba1cb rename the absurd-sql-specific items for clarity 2025-05-26 14:52:39 -06:00
3a6f585de0 adjust so DB calls go to the factory 2025-05-26 13:59:34 -06:00
169 changed files with 19531 additions and 3955 deletions

View File

@@ -9,4 +9,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
# Using shared server by default to ease setup, which works for shared test users.
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
VITE_PASSKEYS_ENABLED=true

View File

@@ -1,6 +0,0 @@
# Admin DID credentials
ADMIN_DID=did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F
ADMIN_PRIVATE_KEY=2b6472c026ec2aa2c4235c994a63868fc9212d18b58f6cbfe861b52e71330f5b
# API Configuration
ENDORSER_API_URL=https://test-api.endorser.ch/api/v2/claim

View File

@@ -9,3 +9,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app

View File

@@ -9,4 +9,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
VITE_PASSKEYS_ENABLED=true

View File

@@ -4,6 +4,12 @@ module.exports = {
node: true,
es2022: true,
},
ignorePatterns: [
'node_modules/',
'dist/',
'dist-electron/',
'*.d.ts'
],
extends: [
"plugin:vue/vue3-recommended",
"eslint:recommended",

View File

@@ -84,7 +84,7 @@ Install dependencies:
* For test, build the app (because test server is not yet set up to build):
```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
```
... and transfer to the test server:

View File

@@ -91,6 +91,8 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation project(':capacitor-community-sqlite')
implementation "androidx.biometric:biometric:1.2.0-alpha05"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-mlkit-barcode-scanning')
implementation project(':capacitor-app')
implementation project(':capacitor-camera')

View File

@@ -16,6 +16,41 @@
}
]
}
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
}
},
"ios": {
"contentInset": "always",
"allowsLinkPreview": true,
"scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true,
"backgroundColor": "#ffffff",
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
},
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
}
}

View File

@@ -1,4 +1,8 @@
[
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{
"pkg": "@capacitor-mlkit/barcode-scanning",
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"

View File

@@ -1,7 +1,15 @@
package app.timesafari;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.community.sqlite.SQLite;
public class MainActivity extends BridgeActivity {
// ... existing code ...
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Initialize SQLite
registerPlugin(SQLite.class);
}
}

View File

@@ -2,6 +2,9 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-mlkit-barcode-scanning'
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')

View File

@@ -1,10 +1,11 @@
{
"appId": "app.timesafari",
"appId": "com.timesafari.app",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"cleartext": true
"cleartext": true,
"androidScheme": "https"
},
"plugins": {
"App": {
@@ -16,6 +17,47 @@
}
]
}
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
},
"CapacitorSQLite": {
"electronIsEncryption": false,
"electronMacLocation": "~/Library/Application Support/TimeSafari",
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari",
"electronLinuxLocation": "~/.local/share/TimeSafari"
}
},
"ios": {
"contentInset": "always",
"allowsLinkPreview": true,
"scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true,
"backgroundColor": "#ffffff",
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
},
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
}
}

270
doc/electron-migration.md Normal file
View File

@@ -0,0 +1,270 @@
# Electron App Migration Strategy
## Overview
This document outlines the migration strategy for the TimeSafari Electron app, focusing on the transition from web-based storage to native SQLite implementation while maintaining cross-platform compatibility.
## Current Architecture
### 1. Platform Services
- `ElectronPlatformService`: Implements platform-specific features for desktop
- Uses `@capacitor-community/sqlite` for database operations
- Maintains compatibility with web/mobile platforms through shared interfaces
### 2. Database Implementation
- SQLite with native Node.js backend
- WAL journal mode for better concurrency
- Connection pooling for performance
- Migration system for schema updates
- Secure file permissions (0o755)
### 3. Build Process
```bash
# Development
npm run dev:electron
# Production Build
npm run build:web
npm run build:electron
npm run electron:build-linux # or electron:build-mac
```
## Migration Goals
1. **Data Integrity**
- Preserve existing data during migration
- Maintain data relationships
- Ensure ACID compliance
- Implement proper backup/restore
2. **Performance**
- Optimize SQLite configuration
- Implement connection pooling
- Use WAL journal mode
- Configure optimal PRAGMA settings
3. **Security**
- Secure file permissions
- Proper IPC communication
- Context isolation
- Safe preload scripts
4. **User Experience**
- Zero data loss
- Automatic migration
- Progress indicators
- Error recovery
## Implementation Details
### 1. Database Initialization
```typescript
// electron/src/rt/sqlite-init.ts
export async function initializeSQLite() {
// Set up database path with proper permissions
const dbPath = path.join(app.getPath('userData'), 'timesafari.db');
// Initialize SQLite plugin
const sqlite = new CapacitorSQLite();
// Configure database
await sqlite.createConnection({
database: 'timesafari',
path: dbPath,
encrypted: false,
mode: 'no-encryption'
});
// Set optimal PRAGMA settings
await sqlite.execute({
database: 'timesafari',
statements: [
'PRAGMA journal_mode = WAL;',
'PRAGMA synchronous = NORMAL;',
'PRAGMA foreign_keys = ON;'
]
});
}
```
### 2. Migration System
```typescript
// electron/src/rt/sqlite-migrations.ts
interface Migration {
version: number;
name: string;
description: string;
sql: string;
rollback?: string;
}
async function runMigrations(plugin: any, database: string) {
// Track migration state
const state = await getMigrationState(plugin, database);
// Execute migrations in transaction
for (const migration of pendingMigrations) {
await executeMigration(plugin, database, migration);
}
}
```
### 3. Platform Service Implementation
```typescript
// src/services/platforms/ElectronPlatformService.ts
export class ElectronPlatformService implements PlatformService {
private sqlite: any;
async dbQuery(sql: string, params: any[]): Promise<QueryExecResult> {
return await this.sqlite.execute({
database: 'timesafari',
statements: [{ statement: sql, values: params }]
});
}
}
```
### 4. Preload Script
```typescript
// electron/preload.ts
contextBridge.exposeInMainWorld('electron', {
sqlite: {
isAvailable: () => ipcRenderer.invoke('sqlite:isAvailable'),
execute: (method: string, ...args: unknown[]) =>
ipcRenderer.invoke('sqlite:execute', method, ...args)
},
getPath: (pathType: string) => ipcRenderer.invoke('get-path', pathType),
env: {
platform: 'electron'
}
});
```
## Build Configuration
### 1. Vite Configuration
```typescript
// vite.config.app.electron.mts
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true
},
define: {
'process.env.VITE_PLATFORM': JSON.stringify('electron'),
'process.env.VITE_PWA_ENABLED': JSON.stringify(false)
}
});
```
### 2. Package Scripts
```json
{
"scripts": {
"dev:electron": "vite build --watch --config vite.config.app.electron.mts",
"build:electron": "vite build --config vite.config.app.electron.mts",
"electron:build-linux": "electron-builder --linux",
"electron:build-mac": "electron-builder --mac"
}
}
```
## Testing Strategy
1. **Unit Tests**
- Database operations
- Migration system
- Platform service methods
- IPC communication
2. **Integration Tests**
- Full migration process
- Data integrity verification
- Cross-platform compatibility
- Error recovery
3. **End-to-End Tests**
- User workflows
- Data persistence
- UI interactions
- Platform-specific features
## Error Handling
1. **Database Errors**
- Connection failures
- Migration errors
- Query execution errors
- Transaction failures
2. **Platform Errors**
- File system errors
- IPC communication errors
- Permission issues
- Resource constraints
3. **Recovery Mechanisms**
- Automatic retry logic
- Transaction rollback
- State verification
- User notifications
## Security Considerations
1. **File System**
- Secure file permissions
- Path validation
- Access control
- Data encryption
2. **IPC Communication**
- Context isolation
- Channel validation
- Data sanitization
- Error handling
3. **Preload Scripts**
- Minimal API exposure
- Type safety
- Input validation
- Error boundaries
## Future Improvements
1. **Performance**
- Query optimization
- Index tuning
- Connection management
- Cache implementation
2. **Features**
- Offline support
- Sync capabilities
- Backup/restore
- Data export/import
3. **Security**
- Database encryption
- Secure storage
- Access control
- Audit logging
## Maintenance
1. **Regular Tasks**
- Database optimization
- Log rotation
- Error monitoring
- Performance tracking
2. **Updates**
- Dependency updates
- Security patches
- Feature additions
- Bug fixes
3. **Documentation**
- API documentation
- Migration guides
- Troubleshooting
- Best practices

File diff suppressed because it is too large Load Diff

View File

@@ -3,45 +3,46 @@
## Core Services
### 1. Storage Service Layer
- [ ] Create base `StorageService` interface
- [ ] Define common methods for all platforms
- [ ] Add platform-specific method signatures
- [ ] Include error handling types
- [ ] Add migration support methods
- [x] Create base `PlatformService` interface
- [x] Define common methods for all platforms
- [x] Add platform-specific method signatures
- [x] Include error handling types
- [x] Add migration support methods
- [ ] Implement platform-specific services
- [ ] `WebSQLiteService` (absurd-sql)
- [ ] Database initialization
- [ ] VFS setup with IndexedDB backend
- [ ] Connection management
- [ ] Query builder
- [ ] `NativeSQLiteService` (iOS/Android)
- [x] Implement platform-specific services
- [x] `AbsurdSqlDatabaseService` (web)
- [x] Database initialization
- [x] VFS setup with IndexedDB backend
- [x] Connection management
- [x] Operation queuing
- [ ] `NativeSQLiteService` (iOS/Android) (planned)
- [ ] SQLCipher integration
- [ ] Native bridge setup
- [ ] File system access
- [ ] `ElectronSQLiteService`
- [ ] `ElectronSQLiteService` (planned)
- [ ] Node SQLite integration
- [ ] IPC communication
- [ ] File system access
### 2. Migration Services
- [ ] Implement `MigrationService`
- [ ] Backup creation
- [ ] Data verification
- [ ] Rollback procedures
- [ ] Progress tracking
- [ ] Create `MigrationUI` components
- [x] Implement basic migration support
- [x] Dual-storage pattern (SQLite + Dexie)
- [x] Basic data verification
- [ ] Rollback procedures (planned)
- [ ] Progress tracking (planned)
- [ ] Create `MigrationUI` components (planned)
- [ ] Progress indicators
- [ ] Error handling
- [ ] User notifications
- [ ] Manual triggers
### 3. Security Layer
- [ ] Implement `EncryptionService`
- [x] Basic data integrity
- [ ] Implement `EncryptionService` (planned)
- [ ] Key management
- [ ] Encryption/decryption
- [ ] Secure storage
- [ ] Add `BiometricService`
- [ ] Add `BiometricService` (planned)
- [ ] Platform detection
- [ ] Authentication flow
- [ ] Fallback mechanisms
@@ -49,18 +50,19 @@
## Platform-Specific Implementation
### Web Platform
- [ ] Setup absurd-sql
- [ ] Install dependencies
- [x] Setup absurd-sql
- [x] Install dependencies
```json
{
"@jlongster/sql.js": "^1.8.0",
"absurd-sql": "^1.8.0"
}
```
- [ ] Configure VFS with IndexedDB backend
- [ ] Setup worker threads
- [ ] Implement connection pooling
- [ ] Configure database pragmas
- [x] Configure VFS with IndexedDB backend
- [x] Setup worker threads
- [x] Implement operation queuing
- [x] Configure database pragmas
```sql
PRAGMA journal_mode=MEMORY;
PRAGMA synchronous=NORMAL;
@@ -68,19 +70,19 @@
PRAGMA busy_timeout=5000;
```
- [ ] Update build configuration
- [ ] Modify `vite.config.ts`
- [ ] Add worker configuration
- [ ] Update chunk splitting
- [ ] Configure asset handling
- [x] Update build configuration
- [x] Modify `vite.config.ts`
- [x] Add worker configuration
- [x] Update chunk splitting
- [x] Configure asset handling
- [ ] Implement IndexedDB fallback
- [ ] Create fallback service
- [ ] Add data synchronization
- [ ] Handle quota exceeded
- [ ] Implement atomic operations
- [x] Implement IndexedDB backend
- [x] Create database service
- [x] Add operation queuing
- [x] Handle initialization
- [x] Implement atomic operations
### iOS Platform
### iOS Platform (Planned)
- [ ] Setup SQLCipher
- [ ] Install pod dependencies
- [ ] Configure encryption
@@ -93,7 +95,7 @@
- [ ] Configure backup
- [ ] Setup app groups
### Android Platform
### Android Platform (Planned)
- [ ] Setup SQLCipher
- [ ] Add Gradle dependencies
- [ ] Configure encryption
@@ -106,7 +108,7 @@
- [ ] Configure backup
- [ ] Setup file provider
### Electron Platform
### Electron Platform (Planned)
- [ ] Setup Node SQLite
- [ ] Install dependencies
- [ ] Configure IPC
@@ -122,7 +124,8 @@
## Data Models and Types
### 1. Database Schema
- [ ] Define tables
- [x] Define tables
```sql
-- Accounts table
CREATE TABLE accounts (
@@ -155,13 +158,14 @@
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
```
- [ ] Create indexes
- [ ] Define constraints
- [ ] Add triggers
- [ ] Setup migrations
- [x] Create indexes
- [x] Define constraints
- [ ] Add triggers (planned)
- [ ] Setup migrations (planned)
### 2. Type Definitions
- [ ] Create interfaces
- [x] Create interfaces
```typescript
interface Account {
did: string;
@@ -185,28 +189,28 @@
}
```
- [ ] Add validation
- [ ] Create DTOs
- [ ] Define enums
- [ ] Add type guards
- [x] Add validation
- [x] Create DTOs
- [x] Define enums
- [x] Add type guards
## UI Components
### 1. Migration UI
### 1. Migration UI (Planned)
- [ ] Create components
- [ ] `MigrationProgress.vue`
- [ ] `MigrationError.vue`
- [ ] `MigrationSettings.vue`
- [ ] `MigrationStatus.vue`
### 2. Settings UI
### 2. Settings UI (Planned)
- [ ] Update components
- [ ] Add storage settings
- [ ] Add migration controls
- [ ] Add backup options
- [ ] Add security settings
### 3. Error Handling UI
### 3. Error Handling UI (Planned)
- [ ] Create components
- [ ] `StorageError.vue`
- [ ] `QuotaExceeded.vue`
@@ -216,20 +220,20 @@
## Testing
### 1. Unit Tests
- [ ] Test services
- [ ] Storage service tests
- [ ] Migration service tests
- [ ] Security service tests
- [ ] Platform detection tests
- [x] Basic service tests
- [x] Platform service tests
- [x] Database operation tests
- [ ] Security service tests (planned)
- [ ] Platform detection tests (planned)
### 2. Integration Tests
### 2. Integration Tests (Planned)
- [ ] Test migrations
- [ ] Web platform tests
- [ ] iOS platform tests
- [ ] Android platform tests
- [ ] Electron platform tests
### 3. E2E Tests
### 3. E2E Tests (Planned)
- [ ] Test workflows
- [ ] Account management
- [ ] Settings management
@@ -239,12 +243,12 @@
## Documentation
### 1. Technical Documentation
- [ ] Update architecture docs
- [ ] Add API documentation
- [ ] Create migration guides
- [ ] Document security measures
- [x] Update architecture docs
- [x] Add API documentation
- [ ] Create migration guides (planned)
- [ ] Document security measures (planned)
### 2. User Documentation
### 2. User Documentation (Planned)
- [ ] Update user guides
- [ ] Add troubleshooting guides
- [ ] Create FAQ
@@ -253,18 +257,18 @@
## Deployment
### 1. Build Process
- [ ] Update build scripts
- [ ] Add platform-specific builds
- [ ] Configure CI/CD
- [ ] Setup automated testing
- [x] Update build scripts
- [x] Add platform-specific builds
- [ ] Configure CI/CD (planned)
- [ ] Setup automated testing (planned)
### 2. Release Process
### 2. Release Process (Planned)
- [ ] Create release checklist
- [ ] Add version management
- [ ] Setup rollback procedures
- [ ] Configure monitoring
## Monitoring and Analytics
## Monitoring and Analytics (Planned)
### 1. Error Tracking
- [ ] Setup error logging
@@ -278,7 +282,7 @@
- [ ] Monitor performance
- [ ] Collect user feedback
## Security Audit
## Security Audit (Planned)
### 1. Code Review
- [ ] Review encryption
@@ -295,29 +299,31 @@
## Success Criteria
### 1. Performance
- [ ] Query response time < 100ms
- [ ] Migration time < 5s per 1000 records
- [ ] Storage overhead < 10%
- [ ] Memory usage < 50MB
- [ ] Atomic operations complete successfully
- [ ] Transaction performance meets requirements
- [x] Query response time < 100ms
- [x] Operation queuing for thread safety
- [x] Proper initialization handling
- [ ] Migration time < 5s per 1000 records (planned)
- [ ] Storage overhead < 10% (planned)
- [ ] Memory usage < 50MB (planned)
### 2. Reliability
- [ ] 99.9% uptime
- [ ] Zero data loss
- [ ] Automatic recovery
- [ ] Backup verification
- [ ] Transaction atomicity
- [ ] Data consistency
- [x] Basic data integrity
- [x] Operation queuing
- [ ] Automatic recovery (planned)
- [ ] Backup verification (planned)
- [ ] Transaction atomicity (planned)
- [ ] Data consistency (planned)
### 3. Security
- [ ] AES-256 encryption
- [ ] Secure key storage
- [ ] Access control
- [ ] Audit logging
- [x] Basic data integrity
- [ ] AES-256 encryption (planned)
- [ ] Secure key storage (planned)
- [ ] Access control (planned)
- [ ] Audit logging (planned)
### 4. User Experience
- [ ] Smooth migration
- [ ] Clear error messages
- [ ] Progress indicators
- [ ] Recovery options
- [x] Basic database operations
- [ ] Smooth migration (planned)
- [ ] Clear error messages (planned)
- [ ] Progress indicators (planned)
- [ ] Recovery options (planned)

55
electron/.gitignore vendored Normal file
View File

@@ -0,0 +1,55 @@
# NPM renames .gitignore to .npmignore
# In order to prevent that, we remove the initial "."
# And the CLI then renames it
app
node_modules
build
dist
logs
# Node.js dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Capacitor build outputs
web/
ios/
android/
electron/app/
# Capacitor SQLite plugin data (important!)
capacitor-sqlite/
# TypeScript / build output
dist/
build/
*.log
# Development / IDE files
.env.local
.env.development.local
.env.test.local
.env.production.local
# VS Code
.vscode/
!.vscode/extensions.json
# JetBrains IDEs (IntelliJ, WebStorm, etc.)
.idea/
*.iml
*.iws
# macOS specific
.DS_Store
*.swp
*~
*.tmp
# Windows specific
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

BIN
electron/assets/appIcon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
electron/assets/appIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
electron/assets/splash.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
electron/assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,62 @@
{
"appId": "com.timesafari.app",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"cleartext": true,
"androidScheme": "https"
},
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
},
"CapacitorSQLite": {
"electronIsEncryption": false,
"electronMacLocation": "~/Library/Application Support/TimeSafari",
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari"
}
},
"ios": {
"contentInset": "always",
"allowsLinkPreview": true,
"scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true,
"backgroundColor": "#ffffff",
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
},
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
}
}

View File

@@ -0,0 +1,28 @@
{
"appId": "com.yourdoamnin.yourapp",
"directories": {
"buildResources": "resources"
},
"files": [
"assets/**/*",
"build/**/*",
"capacitor.config.*",
"app/**/*"
],
"publish": {
"provider": "github"
},
"nsis": {
"allowElevation": true,
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"win": {
"target": "nsis",
"icon": "assets/appIcon.ico"
},
"mac": {
"category": "your.app.category.type",
"target": "dmg"
}
}

75
electron/live-runner.js Normal file
View File

@@ -0,0 +1,75 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const cp = require('child_process');
const chokidar = require('chokidar');
const electron = require('electron');
let child = null;
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const reloadWatcher = {
debouncer: null,
ready: false,
watcher: null,
restarting: false,
};
///*
function runBuild() {
return new Promise((resolve, _reject) => {
let tempChild = cp.spawn(npmCmd, ['run', 'build']);
tempChild.once('exit', () => {
resolve();
});
tempChild.stdout.pipe(process.stdout);
});
}
//*/
async function spawnElectron() {
if (child !== null) {
child.stdin.pause();
child.kill();
child = null;
await runBuild();
}
child = cp.spawn(electron, ['--inspect=5858', './']);
child.on('exit', () => {
if (!reloadWatcher.restarting) {
process.exit(0);
}
});
child.stdout.pipe(process.stdout);
}
function setupReloadWatcher() {
reloadWatcher.watcher = chokidar
.watch('./src/**/*', {
ignored: /[/\\]\./,
persistent: true,
})
.on('ready', () => {
reloadWatcher.ready = true;
})
.on('all', (_event, _path) => {
if (reloadWatcher.ready) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = setTimeout(async () => {
console.log('Restarting');
reloadWatcher.restarting = true;
await spawnElectron();
reloadWatcher.restarting = false;
reloadWatcher.ready = false;
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
reloadWatcher.watcher = null;
setupReloadWatcher();
}, 500);
}
});
}
(async () => {
await runBuild();
await spawnElectron();
setupReloadWatcher();
})();

5460
electron/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
electron/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "TimeSafari",
"version": "1.0.0",
"description": "TimeSafari Electron App",
"author": {
"name": "",
"email": ""
},
"repository": {
"type": "git",
"url": ""
},
"license": "MIT",
"main": "build/src/index.js",
"scripts": {
"build": "tsc && electron-rebuild",
"electron:start-live": "node ./live-runner.js",
"electron:start": "npm run build && electron --inspect=5858 ./",
"electron:pack": "npm run build && electron-builder build --dir -c ./electron-builder.config.json",
"electron:make": "npm run build && electron-builder build -c ./electron-builder.config.json -p always"
},
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
"@capacitor-community/sqlite": "^6.0.2",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"chokidar": "~3.5.3",
"crypto": "^1.0.1",
"crypto-js": "^4.2.0",
"electron-is-dev": "~2.0.0",
"electron-json-storage": "^4.6.0",
"electron-serve": "~1.1.0",
"electron-unhandled": "~4.0.1",
"electron-updater": "^5.3.0",
"electron-window-state": "^5.0.3",
"jszip": "^3.10.1",
"node-fetch": "^2.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/crypto-js": "^4.2.2",
"@types/electron-json-storage": "^4.5.4",
"electron": "^26.2.2",
"electron-builder": "~23.6.0",
"source-map-support": "^0.5.21",
"typescript": "^5.0.4"
},
"keywords": [
"capacitor",
"electron"
]
}

View File

@@ -0,0 +1,10 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const electronPublish = require('electron-publish');
class Publisher extends electronPublish.Publisher {
async upload(task) {
console.log('electron-publisher-custom', task.file);
}
}
module.exports = Publisher;

140
electron/src/index.ts Normal file
View File

@@ -0,0 +1,140 @@
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
import { getCapacitorElectronConfig, setupElectronDeepLinking } from '@capacitor-community/electron';
import type { MenuItemConstructorOptions } from 'electron';
import { app, MenuItem } from 'electron';
import electronIsDev from 'electron-is-dev';
import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater';
import { ElectronCapacitorApp, setupContentSecurityPolicy, setupReloadWatcher } from './setup';
import { initializeSQLite, setupSQLiteHandlers } from './rt/sqlite-init';
// Graceful handling of unhandled errors.
unhandled();
// Define our menu templates (these are optional)
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'viewMenu' },
];
// Get Config options from capacitor.config
const capacitorFileConfig: CapacitorElectronConfig = getCapacitorElectronConfig();
// Initialize our app. You can pass menu templates into the app here.
const myCapacitorApp = new ElectronCapacitorApp(capacitorFileConfig, trayMenuTemplate, appMenuBarMenuTemplate);
// If deeplinking is enabled then we will set it up here.
if (capacitorFileConfig.electron?.deepLinkingEnabled) {
setupElectronDeepLinking(myCapacitorApp, {
customProtocol: capacitorFileConfig.electron.deepLinkingCustomProtocol ?? 'mycapacitorapp',
});
}
// If we are in Dev mode, use the file watcher components.
if (electronIsDev) {
setupReloadWatcher(myCapacitorApp);
}
// Run Application
(async () => {
try {
// Wait for electron app to be ready first
await app.whenReady();
console.log('[Electron Main Process] App is ready');
// Initialize SQLite plugin and handlers BEFORE creating any windows
console.log('[Electron Main Process] Initializing SQLite...');
setupSQLiteHandlers();
await initializeSQLite();
console.log('[Electron Main Process] SQLite initialization complete');
// Security - Set Content-Security-Policy
setupContentSecurityPolicy(myCapacitorApp.getCustomURLScheme());
// Initialize our app and create window
console.log('[Electron Main Process] Starting app initialization...');
await myCapacitorApp.init();
console.log('[Electron Main Process] App initialization complete');
// Get the main window
const mainWindow = myCapacitorApp.getMainWindow();
if (!mainWindow) {
throw new Error('Main window not available after app initialization');
}
// Wait for window to be ready and loaded
await new Promise<void>((resolve) => {
const handleReady = () => {
console.log('[Electron Main Process] Window ready to show');
mainWindow.show();
// Wait for window to finish loading
mainWindow.webContents.once('did-finish-load', () => {
console.log('[Electron Main Process] Window finished loading');
// Send SQLite ready signal after window is fully loaded
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('sqlite-ready');
console.log('[Electron Main Process] Sent SQLite ready signal to renderer');
} else {
console.warn('[Electron Main Process] Window was destroyed before sending SQLite ready signal');
}
resolve();
});
};
// Always use the event since isReadyToShow is not reliable
mainWindow.once('ready-to-show', handleReady);
});
// Check for updates if we are in a packaged app
if (!electronIsDev) {
console.log('[Electron Main Process] Checking for updates...');
autoUpdater.checkForUpdatesAndNotify();
}
// Handle window close
mainWindow.on('closed', () => {
console.log('[Electron Main Process] Main window closed');
});
// Handle window close request
mainWindow.on('close', (event) => {
console.log('[Electron Main Process] Window close requested');
if (mainWindow.webContents.isLoading()) {
event.preventDefault();
console.log('[Electron Main Process] Deferring window close due to loading state');
mainWindow.webContents.once('did-finish-load', () => {
mainWindow.close();
});
}
});
} catch (error) {
console.error('[Electron Main Process] Fatal error during initialization:', error);
app.quit();
}
})();
// Handle when all of our windows are close (platforms have their own expectations).
app.on('window-all-closed', function () {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit();
}
});
// When the dock icon is clicked.
app.on('activate', async function () {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (myCapacitorApp.getMainWindow().isDestroyed()) {
await myCapacitorApp.init();
}
});
// Place all ipc or other electron api calls and custom functionality under this line

303
electron/src/preload.ts Normal file
View File

@@ -0,0 +1,303 @@
/**
* Preload script for Electron
* Sets up secure IPC communication between renderer and main process
*
* @author Matthew Raymer
*/
import { contextBridge, ipcRenderer } from 'electron';
// Enhanced logger for preload script that forwards to main process
const logger = {
log: (...args: unknown[]) => {
console.log('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'log', args });
},
error: (...args: unknown[]) => {
console.error('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'error', args });
},
info: (...args: unknown[]) => {
console.info('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'info', args });
},
warn: (...args: unknown[]) => {
console.warn('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'warn', args });
},
debug: (...args: unknown[]) => {
console.debug('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'debug', args });
},
sqlite: {
log: (operation: string, ...args: unknown[]) => {
const message = ['[Preload][SQLite]', operation, ...args];
console.log(...message);
ipcRenderer.send('renderer-log', {
level: 'log',
args: message,
source: 'sqlite',
operation
});
},
error: (operation: string, error: unknown) => {
const message = ['[Preload][SQLite]', operation, 'failed:', error];
console.error(...message);
ipcRenderer.send('renderer-log', {
level: 'error',
args: message,
source: 'sqlite',
operation,
error: error instanceof Error ? {
name: error.name,
message: error.message,
stack: error.stack
} : error
});
},
debug: (operation: string, ...args: unknown[]) => {
const message = ['[Preload][SQLite]', operation, ...args];
console.debug(...message);
ipcRenderer.send('renderer-log', {
level: 'debug',
args: message,
source: 'sqlite',
operation
});
}
}
};
// Types for SQLite connection options
interface SQLiteConnectionOptions {
database: string;
version?: number;
readOnly?: boolean;
readonly?: boolean; // Handle both cases
encryption?: string;
mode?: string;
useNative?: boolean;
[key: string]: unknown; // Allow other properties
}
// Define valid channels for security
const VALID_CHANNELS = {
send: ['toMain'] as const,
receive: ['fromMain', 'sqlite-ready', 'database-status'] as const,
invoke: [
'sqlite-is-available',
'sqlite-echo',
'sqlite-create-connection',
'sqlite-execute',
'sqlite-query',
'sqlite-run',
'sqlite-close-connection',
'sqlite-open',
'sqlite-close',
'sqlite-is-db-open',
'sqlite-status',
'get-path',
'get-base-path'
] as const
};
type ValidSendChannel = typeof VALID_CHANNELS.send[number];
type ValidReceiveChannel = typeof VALID_CHANNELS.receive[number];
type ValidInvokeChannel = typeof VALID_CHANNELS.invoke[number];
// Create a secure IPC bridge
const createSecureIPCBridge = () => {
return {
send: (channel: string, data: unknown) => {
if (VALID_CHANNELS.send.includes(channel as ValidSendChannel)) {
logger.debug('IPC Send:', channel, data);
ipcRenderer.send(channel, data);
} else {
logger.warn(`[Preload] Attempted to send on invalid channel: ${channel}`);
}
},
receive: (channel: string, func: (...args: unknown[]) => void) => {
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
logger.debug('IPC Receive:', channel);
ipcRenderer.on(channel, (_event, ...args) => {
logger.debug('IPC Received:', channel, args);
func(...args);
});
} else {
logger.warn(`[Preload] Attempted to receive on invalid channel: ${channel}`);
}
},
once: (channel: string, func: (...args: unknown[]) => void) => {
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
logger.debug('IPC Once:', channel);
ipcRenderer.once(channel, (_event, ...args) => {
logger.debug('IPC Received Once:', channel, args);
func(...args);
});
} else {
logger.warn(`[Preload] Attempted to receive once on invalid channel: ${channel}`);
}
},
invoke: async (channel: string, ...args: unknown[]) => {
if (VALID_CHANNELS.invoke.includes(channel as ValidInvokeChannel)) {
logger.debug('IPC Invoke:', channel, args);
try {
const result = await ipcRenderer.invoke(channel, ...args);
logger.debug('IPC Invoke Result:', channel, result);
return result;
} catch (error) {
logger.error('IPC Invoke Error:', channel, error);
throw error;
}
} else {
logger.warn(`[Preload] Attempted to invoke on invalid channel: ${channel}`);
throw new Error(`Invalid channel: ${channel}`);
}
}
};
};
// Create SQLite proxy with retry logic
const createSQLiteProxy = () => {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
const withRetry = async <T>(operation: string, ...args: unknown[]): Promise<T> => {
let lastError: Error | undefined;
const operationId = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const startTime = Date.now();
logger.sqlite.debug(operation, 'starting with args:', {
operationId,
args,
timestamp: new Date().toISOString()
});
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
logger.sqlite.debug(operation, `attempt ${attempt}/${MAX_RETRIES}`, {
operationId,
attempt,
args,
timestamp: new Date().toISOString()
});
// Log the exact IPC call
logger.sqlite.debug(operation, 'invoking IPC', {
operationId,
channel: `sqlite-${operation}`,
args,
timestamp: new Date().toISOString()
});
const result = await ipcRenderer.invoke(`sqlite-${operation}`, ...args);
const duration = Date.now() - startTime;
logger.sqlite.log(operation, 'success', {
operationId,
attempt,
result,
duration: `${duration}ms`,
timestamp: new Date().toISOString()
});
return result as T;
} catch (error) {
const duration = Date.now() - startTime;
lastError = error instanceof Error ? error : new Error(String(error));
logger.sqlite.error(operation, {
operationId,
attempt,
error: {
name: lastError.name,
message: lastError.message,
stack: lastError.stack
},
args,
duration: `${duration}ms`,
timestamp: new Date().toISOString()
});
if (attempt < MAX_RETRIES) {
const backoffDelay = RETRY_DELAY * Math.pow(2, attempt - 1);
logger.warn(`[Preload] SQLite ${operation} failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${backoffDelay}ms...`, {
operationId,
error: lastError,
args,
nextAttemptIn: `${backoffDelay}ms`,
timestamp: new Date().toISOString()
});
await new Promise(resolve => setTimeout(resolve, backoffDelay));
}
}
}
const finalError = new Error(
`SQLite ${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message || "Unknown error"}`
);
logger.error('[Preload] SQLite operation failed permanently:', {
operation,
operationId,
error: {
name: finalError.name,
message: finalError.message,
stack: finalError.stack,
originalError: lastError
},
args,
attempts: MAX_RETRIES,
timestamp: new Date().toISOString()
});
throw finalError;
};
return {
isAvailable: () => withRetry('is-available'),
echo: (value: string) => withRetry('echo', { value }),
createConnection: (options: SQLiteConnectionOptions) => withRetry('create-connection', options),
closeConnection: (options: { database: string }) => withRetry('close-connection', options),
query: (options: { statement: string; values?: unknown[] }) => withRetry('query', options),
run: (options: { statement: string; values?: unknown[] }) => withRetry('run', options),
execute: (options: { statements: { statement: string; values?: unknown[] }[] }) => withRetry('execute', options),
getPlatform: () => Promise.resolve('electron')
};
};
try {
// Expose the secure IPC bridge and SQLite proxy
const electronAPI = {
ipcRenderer: createSecureIPCBridge(),
sqlite: createSQLiteProxy(),
env: {
platform: 'electron',
isDev: process.env.NODE_ENV === 'development'
}
};
// Log the exposed API for debugging
logger.debug('Exposing Electron API:', {
hasIpcRenderer: !!electronAPI.ipcRenderer,
hasSqlite: !!electronAPI.sqlite,
sqliteMethods: Object.keys(electronAPI.sqlite),
env: electronAPI.env
});
contextBridge.exposeInMainWorld('electron', electronAPI);
logger.info('[Preload] IPC bridge and SQLite proxy initialized successfully');
} catch (error) {
logger.error('[Preload] Failed to initialize IPC bridge:', error);
}
// Log startup
logger.log('[CapacitorSQLite] Preload script starting...');
// Handle window load
window.addEventListener('load', () => {
logger.log('[CapacitorSQLite] Preload script complete');
});

View File

@@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const CapacitorCommunitySqlite = require('../../../node_modules/@capacitor-community/sqlite/electron/dist/plugin.js');
module.exports = {
CapacitorCommunitySqlite,
}

View File

@@ -0,0 +1,88 @@
import { randomBytes } from 'crypto';
import { ipcRenderer, contextBridge } from 'electron';
import { EventEmitter } from 'events';
////////////////////////////////////////////////////////
// eslint-disable-next-line @typescript-eslint/no-var-requires
const plugins = require('./electron-plugins');
const randomId = (length = 5) => randomBytes(length).toString('hex');
const contextApi: {
[plugin: string]: { [functionName: string]: () => Promise<any> };
} = {};
Object.keys(plugins).forEach((pluginKey) => {
Object.keys(plugins[pluginKey])
.filter((className) => className !== 'default')
.forEach((classKey) => {
const functionList = Object.getOwnPropertyNames(plugins[pluginKey][classKey].prototype).filter(
(v) => v !== 'constructor'
);
if (!contextApi[classKey]) {
contextApi[classKey] = {};
}
functionList.forEach((functionName) => {
if (!contextApi[classKey][functionName]) {
contextApi[classKey][functionName] = (...args) => ipcRenderer.invoke(`${classKey}-${functionName}`, ...args);
}
});
// Events
if (plugins[pluginKey][classKey].prototype instanceof EventEmitter) {
const listeners: { [key: string]: { type: string; listener: (...args: any[]) => void } } = {};
const listenersOfTypeExist = (type) =>
!!Object.values(listeners).find((listenerObj) => listenerObj.type === type);
Object.assign(contextApi[classKey], {
addListener(type: string, callback: (...args) => void) {
const id = randomId();
// Deduplicate events
if (!listenersOfTypeExist(type)) {
ipcRenderer.send(`event-add-${classKey}`, type);
}
const eventHandler = (_, ...args) => callback(...args);
ipcRenderer.addListener(`event-${classKey}-${type}`, eventHandler);
listeners[id] = { type, listener: eventHandler };
return id;
},
removeListener(id: string) {
if (!listeners[id]) {
throw new Error('Invalid id');
}
const { type, listener } = listeners[id];
ipcRenderer.removeListener(`event-${classKey}-${type}`, listener);
delete listeners[id];
if (!listenersOfTypeExist(type)) {
ipcRenderer.send(`event-remove-${classKey}-${type}`);
}
},
removeAllListeners(type: string) {
Object.entries(listeners).forEach(([id, listenerObj]) => {
if (!type || listenerObj.type === type) {
ipcRenderer.removeListener(`event-${classKey}-${listenerObj.type}`, listenerObj.listener);
ipcRenderer.send(`event-remove-${classKey}-${listenerObj.type}`);
delete listeners[id];
}
});
},
});
}
});
});
contextBridge.exposeInMainWorld('CapacitorCustomPlatform', {
name: 'electron',
plugins: contextApi,
});
////////////////////////////////////////////////////////

188
electron/src/rt/logger.ts Normal file
View File

@@ -0,0 +1,188 @@
/**
* Enhanced logging system for TimeSafari Electron
* Provides structured logging with proper levels and formatting
* Supports both console and file output with different verbosity levels
*
* @author Matthew Raymer
*/
import { app, ipcMain } from 'electron';
import winston from 'winston';
import path from 'path';
import os from 'os';
import fs from 'fs';
// Extend Winston Logger type with our custom loggers
declare module 'winston' {
interface Logger {
sqlite: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
migration: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
}
}
// Create logs directory if it doesn't exist
const logsDir = path.join(app.getPath('userData'), 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Custom format for console output with migration filtering
const consoleFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...metadata }) => {
// Skip migration logs unless DEBUG_MIGRATIONS is set
if (level === 'info' &&
typeof message === 'string' &&
message.includes('[Migration]') &&
!process.env.DEBUG_MIGRATIONS) {
return '';
}
let msg = `${timestamp} [${level}] ${message}`;
if (Object.keys(metadata).length > 0) {
msg += ` ${JSON.stringify(metadata, null, 2)}`;
}
return msg;
})
);
// Custom format for file output
const fileFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.json()
);
// Create logger instance
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: fileFormat,
defaultMeta: { service: 'timesafari-electron' },
transports: [
// Console transport with custom format and migration filtering
new winston.transports.Console({
format: consoleFormat,
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
silent: false // Ensure we can still see non-migration logs
}),
// File transport for all logs
new winston.transports.File({
filename: path.join(logsDir, 'error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// File transport for all logs including debug
new winston.transports.File({
filename: path.join(logsDir, 'combined.log'),
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
}) as winston.Logger & {
sqlite: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
migration: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
};
// Add SQLite specific logger
logger.sqlite = {
debug: (message: string, ...args: unknown[]) => {
logger.debug(`[SQLite] ${message}`, ...args);
},
info: (message: string, ...args: unknown[]) => {
logger.info(`[SQLite] ${message}`, ...args);
},
warn: (message: string, ...args: unknown[]) => {
logger.warn(`[SQLite] ${message}`, ...args);
},
error: (message: string, ...args: unknown[]) => {
logger.error(`[SQLite] ${message}`, ...args);
}
};
// Add migration specific logger with debug filtering
logger.migration = {
debug: (message: string, ...args: unknown[]) => {
if (process.env.DEBUG_MIGRATIONS) {
//logger.debug(`[Migration] ${message}`, ...args);
}
},
info: (message: string, ...args: unknown[]) => {
// Always log to file, but only log to console if DEBUG_MIGRATIONS is set
if (process.env.DEBUG_MIGRATIONS) {
//logger.info(`[Migration] ${message}`, ...args);
} else {
// Use a separate transport for migration logs to file only
const metadata = args[0] as Record<string, unknown>;
logger.write({
level: 'info',
message: `[Migration] ${message}`,
...(metadata || {})
});
}
},
warn: (message: string, ...args: unknown[]) => {
// Always log warnings to both console and file
//logger.warn(`[Migration] ${message}`, ...args);
},
error: (message: string, ...args: unknown[]) => {
// Always log errors to both console and file
//logger.error(`[Migration] ${message}`, ...args);
}
};
// Add renderer log handler
ipcMain.on('renderer-log', (_event, { level, args, source, operation, error }) => {
const message = args.map((arg: unknown) =>
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
).join(' ');
const meta = {
source: source || 'renderer',
...(operation && { operation }),
...(error && { error })
};
switch (level) {
case 'error':
logger.error(message, meta);
break;
case 'warn':
logger.warn(message, meta);
break;
case 'info':
logger.info(message, meta);
break;
case 'debug':
logger.debug(message, meta);
break;
default:
logger.log(level, message, meta);
}
});
// Export logger instance
export { logger };
// Export a function to get the logs directory
export const getLogsDirectory = () => logsDir;

View File

@@ -0,0 +1,14 @@
/**
* Custom error class for SQLite operations
* Provides additional context and error tracking for SQLite operations
*/
export class SQLiteError extends Error {
constructor(
message: string,
public operation: string,
public cause?: unknown
) {
super(message);
this.name = 'SQLiteError';
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

442
electron/src/setup.ts Normal file
View File

@@ -0,0 +1,442 @@
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
import {
CapElectronEventEmitter,
CapacitorSplashScreen,
setupCapacitorElectronPlugins,
} from '@capacitor-community/electron';
import chokidar from 'chokidar';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, Menu, MenuItem, nativeImage, Tray, session } from 'electron';
import electronIsDev from 'electron-is-dev';
import electronServe from 'electron-serve';
import windowStateKeeper from 'electron-window-state';
import { join } from 'path';
/**
* Reload watcher configuration and state management
* Prevents infinite reload loops and implements rate limiting
* Also prevents reloads during critical database operations
*
* @author Matthew Raymer
*/
const RELOAD_CONFIG = {
DEBOUNCE_MS: 1500,
COOLDOWN_MS: 5000,
MAX_RELOADS_PER_MINUTE: 10,
MAX_RELOADS_PER_SESSION: 100,
DATABASE_OPERATION_TIMEOUT_MS: 10000 // 10 second timeout for database operations
};
// Track database operation state
let isDatabaseOperationInProgress = false;
let lastDatabaseOperationTime = 0;
/**
* Checks if a database operation is in progress or recently completed
* @returns {boolean} Whether a database operation is active
*/
const isDatabaseOperationActive = (): boolean => {
const now = Date.now();
return isDatabaseOperationInProgress ||
(now - lastDatabaseOperationTime < RELOAD_CONFIG.DATABASE_OPERATION_TIMEOUT_MS);
};
/**
* Marks the start of a database operation
*/
export const startDatabaseOperation = (): void => {
isDatabaseOperationInProgress = true;
lastDatabaseOperationTime = Date.now();
};
/**
* Marks the end of a database operation
*/
export const endDatabaseOperation = (): void => {
isDatabaseOperationInProgress = false;
lastDatabaseOperationTime = Date.now();
};
const reloadWatcher = {
debouncer: null as NodeJS.Timeout | null,
ready: false,
watcher: null as chokidar.FSWatcher | null,
lastReloadTime: 0,
reloadCount: 0,
sessionReloadCount: 0,
resetTimeout: null as NodeJS.Timeout | null,
isReloading: false
};
/**
* Resets the reload counter after one minute
*/
const resetReloadCounter = () => {
reloadWatcher.reloadCount = 0;
reloadWatcher.resetTimeout = null;
};
/**
* Checks if a reload is allowed based on rate limits, cooldown, and database state
* @returns {boolean} Whether a reload is allowed
*/
const canReload = (): boolean => {
const now = Date.now();
// Check if database operation is active
if (isDatabaseOperationActive()) {
console.warn('[Reload Watcher] Skipping reload - database operation in progress');
return false;
}
// Check cooldown period
if (now - reloadWatcher.lastReloadTime < RELOAD_CONFIG.COOLDOWN_MS) {
console.warn('[Reload Watcher] Skipping reload - cooldown period active');
return false;
}
// Check per-minute limit
if (reloadWatcher.reloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_MINUTE) {
console.warn('[Reload Watcher] Skipping reload - maximum reloads per minute reached');
return false;
}
// Check session limit
if (reloadWatcher.sessionReloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_SESSION) {
console.error('[Reload Watcher] Maximum reloads per session reached. Please restart the application.');
return false;
}
return true;
};
/**
* Cleans up the current watcher instance
*/
const cleanupWatcher = () => {
if (reloadWatcher.watcher) {
reloadWatcher.watcher.close();
reloadWatcher.watcher = null;
}
if (reloadWatcher.debouncer) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
}
if (reloadWatcher.resetTimeout) {
clearTimeout(reloadWatcher.resetTimeout);
reloadWatcher.resetTimeout = null;
}
};
/**
* Sets up the file watcher for development mode reloading
* Implements rate limiting and prevents infinite reload loops
*
* @param electronCapacitorApp - The Electron Capacitor app instance
*/
export function setupReloadWatcher(electronCapacitorApp: ElectronCapacitorApp): void {
// Cleanup any existing watcher
cleanupWatcher();
// Reset state
reloadWatcher.ready = false;
reloadWatcher.isReloading = false;
reloadWatcher.watcher = chokidar
.watch(join(app.getAppPath(), 'app'), {
ignored: /[/\\]\./,
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 1000,
pollInterval: 100
}
})
.on('ready', () => {
reloadWatcher.ready = true;
console.log('[Reload Watcher] Ready to watch for changes');
})
.on('all', (_event, _path) => {
if (!reloadWatcher.ready || reloadWatcher.isReloading) {
return;
}
// Clear existing debouncer
if (reloadWatcher.debouncer) {
clearTimeout(reloadWatcher.debouncer);
}
// Set up new debouncer
reloadWatcher.debouncer = setTimeout(async () => {
if (!canReload()) {
return;
}
try {
reloadWatcher.isReloading = true;
// Update reload counters
reloadWatcher.lastReloadTime = Date.now();
reloadWatcher.reloadCount++;
reloadWatcher.sessionReloadCount++;
// Set up reset timeout for per-minute counter
if (!reloadWatcher.resetTimeout) {
reloadWatcher.resetTimeout = setTimeout(resetReloadCounter, 60000);
}
// Perform reload
console.log('[Reload Watcher] Reloading window...');
await electronCapacitorApp.getMainWindow().webContents.reload();
// Reset state after reload
reloadWatcher.ready = false;
reloadWatcher.isReloading = false;
// Re-setup watcher after successful reload
setupReloadWatcher(electronCapacitorApp);
} catch (error) {
console.error('[Reload Watcher] Error during reload:', error);
reloadWatcher.isReloading = false;
reloadWatcher.ready = true;
}
}, RELOAD_CONFIG.DEBOUNCE_MS);
})
.on('error', (error) => {
console.error('[Reload Watcher] Error:', error);
cleanupWatcher();
});
}
// Define our class to manage our app.
export class ElectronCapacitorApp {
private MainWindow: BrowserWindow | null = null;
private SplashScreen: CapacitorSplashScreen | null = null;
private TrayIcon: Tray | null = null;
private CapacitorFileConfig: CapacitorElectronConfig;
private TrayMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
new MenuItem({ label: 'Quit App', role: 'quit' }),
];
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'viewMenu' },
];
private mainWindowState;
private loadWebApp;
private customScheme: string;
constructor(
capacitorFileConfig: CapacitorElectronConfig,
trayMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[],
appMenuBarMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[]
) {
this.CapacitorFileConfig = capacitorFileConfig;
this.customScheme = this.CapacitorFileConfig.electron?.customUrlScheme ?? 'capacitor-electron';
if (trayMenuTemplate) {
this.TrayMenuTemplate = trayMenuTemplate;
}
if (appMenuBarMenuTemplate) {
this.AppMenuBarMenuTemplate = appMenuBarMenuTemplate;
}
// Setup our web app loader, this lets us load apps like react, vue, and angular without changing their build chains.
this.loadWebApp = electronServe({
directory: join(app.getAppPath(), 'app'),
scheme: this.customScheme,
});
}
// Helper function to load in the app.
private async loadMainWindow(thisRef: any) {
await thisRef.loadWebApp(thisRef.MainWindow);
}
// Expose the mainWindow ref for use outside of the class.
getMainWindow(): BrowserWindow {
return this.MainWindow;
}
getCustomURLScheme(): string {
return this.customScheme;
}
async init(): Promise<void> {
const icon = nativeImage.createFromPath(
join(app.getAppPath(), 'assets', process.platform === 'win32' ? 'appIcon.ico' : 'appIcon.png')
);
this.mainWindowState = windowStateKeeper({
defaultWidth: 1000,
defaultHeight: 800,
});
// Setup preload script path based on environment
const preloadPath = app.isPackaged
? join(process.resourcesPath, 'preload.js')
: join(__dirname, 'preload.js');
console.log('[Electron Main Process] Preload path:', preloadPath);
console.log('[Electron Main Process] Preload exists:', require('fs').existsSync(preloadPath));
this.MainWindow = new BrowserWindow({
icon,
show: false,
x: this.mainWindowState.x,
y: this.mainWindowState.y,
width: this.mainWindowState.width,
height: this.mainWindowState.height,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
preload: preloadPath,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
this.mainWindowState.manage(this.MainWindow);
if (this.CapacitorFileConfig.backgroundColor) {
this.MainWindow.setBackgroundColor(this.CapacitorFileConfig.electron.backgroundColor);
}
// If we close the main window with the splashscreen enabled we need to destory the ref.
this.MainWindow.on('closed', () => {
if (this.SplashScreen?.getSplashWindow() && !this.SplashScreen.getSplashWindow().isDestroyed()) {
this.SplashScreen.getSplashWindow().close();
}
});
// When the tray icon is enabled, setup the options.
if (this.CapacitorFileConfig.electron?.trayIconAndMenuEnabled) {
this.TrayIcon = new Tray(icon);
this.TrayIcon.on('double-click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
} else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
this.TrayIcon.on('click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
} else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
this.TrayIcon.setToolTip(app.getName());
this.TrayIcon.setContextMenu(Menu.buildFromTemplate(this.TrayMenuTemplate));
}
// Setup the main manu bar at the top of our window.
Menu.setApplicationMenu(Menu.buildFromTemplate(this.AppMenuBarMenuTemplate));
// If the splashscreen is enabled, show it first while the main window loads then switch it out for the main window, or just load the main window from the start.
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen = new CapacitorSplashScreen({
imageFilePath: join(
app.getAppPath(),
'assets',
this.CapacitorFileConfig.electron?.splashScreenImageName ?? 'splash.png'
),
windowWidth: 400,
windowHeight: 400,
});
this.SplashScreen.init(this.loadMainWindow, this);
} else {
this.loadMainWindow(this);
}
// Security
this.MainWindow.webContents.setWindowOpenHandler((details) => {
if (!details.url.includes(this.customScheme)) {
return { action: 'deny' };
} else {
return { action: 'allow' };
}
});
this.MainWindow.webContents.on('will-navigate', (event, _newURL) => {
if (!this.MainWindow.webContents.getURL().includes(this.customScheme)) {
event.preventDefault();
}
});
// Link electron plugins into the system.
setupCapacitorElectronPlugins();
// When the web app is loaded we hide the splashscreen if needed and show the mainwindow.
this.MainWindow.webContents.on('dom-ready', () => {
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen.getSplashWindow().hide();
}
if (!this.CapacitorFileConfig.electron?.hideMainWindowOnLaunch) {
this.MainWindow.show();
}
// Re-register SQLite handlers after reload
if (electronIsDev) {
console.log('[Electron Main Process] Re-registering SQLite handlers after reload');
const { setupSQLiteHandlers } = require('./rt/sqlite-init');
setupSQLiteHandlers();
}
setTimeout(() => {
if (electronIsDev) {
this.MainWindow.webContents.openDevTools();
}
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
}, 400);
});
}
}
// Set a CSP up for our application based on the custom scheme
export function setupContentSecurityPolicy(customScheme: string): void {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
// Base CSP for both dev and prod
`default-src ${customScheme}://*;`,
// Script sources
`script-src ${customScheme}://* 'self' 'unsafe-inline'${electronIsDev ? " 'unsafe-eval'" : ''};`,
// Style sources
`style-src ${customScheme}://* 'self' 'unsafe-inline' https://fonts.googleapis.com;`,
// Font sources
`font-src ${customScheme}://* 'self' https://fonts.gstatic.com;`,
// Image sources
`img-src ${customScheme}://* 'self' data: https:;`,
// Connect sources (for API calls)
`connect-src ${customScheme}://* 'self' https:;`,
// Worker sources
`worker-src ${customScheme}://* 'self' blob:;`,
// Frame sources
`frame-src ${customScheme}://* 'self';`,
// Media sources
`media-src ${customScheme}://* 'self' data:;`,
// Object sources
`object-src 'none';`,
// Base URI
`base-uri 'self';`,
// Form action
`form-action ${customScheme}://* 'self';`,
// Frame ancestors
`frame-ancestors 'none';`,
// Upgrade insecure requests
'upgrade-insecure-requests;',
// Block mixed content
'block-all-mixed-content;'
].join(' ')
},
});
});
}

18
electron/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compileOnSave": true,
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
"compilerOptions": {
"outDir": "./build",
"importHelpers": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"typeRoots": ["./node_modules/@types"],
"allowJs": true,
"rootDir": ".",
"skipLibCheck": true,
"resolveJsonModule": true
}
}

155
experiment.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/bash
# experiment.sh
# Author: Matthew Raymer
# Description: Build script for TimeSafari Electron application
# This script handles the complete build process for the TimeSafari Electron app,
# including web asset compilation and Capacitor sync.
#
# Build Process:
# 1. Environment setup and dependency checks
# 2. Web asset compilation (Vite)
# 3. Capacitor sync
# 4. Electron start
#
# Dependencies:
# - Node.js and npm
# - TypeScript
# - Vite
# - @capacitor-community/electron
#
# Usage: ./experiment.sh
#
# Exit Codes:
# 1 - Required command not found
# 2 - TypeScript installation failed
# 3 - Build process failed
# 4 - Capacitor sync failed
# 5 - Electron start failed
# Exit on any error
set -e
# ANSI color codes for better output formatting
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] [SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR]${NC} $1"
}
# Function to check if a command exists
check_command() {
if ! command -v "$1" &> /dev/null; then
log_error "$1 is required but not installed."
exit 1
fi
log_info "Found $1: $(command -v "$1")"
}
# Function to measure and log execution time
measure_time() {
local start_time=$(date +%s)
"$@"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
log_success "Completed in ${duration} seconds"
}
# Print build header
echo -e "\n${BLUE}=== TimeSafari Electron Build Process ===${NC}\n"
log_info "Starting build process at $(date)"
# Check required commands
log_info "Checking required dependencies..."
check_command node
check_command npm
check_command git
# Create application data directory
log_info "Setting up application directories..."
mkdir -p ~/.local/share/TimeSafari/timesafari
# Clean up previous builds
log_info "Cleaning previous builds..."
rm -rf dist* || log_warn "No previous builds to clean"
# Set environment variables for the build
log_info "Configuring build environment..."
export VITE_PLATFORM=electron
export VITE_PWA_ENABLED=false
export VITE_DISABLE_PWA=true
export DEBUG_MIGRATIONS=0
# Ensure TypeScript is installed
log_info "Verifying TypeScript installation..."
if [ ! -f "./node_modules/.bin/tsc" ]; then
log_info "Installing TypeScript..."
if ! npm install --save-dev typescript@~5.2.2; then
log_error "TypeScript installation failed!"
exit 2
fi
# Verify installation
if [ ! -f "./node_modules/.bin/tsc" ]; then
log_error "TypeScript installation verification failed!"
exit 2
fi
log_success "TypeScript installed successfully"
else
log_info "TypeScript already installed"
fi
# Get git hash for versioning
GIT_HASH=$(git log -1 --pretty=format:%h)
log_info "Using git hash: ${GIT_HASH}"
# Build web assets
log_info "Building web assets with Vite..."
if ! measure_time env VITE_GIT_HASH="$GIT_HASH" npx vite build --config vite.config.app.electron.mts --mode electron; then
log_error "Web asset build failed!"
exit 3
fi
# Sync with Capacitor
log_info "Syncing with Capacitor..."
if ! measure_time npx cap sync electron; then
log_error "Capacitor sync failed!"
exit 4
fi
# Restore capacitor config
log_info "Restoring capacitor config..."
if ! git checkout electron/capacitor.config.json; then
log_error "Failed to restore capacitor config!"
exit 4
fi
# Start Electron
log_info "Starting Electron..."
cd electron/
if ! measure_time npm run electron:start; then
log_error "Electron start failed!"
exit 5
fi
# Print build summary
log_success "Build and start completed successfully!"
echo -e "\n${GREEN}=== End of Build Process ===${NC}\n"
# Exit with success
exit 0

View File

@@ -1,5 +1,6 @@
import UIKit
import Capacitor
import CapacitorCommunitySqlite
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -7,6 +8,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Initialize SQLite
let sqlite = SQLite()
sqlite.initialize()
// Override point for customization after application launch.
return true
}

View File

@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'

4313
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.cjs && node scripts/copy-wasm.cjs",
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:prerequisites": "node scripts/check-prerequisites.js",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on",
@@ -22,14 +22,15 @@
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
"build:web:electron": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts && VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.electron.mts --mode electron",
"build:electron": "npm run clean:electron && npm run build:web:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.cjs",
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"electron:dev": "npm run build && electron .",
"electron:start": "electron .",
"clean:android": "adb uninstall app.timesafari.app || true",
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux": "electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
"build:electron-prod": "NODE_ENV=production npm run build:electron",
@@ -46,6 +47,7 @@
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
},
"dependencies": {
"@capacitor-community/sqlite": "^6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",
@@ -56,8 +58,8 @@
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@dicebear/collection": "^5.4.3",
"@dicebear/core": "^5.4.3",
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
@@ -68,7 +70,7 @@
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@simplewebauthn/server": "^10.0.1",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
@@ -85,6 +87,7 @@
"absurd-sql": "^0.0.54",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1",
"dexie": "^3.2.7",
@@ -92,22 +95,23 @@
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"dotenv": "^16.0.3",
"ethereum-cryptography": "^2.1.3",
"electron-json-storage": "^4.6.0",
"ethereum-cryptography": "^2.2.1",
"ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0",
"jdenticon": "^3.3.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"lru-cache": "^10.4.3",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"nostr-tools": "^2.10.4",
"nostr-tools": "^2.13.1",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1",
"pinia-plugin-persistedstate": "^3.2.3",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
"ramda": "^0.29.1",
@@ -123,12 +127,13 @@
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-qrcode-reader": "^5.7.2",
"vue-router": "^4.5.0",
"web-did-resolver": "^2.0.27",
"web-did-resolver": "^2.0.30",
"zod": "^3.24.2"
},
"devDependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor/assets": "^3.0.5",
"@playwright/test": "^1.45.2",
"@types/dom-webcodecs": "^0.1.7",
@@ -143,7 +148,7 @@
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0",
@@ -163,12 +168,13 @@
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"rimraf": "^6.0.1",
"source-map-support": "^0.5.21",
"tailwindcss": "^3.4.1",
"typescript": "~5.2.2",
"vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8"
"vite-plugin-pwa": "^1.0.0"
},
"main": "./dist-electron/main.js",
"main": "./dist-electron/main.mjs",
"build": {
"appId": "app.timesafari",
"productName": "TimeSafari",
@@ -177,12 +183,17 @@
},
"files": [
"dist-electron/**/*",
"dist/**/*"
"dist/**/*",
"capacitor.config.json"
],
"extraResources": [
{
"from": "dist",
"from": "dist-electron/www",
"to": "www"
},
{
"from": "dist-electron/resources/preload.js",
"to": "preload.js"
}
],
"linux": {
@@ -220,5 +231,6 @@
}
]
}
}
},
"type": "module"
}

View File

@@ -1,5 +1,6 @@
eth_keys
pywebview
pyinstaller>=6.12.0
setuptools>=69.0.0 # Required for distutils for electron-builder on macOS
# For development
watchdog>=3.0.0 # For file watching support

View File

@@ -0,0 +1,96 @@
const fs = require("fs");
const fse = require("fs-extra");
const path = require("path");
const { execSync } = require('child_process');
console.log("Starting Electron build finalization...");
// Define paths
const distPath = path.join(__dirname, "..", "dist");
const electronDistPath = path.join(__dirname, "..", "dist-electron");
const wwwPath = path.join(electronDistPath, "www");
const builtIndexPath = path.join(distPath, "index.html");
const finalIndexPath = path.join(wwwPath, "index.html");
// Ensure target directory exists
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// Copy assets directory
const assetsSrc = path.join(distPath, "assets");
const assetsDest = path.join(wwwPath, "assets");
if (fs.existsSync(assetsSrc)) {
fse.copySync(assetsSrc, assetsDest, { overwrite: true });
}
// Copy favicon.ico
const faviconSrc = path.join(distPath, "favicon.ico");
if (fs.existsSync(faviconSrc)) {
fs.copyFileSync(faviconSrc, path.join(wwwPath, "favicon.ico"));
}
// Copy manifest.webmanifest
const manifestSrc = path.join(distPath, "manifest.webmanifest");
if (fs.existsSync(manifestSrc)) {
fs.copyFileSync(manifestSrc, path.join(wwwPath, "manifest.webmanifest"));
}
// Load and modify index.html from Vite output
let indexContent = fs.readFileSync(builtIndexPath, "utf-8");
// Inject the window.process shim after the first <script> block
indexContent = indexContent.replace(
/<script[^>]*type="module"[^>]*>/,
match => `${match}\n window.process = { env: { VITE_PLATFORM: 'electron' } };`
);
// Write the modified index.html to dist-electron/www
fs.writeFileSync(finalIndexPath, indexContent);
// Copy preload script to resources
const preloadSrc = path.join(electronDistPath, "preload.mjs");
const preloadDest = path.join(electronDistPath, "resources", "preload.js");
// Ensure resources directory exists
const resourcesDir = path.join(electronDistPath, "resources");
if (!fs.existsSync(resourcesDir)) {
fs.mkdirSync(resourcesDir, { recursive: true });
}
if (fs.existsSync(preloadSrc)) {
// Read the preload script
let preloadContent = fs.readFileSync(preloadSrc, 'utf-8');
// Convert ESM to CommonJS if needed
preloadContent = preloadContent
.replace(/import\s*{\s*([^}]+)\s*}\s*from\s*['"]electron['"];?/g, 'const { $1 } = require("electron");')
.replace(/export\s*{([^}]+)};?/g, '')
.replace(/export\s+default\s+([^;]+);?/g, 'module.exports = $1;');
// Write the modified preload script
fs.writeFileSync(preloadDest, preloadContent);
console.log("Preload script copied and converted to resources directory");
} else {
console.error("Preload script not found at:", preloadSrc);
process.exit(1);
}
// Copy capacitor.config.json to dist-electron
try {
console.log("Copying capacitor.config.json to dist-electron...");
const configPath = path.join(process.cwd(), 'capacitor.config.json');
const targetPath = path.join(process.cwd(), 'dist-electron', 'capacitor.config.json');
if (!fs.existsSync(configPath)) {
throw new Error('capacitor.config.json not found in project root');
}
fs.copyFileSync(configPath, targetPath);
console.log("Successfully copied capacitor.config.json");
} catch (error) {
console.error("Failed to copy capacitor.config.json:", error);
throw error;
}
console.log("Electron index.html copied and patched for Electron context.");

View File

@@ -1,243 +0,0 @@
const fs = require('fs');
const path = require('path');
console.log('Starting electron build process...');
// Copy web files
const webDistPath = path.join(__dirname, '..', 'dist');
const electronDistPath = path.join(__dirname, '..', 'dist-electron');
const wwwPath = path.join(electronDistPath, 'www');
// Create www directory if it doesn't exist
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// Copy web files to www directory
fs.cpSync(webDistPath, wwwPath, { recursive: true });
// Fix asset paths in index.html
const indexPath = path.join(wwwPath, 'index.html');
let indexContent = fs.readFileSync(indexPath, 'utf8');
// Fix asset paths
indexContent = indexContent
.replace(/\/assets\//g, './assets/')
.replace(/href="\//g, 'href="./')
.replace(/src="\//g, 'src="./');
fs.writeFileSync(indexPath, indexContent);
// Check for remaining /assets/ paths
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/'));
console.log('Sample of fixed content:', indexContent.substring(0, 500));
console.log('Copied and fixed web files in:', wwwPath);
// Copy main process files
console.log('Copying main process files...');
// Create the main process file with inlined logger
const mainContent = `const { app, BrowserWindow } = require("electron");
const path = require("path");
const fs = require("fs");
// Inline logger implementation
const logger = {
log: (...args) => console.log(...args),
error: (...args) => console.error(...args),
info: (...args) => console.info(...args),
warn: (...args) => console.warn(...args),
debug: (...args) => console.debug(...args),
};
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
},
});
// Always open DevTools for now
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = \`file://\${path.join(__dirname, "www", url)}\`;
}
// Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: \`file://\${__dirname}\`;
const assetPath = url.split("/assets/")[1];
const newUrl = \`\${baseDir}/www/assets/\${assetPath}\`;
callback({ redirectURL: newUrl });
return;
}
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
}
if (!fs.existsSync(indexPath)) {
logger.error(\`Index file not found at: \${indexPath}\`);
throw new Error("Index file not found");
}
// Add CSP headers to allow API connections, Google Fonts, and zxing-wasm
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self';" +
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app https://*.jsdelivr.net;" +
"img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.jsdelivr.net;" +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
"font-src 'self' data: https://fonts.gstatic.com;" +
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
"worker-src 'self' blob:;",
],
},
});
},
);
// Load the index.html
mainWindow
.loadFile(indexPath)
.then(() => {
logger.log("Successfully loaded index.html");
if (isDev) {
mainWindow.webContents.openDevTools();
logger.log("DevTools opened - running in dev mode");
}
})
.catch((err) => {
logger.error("Failed to load index.html:", err);
logger.error("Attempted path:", indexPath);
});
// Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, _level, message) => {
logger.log("Renderer Console:", message);
});
// Add right after creating the BrowserWindow
mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription) => {
logger.error("Page failed to load:", errorCode, errorDescription);
},
);
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
logger.error("Preload script error:", preloadPath, error);
});
mainWindow.webContents.on(
"console-message",
(_event, _level, message, line, sourceId) => {
logger.log("Renderer Console:", line, sourceId, message);
},
);
// Enable remote debugging when in dev mode
if (isDev) {
mainWindow.webContents.openDevTools();
}
}
// Handle app ready
app.whenReady().then(createWindow);
// Handle all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Handle any errors
process.on("uncaughtException", (error) => {
logger.error("Uncaught Exception:", error);
});
`;
// Write the main process file
const mainDest = path.join(electronDistPath, 'main.js');
fs.writeFileSync(mainDest, mainContent);
// Copy preload script if it exists
const preloadSrc = path.join(__dirname, '..', 'src', 'electron', 'preload.js');
const preloadDest = path.join(electronDistPath, 'preload.js');
if (fs.existsSync(preloadSrc)) {
console.log(`Copying ${preloadSrc} to ${preloadDest}`);
fs.copyFileSync(preloadSrc, preloadDest);
}
// Verify build structure
console.log('\nVerifying build structure:');
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath));
console.log('Build completed successfully!');

View File

@@ -330,8 +330,11 @@
<script lang="ts">
import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
import { NotificationIface } from "./constants/app";
import { NotificationIface, USE_DEXIE_DB } from "./constants/app";
import * as databaseUtil from "./db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "./db/index";
import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger";
interface Settings {
@@ -396,7 +399,11 @@ export default class App extends Vue {
try {
logger.log("Retrieving settings for the active account...");
const settings: Settings = await retrieveSettingsForActiveAccount();
let settings: Settings =
await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logger.log("Retrieved settings:", settings);
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
@@ -452,9 +459,10 @@ export default class App extends Vue {
return true;
}
const serverSubscription = {
...subscription,
};
const serverSubscription =
typeof subscription === "object" && subscription !== null
? { ...subscription }
: {};
if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title;
logger.log(

View File

@@ -62,7 +62,7 @@ backup and database export, with platform-specific download instructions. * *
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db } from "../db/index";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
@@ -131,6 +131,9 @@ export default class DataExportSection extends Vue {
*/
public async exportDatabase() {
try {
if (!USE_DEXIE_DB) {
throw new Error("Not implemented");
}
const blob = await db.export({
prettyJson: true,
transform: (table, value, key) => {
@@ -145,7 +148,7 @@ export default class DataExportSection extends Vue {
return { value, key };
},
});
const fileName = `${db.name}-backup.json`;
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup.json`;
if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link
@@ -159,6 +162,8 @@ export default class DataExportSection extends Vue {
// Native platform: Write to app directory
const content = await blob.text();
await this.platformService.writeAndShareFile(fileName, content);
} else {
throw new Error("This platform does not support file downloads.");
}
this.$notify(

View File

@@ -99,8 +99,12 @@ import {
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import { USE_DEXIE_DB } from "@/constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({
components: {
@@ -122,7 +126,10 @@ export default class FeedFilters extends Vue {
async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged;
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
@@ -144,9 +151,17 @@ export default class FeedFilters extends Vue {
async toggleNearby() {
this.settingChanged = true;
this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby,
});
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
`UPDATE settings SET filterFeedByNearby = ? WHERE id = ?`,
[this.isNearby, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby,
});
}
}
async clearAll() {
@@ -154,10 +169,18 @@ export default class FeedFilters extends Vue {
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false,
filterFeedByVisible: false,
});
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`,
[false, false, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false,
filterFeedByVisible: false,
});
}
this.hasVisibleDid = false;
this.isNearby = false;
@@ -168,10 +191,18 @@ export default class FeedFilters extends Vue {
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true,
filterFeedByVisible: true,
});
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`,
[true, true, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true,
filterFeedByVisible: true,
});
}
this.hasVisibleDid = true;
this.isNearby = true;

View File

@@ -89,7 +89,7 @@
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import {
createAndSubmitGive,
didInfo,
@@ -98,8 +98,10 @@ import {
import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component
export default class GiftedDialog extends Vue {
@@ -144,11 +146,23 @@ export default class GiftedDialog extends Vue {
this.offerId = offerId || "";
try {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray();
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) {
this.allContacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
this.allMyDids = await retrieveAccountDids();
@@ -306,10 +320,7 @@ export default class GiftedDialog extends Vue {
this.fromProjectId,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
if (!result.success) {
const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result);
this.$notify(
@@ -356,15 +367,6 @@ export default class GiftedDialog extends Vue {
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message

View File

@@ -74,10 +74,12 @@
import { Vue, Component } from "vue-facing-decorator";
import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { GiverReceiverInputInfo } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component
export default class GivenPrompts extends Vue {
@@ -127,8 +129,16 @@ export default class GivenPrompts extends Vue {
this.visible = true;
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
await db.open();
this.numContacts = await db.contacts.count();
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(
"SELECT COUNT(*) FROM contacts",
);
if (result) {
this.numContacts = result.values[0][0] as number;
}
if (USE_DEXIE_DB) {
this.numContacts = await db.contacts.count();
}
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
}
@@ -229,10 +239,22 @@ export default class GivenPrompts extends Vue {
this.nextIdeaPastContacts();
} else {
// get the contact at that offset
await db.open();
this.currentContact = await db.contacts
.offset(someContactDbIndex)
.first();
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(
"SELECT * FROM contacts LIMIT 1 OFFSET ?",
[someContactDbIndex],
);
if (result) {
this.currentContact = databaseUtil.mapQueryResultToValues(result)[
someContactDbIndex
] as unknown as Contact;
}
if (USE_DEXIE_DB) {
await db.open();
this.currentContact = await db.contacts
.offset(someContactDbIndex)
.first();
}
this.shownContactDbIndices[someContactDbIndex] = true;
}
}

View File

@@ -247,11 +247,16 @@ import axios from "axios";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import {
DEFAULT_IMAGE_API_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import * as databaseUtil from "../db/databaseUtil";
const inputImageFileNameRef = ref<Blob>();
@@ -334,7 +339,10 @@ export default class ImageMethodDialog extends Vue {
*/
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || "";
} catch (error: unknown) {
logger.error("Error retrieving settings from database:", error);

View File

@@ -172,8 +172,10 @@ import {
} from "../libs/endorserServer";
import { decryptMessage } from "../libs/crypto";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import * as libsUtil from "../libs/util";
import { NotificationIface } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
interface Member {
admitted: boolean;
@@ -209,7 +211,10 @@ export default class MembersList extends Vue {
contacts: Array<Contact> = [];
async created() {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
@@ -355,7 +360,16 @@ export default class MembersList extends Vue {
}
async loadContacts() {
this.contacts = await db.contacts.toArray();
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery("SELECT * FROM contacts");
if (result) {
this.contacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.contacts = await db.contacts.toArray();
}
}
getContactFor(did: string): Contact | undefined {
@@ -439,7 +453,14 @@ export default class MembersList extends Vue {
if (result.success) {
decrMember.isRegistered = true;
if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true });
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET registered = ? WHERE did = ?",
[true, decrMember.did],
);
if (USE_DEXIE_DB) {
await db.contacts.update(decrMember.did, { registered: true });
}
oldContact.registered = true;
}
this.$notify(
@@ -492,7 +513,14 @@ export default class MembersList extends Vue {
name: member.name,
};
await db.contacts.add(newContact);
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"INSERT INTO contacts (did, name) VALUES (?, ?)",
[member.did, member.name],
);
if (USE_DEXIE_DB) {
await db.contacts.add(newContact);
}
this.contacts.push(newContact);
this.$notify(

View File

@@ -82,12 +82,13 @@
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import {
createAndSubmitOffer,
serverMessageForUser,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { logger } from "../utils/logger";
@@ -116,7 +117,10 @@ export default class OfferDialog extends Vue {
this.recipientDid = recipientDid;
this.recipientName = recipientName;
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
@@ -245,10 +249,7 @@ export default class OfferDialog extends Vue {
this.projectId,
);
if (
result.type === "error" ||
this.isOfferCreationError(result.response)
) {
if (!result.success) {
const errorMessage = this.getOfferCreationErrorMessage(result);
logger.error("Error with offer creation result:", result);
this.$notify(
@@ -292,15 +293,6 @@ export default class OfferDialog extends Vue {
// Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isOfferCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message

View File

@@ -201,13 +201,16 @@
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { NotificationIface } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import {
db,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { OnboardPage } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Contact } from "@/db/tables/contacts";
@Component({
computed: {
@@ -222,7 +225,7 @@ export default class OnboardingDialog extends Vue {
$router!: Router;
activeDid = "";
firstContactName = null;
firstContactName = "";
givenName = "";
isRegistered = false;
numContacts = 0;
@@ -231,29 +234,54 @@ export default class OnboardingDialog extends Vue {
async open(page: OnboardPage) {
this.page = page;
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
const contacts = await db.contacts.toArray();
this.numContacts = contacts.length;
if (this.numContacts > 0) {
this.firstContactName = contacts[0].name;
const platformService = PlatformServiceFactory.getInstance();
const dbContacts = await platformService.dbQuery("SELECT * FROM contacts");
if (dbContacts) {
this.numContacts = dbContacts.values.length;
const firstContact = dbContacts.values[0];
const fullContact = databaseUtil.mapColumnsToValues(dbContacts.columns, [
firstContact,
]) as unknown as Contact;
this.firstContactName = fullContact.name || "";
}
if (USE_DEXIE_DB) {
const contacts = await db.contacts.toArray();
this.numContacts = contacts.length;
if (this.numContacts > 0) {
this.firstContactName = contacts[0].name || "";
}
}
this.visible = true;
if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages
await updateAccountSettings(this.activeDid, {
await databaseUtil.updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
if (USE_DEXIE_DB) {
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
}
}
}
async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false;
if (done) {
await updateAccountSettings(this.activeDid, {
await databaseUtil.updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
if (USE_DEXIE_DB) {
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
}
if (goHome) {
this.$router.push({ name: "home" });
}

View File

@@ -119,7 +119,12 @@ PhotoDialog.vue */
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import {
DEFAULT_IMAGE_API_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
@@ -173,9 +178,12 @@ export default class PhotoDialog extends Vue {
* @throws {Error} When settings retrieval fails
*/
async mounted() {
logger.log("PhotoDialog mounted");
// logger.log("PhotoDialog mounted");
try {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
logger.log("isRegistered:", this.isRegistered);

View File

@@ -102,7 +102,12 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
import {
DEFAULT_PUSH_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import {
logConsoleAndDb,
retrieveSettingsForActiveAccount,
@@ -169,7 +174,10 @@ export default class PushNotificationPermission extends Vue {
this.isVisible = true;
this.pushType = pushType;
try {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;

View File

@@ -15,7 +15,8 @@
<script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator";
import { AppString, NotificationIface } from "../constants/app";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
@Component
@@ -28,7 +29,10 @@ export default class TopMessage extends Vue {
async mounted() {
try {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
if (
settings.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER

View File

@@ -37,8 +37,9 @@
<script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
@Component
@@ -61,15 +62,23 @@ export default class UserNameDialog extends Vue {
*/
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.givenName = settings.firstName || "";
this.visible = true;
}
async onClickSaveChanges() {
await db.settings.update(MASTER_SETTINGS_KEY, {
await databaseUtil.updateDefaultSettings({
firstName: this.givenName,
});
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName,
});
}
this.visible = false;
this.callback(this.givenName);
}

View File

@@ -3,6 +3,8 @@ import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js";
import { USE_DEXIE_DB } from "../../../../constants/app";
import * as databaseUtil from "../../../../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../../../../db";
import { getHeaders } from "../../../../libs/endorserServer";
import { logger } from "../../../../utils/logger";
@@ -14,7 +16,10 @@ export async function loadLandmarks(vue, world, scene, loop) {
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
try {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
const activeDid = settings.activeDid || "";
const apiServer = settings.apiServer;
const headers = await getHeaders(activeDid);

View File

@@ -7,6 +7,7 @@ export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari",
APP_NAME_NO_SPACES = "TimeSafari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
@@ -43,13 +44,15 @@ export const DEFAULT_PARTNER_API_SERVER =
AppString.TEST_PARTNER_API_SERVER;
export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host;
import.meta.env.VITE_DEFAULT_PUSH_SERVER || "https://timesafari.app";
export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
export const USE_DEXIE_DB = false;
/**
* The possible values for "group" and "type" are in App.vue.
* Some of this comes from the notiwind package, some is custom.

View File

@@ -1,5 +1,32 @@
import migrationService from "../services/migrationService";
import type { QueryExecResult, SqlValue } from "../interfaces/database";
import type { QueryExecResult } from "../interfaces/database";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { arrayBufferToBase64 } from "@/libs/crypto";
// Generate a random secret for the secret table
// It's not really secure to maintain the secret next to the user's data.
// However, until we have better hooks into a real wallet or reliable secure
// storage, we'll do this for user convenience. As they sign more records
// and integrate with more people, they'll value it more and want to be more
// secure, so we'll prompt them to take steps to back it up, properly encrypt,
// etc. At the beginning, we'll prompt for a password, then we'll prompt for a
// PWA so it's not in a browser... and then we hope to be integrated with a
// real wallet or something else more secure.
// One might ask: why encrypt at all? We figure a basic encryption is better
// than none. Plus, we expect to support their own password or keystore or
// external wallet as better signing options in the future, so it's gonna be
// important to have the structure where each account access might require
// user action.
// (Once upon a time we stored the secret in localStorage, but it frequently
// got erased, even though the IndexedDB still had the identity data. This
// ended up throwing lots of errors to the user... and they'd end up in a state
// where they couldn't take action because they couldn't unlock that identity.)
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const secretBase64 = arrayBufferToBase64(randomBytes);
// Each migration can include multiple SQL statements (with semicolons)
const MIGRATIONS = [
@@ -12,8 +39,8 @@ const MIGRATIONS = [
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identity TEXT,
mnemonic TEXT,
identityEncrBase64 TEXT, -- encrypted & base64-encoded
mnemonicEncrBase64 TEXT, -- encrypted & base64-encoded
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
@@ -22,9 +49,11 @@ const MIGRATIONS = [
CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret TEXT NOT NULL
secretBase64 TEXT NOT NULL
);
INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
@@ -59,6 +88,8 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
@@ -76,7 +107,7 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE IF NOT EXISTS logs (
date TEXT PRIMARY KEY,
date TEXT NOT NULL,
message TEXT NOT NULL
);
@@ -96,10 +127,7 @@ export async function registerMigrations(): Promise<void> {
}
export async function runMigrations(
sqlExec: (
sql: string,
params?: SqlValue[],
) => Promise<Array<QueryExecResult>>,
sqlExec: (sql: string, params?: unknown[]) => Promise<Array<QueryExecResult>>,
): Promise<void> {
await registerMigrations();
await migrationService.runMigrations(sqlExec);

308
src/db/databaseUtil.ts Normal file
View File

@@ -0,0 +1,308 @@
/**
* This file is the SQL replacement of the index.ts file in the db directory.
* That file will eventually be deleted.
*/
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { MASTER_SETTINGS_KEY, Settings } from "./tables/settings";
import { logger } from "../utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
import { QueryExecResult } from "../interfaces/database";
const formatLogObject = (obj: unknown): string => {
try {
return JSON.stringify(obj, null, 2);
} catch (error) {
return `[Object could not be stringified: ${error instanceof Error ? error.message : String(error)}]`;
}
};
export async function updateDefaultSettings(
settingsChanges: Settings,
): Promise<boolean> {
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id;
try {
const platformService = PlatformServiceFactory.getInstance();
const { sql, params } = generateUpdateStatement(
settingsChanges,
"settings",
"id = ?",
[MASTER_SETTINGS_KEY],
);
console.log("[databaseUtil] updateDefaultSettings", { sql, params });
const result = await platformService.dbExec(sql, params);
console.log("[databaseUtil] updateDefaultSettings result", { result });
return result.changes === 1;
} catch (error) {
logger.error("Error updating default settings:", error);
console.log("[databaseUtil] updateDefaultSettings error", { error });
if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message
} else {
throw new Error(
`Failed to update settings. We recommend you try again or restart the app.`,
);
}
}
}
export async function updateAccountSettings(
accountDid: string,
settingsChanges: Settings,
): Promise<boolean> {
settingsChanges.accountDid = accountDid;
delete settingsChanges.id; // key off account, not ID
const platform = PlatformServiceFactory.getInstance();
// First try to update existing record
const { sql: updateSql, params: updateParams } = generateUpdateStatement(
settingsChanges,
"settings",
"accountDid = ?",
[accountDid],
);
const updateResult = await platform.dbExec(updateSql, updateParams);
// If no record was updated, insert a new one
if (updateResult.changes === 1) {
return true;
} else {
const columns = Object.keys(settingsChanges);
const values = Object.values(settingsChanges);
const placeholders = values.map(() => "?").join(", ");
const insertSql = `INSERT INTO settings (${columns.join(", ")}) VALUES (${placeholders})`;
const result = await platform.dbExec(insertSql, values);
return result.changes === 1;
}
}
const DEFAULT_SETTINGS: Settings = {
id: MASTER_SETTINGS_KEY,
activeDid: undefined,
apiServer: DEFAULT_ENDORSER_API_SERVER,
};
// retrieves default settings
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
console.log('[DatabaseUtil] Retrieving default account settings');
const platform = PlatformServiceFactory.getInstance();
console.log('[DatabaseUtil] Platform service state:', {
platformType: platform.constructor.name,
capabilities: platform.getCapabilities(),
timestamp: new Date().toISOString()
});
const result = await platform.dbQuery("SELECT * FROM settings WHERE id = ?", [
MASTER_SETTINGS_KEY,
]);
if (!result) {
console.log('[DatabaseUtil] No settings found, returning defaults');
return DEFAULT_SETTINGS;
}
const settings = mapColumnsToValues(result.columns, result.values)[0] as Settings;
if (settings.searchBoxes) {
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
}
console.log('[DatabaseUtil] Retrieved settings:', {
settings: formatLogObject(settings),
timestamp: new Date().toISOString()
});
return settings;
}
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
console.log('[DatabaseUtil] Retrieving active account settings');
const defaultSettings = await retrieveSettingsForDefaultAccount();
console.log('[DatabaseUtil] Default settings retrieved:', {
defaultSettings: formatLogObject(defaultSettings),
timestamp: new Date().toISOString()
});
if (!defaultSettings.activeDid) {
console.log('[DatabaseUtil] No active DID, returning default settings');
return defaultSettings;
}
const platform = PlatformServiceFactory.getInstance();
const result = await platform.dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[defaultSettings.activeDid],
);
const overrideSettings = result
? (mapColumnsToValues(result.columns, result.values)[0] as Settings)
: {};
const overrideSettingsFiltered = Object.fromEntries(
Object.entries(overrideSettings).filter(([_, v]) => v !== null),
);
const settings = { ...defaultSettings, ...overrideSettingsFiltered };
if (settings.searchBoxes) {
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
}
console.log('[DatabaseUtil] Final active account settings:', {
settings: formatLogObject(settings),
timestamp: new Date().toISOString()
});
return settings;
}
let lastCleanupDate: string | null = null;
/**
* Logs a message to the database with proper handling of concurrent writes
* @param message - The message to log
* @author Matthew Raymer
*/
export async function logToDb(message: string): Promise<void> {
//const platform = PlatformServiceFactory.getInstance();
const todayKey = new Date().toDateString();
//const nowKey = new Date().toISOString();
try {
// Try to insert first, if it fails due to UNIQUE constraint, update instead
// await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [
// nowKey,
// message,
// ]);
// Clean up old logs (keep only last 7 days) - do this less frequently
// Only clean up if the date is different from the last cleanup
if (!lastCleanupDate || lastCleanupDate !== todayKey) {
// const sevenDaysAgo = new Date(
// new Date().getTime() - 7 * 24 * 60 * 60 * 1000,
// );
// await platform.dbExec("DELETE FROM logs WHERE date < ?", [
// sevenDaysAgo.toDateString(),
// ]);
lastCleanupDate = todayKey;
}
} catch (error) {
// Log to console as fallback
// eslint-disable-next-line no-console
console.error(
"Error logging to database:",
error,
" ... for original message:",
message,
);
}
}
// similar method is in the sw_scripts/additional-scripts.js file
export async function logConsoleAndDb(
message: string,
isError = false,
): Promise<void> {
if (isError) {
logger.error(`${new Date().toISOString()} ${message}`);
} else {
logger.log(`${new Date().toISOString()} ${message}`);
}
await logToDb(message);
}
/**
* Generates an SQL INSERT statement and parameters from a model object.
* @param model The model object containing fields to update
* @param tableName The name of the table to update
* @returns Object containing the SQL statement and parameters array
*/
export function generateInsertStatement(
model: Record<string, unknown>,
tableName: string,
): { sql: string; params: unknown[] } {
const columns = Object.keys(model).filter((key) => model[key] !== undefined);
const values = Object.values(model).filter((value) => value !== undefined);
const placeholders = values.map(() => "?").join(", ");
const insertSql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
return {
sql: insertSql,
params: values,
};
}
/**
* Generates an SQL UPDATE statement and parameters from a model object.
* @param model The model object containing fields to update
* @param tableName The name of the table to update
* @param whereClause The WHERE clause for the update (e.g. "id = ?")
* @param whereParams Parameters for the WHERE clause
* @returns Object containing the SQL statement and parameters array
*/
export function generateUpdateStatement(
model: Record<string, unknown>,
tableName: string,
whereClause: string,
whereParams: unknown[] = [],
): { sql: string; params: unknown[] } {
// Filter out undefined/null values and create SET clause
const setClauses: string[] = [];
const params: unknown[] = [];
Object.entries(model).forEach(([key, value]) => {
if (value !== undefined) {
setClauses.push(`${key} = ?`);
params.push(value);
}
});
if (setClauses.length === 0) {
throw new Error("No valid fields to update");
}
const sql = `UPDATE ${tableName} SET ${setClauses.join(", ")} WHERE ${whereClause}`;
return {
sql,
params: [...params, ...whereParams],
};
}
export function mapQueryResultToValues(
record: QueryExecResult | undefined,
): Array<Record<string, unknown>> {
if (!record) {
return [];
}
return mapColumnsToValues(record.columns, record.values) as Array<
Record<string, unknown>
>;
}
/**
* Maps an array of column names to an array of value arrays, creating objects where each column name
* is mapped to its corresponding value.
* @param columns Array of column names to use as object keys
* @param values Array of value arrays, where each inner array corresponds to one row of data
* @returns Array of objects where each object maps column names to their corresponding values
*/
export function mapColumnsToValues(
columns: string[],
values: unknown[][],
): Array<Record<string, unknown>> {
return values.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((column, index) => {
obj[column] = row[index];
});
return obj;
});
}

View File

@@ -1,3 +1,9 @@
/**
* This is the original IndexedDB version of the database.
* It will eventually be replaced fully by the SQL version in databaseUtil.ts.
* Turn this on or off with the USE_DEXIE_DB constant in constants/app.ts.
*/
import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import * as R from "ramda";
@@ -26,8 +32,8 @@ type NonsensitiveTables = {
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;

View File

@@ -45,6 +45,12 @@ export type Account = {
publicKeyHex: string;
};
// When finished with USE_DEXIE_DB, move these fields to Account and move identity and mnemonic here.
export type AccountEncrypted = Account & {
identityEncrBase64: string;
mnemonicEncrBase64: string;
};
/**
* Schema for the accounts table in the database.
* Fields starting with a $ character are encrypted.

View File

@@ -1,174 +0,0 @@
const { app, BrowserWindow } = require("electron");
const path = require("path");
const fs = require("fs");
const logger = require("../utils/logger");
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
},
});
// Always open DevTools for now
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = `file://${path.join(__dirname, "www", url)}`;
}
// Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: `file://${__dirname}`;
const assetPath = url.split("/assets/")[1];
const newUrl = `${baseDir}/www/assets/${assetPath}`;
callback({ redirectURL: newUrl });
return;
}
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
}
if (!fs.existsSync(indexPath)) {
logger.error(`Index file not found at: ${indexPath}`);
throw new Error("Index file not found");
}
// Add CSP headers to allow API connections
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self';" +
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
"img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
"style-src 'self' 'unsafe-inline';" +
"font-src 'self' data:;",
],
},
});
},
);
// Load the index.html
mainWindow
.loadFile(indexPath)
.then(() => {
logger.log("Successfully loaded index.html");
if (isDev) {
mainWindow.webContents.openDevTools();
logger.log("DevTools opened - running in dev mode");
}
})
.catch((err) => {
logger.error("Failed to load index.html:", err);
logger.error("Attempted path:", indexPath);
});
// Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, level, message) => {
logger.log("Renderer Console:", message);
});
// Add right after creating the BrowserWindow
mainWindow.webContents.on(
"did-fail-load",
(event, errorCode, errorDescription) => {
logger.error("Page failed to load:", errorCode, errorDescription);
},
);
mainWindow.webContents.on("preload-error", (event, preloadPath, error) => {
logger.error("Preload script error:", preloadPath, error);
});
mainWindow.webContents.on(
"console-message",
(event, level, message, line, sourceId) => {
logger.log("Renderer Console:", line, sourceId, message);
},
);
// Enable remote debugging when in dev mode
if (isDev) {
mainWindow.webContents.openDevTools();
}
}
// Handle app ready
app.whenReady().then(createWindow);
// Handle all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Handle any errors
process.on("uncaughtException", (error) => {
logger.error("Uncaught Exception:", error);
});

View File

@@ -1,187 +0,0 @@
import { app, BrowserWindow } from "electron";
import path from "path";
import fs from "fs";
// Simple logger implementation
const logger = {
// eslint-disable-next-line no-console
log: (...args: unknown[]) => console.log(...args),
// eslint-disable-next-line no-console
error: (...args: unknown[]) => console.error(...args),
// eslint-disable-next-line no-console
info: (...args: unknown[]) => console.info(...args),
// eslint-disable-next-line no-console
warn: (...args: unknown[]) => console.warn(...args),
// eslint-disable-next-line no-console
debug: (...args: unknown[]) => console.debug(...args),
};
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
function createWindow(): void {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
},
});
// Always open DevTools for now
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = `file://${path.join(__dirname, "www", url)}`;
}
// Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: `file://${__dirname}`;
const assetPath = url.split("/assets/")[1];
const newUrl = `${baseDir}/www/assets/${assetPath}`;
callback({ redirectURL: newUrl });
return;
}
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
}
if (!fs.existsSync(indexPath)) {
logger.error(`Index file not found at: ${indexPath}`);
throw new Error("Index file not found");
}
// Add CSP headers to allow API connections
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self';" +
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
"img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
"style-src 'self' 'unsafe-inline';" +
"font-src 'self' data:;",
],
},
});
},
);
// Load the index.html
mainWindow
.loadFile(indexPath)
.then(() => {
logger.log("Successfully loaded index.html");
if (isDev) {
mainWindow.webContents.openDevTools();
logger.log("DevTools opened - running in dev mode");
}
})
.catch((err) => {
logger.error("Failed to load index.html:", err);
logger.error("Attempted path:", indexPath);
});
// Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, _level, message) => {
logger.log("Renderer Console:", message);
});
// Add right after creating the BrowserWindow
mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription) => {
logger.error("Page failed to load:", errorCode, errorDescription);
},
);
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
logger.error("Preload script error:", preloadPath, error);
});
mainWindow.webContents.on(
"console-message",
(_event, _level, message, line, sourceId) => {
logger.log("Renderer Console:", line, sourceId, message);
},
);
// Enable remote debugging when in dev mode
if (isDev) {
mainWindow.webContents.openDevTools();
}
}
// Handle app ready
app.whenReady().then(createWindow);
// Handle all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Handle any errors
process.on("uncaughtException", (error) => {
logger.error("Uncaught Exception:", error);
});

View File

@@ -1,78 +0,0 @@
const { contextBridge, ipcRenderer } = require("electron");
const logger = {
log: (message, ...args) => {
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.log(message, ...args);
/* eslint-enable no-console */
}
},
warn: (message, ...args) => {
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.warn(message, ...args);
/* eslint-enable no-console */
}
},
error: (message, ...args) => {
/* eslint-disable no-console */
console.error(message, ...args); // Errors should always be logged
/* eslint-enable no-console */
},
};
// Use a more direct path resolution approach
const getPath = (pathType) => {
switch (pathType) {
case "userData":
return (
process.env.APPDATA ||
(process.platform === "darwin"
? `${process.env.HOME}/Library/Application Support`
: `${process.env.HOME}/.local/share`)
);
case "home":
return process.env.HOME;
case "appPath":
return process.resourcesPath;
default:
return "";
}
};
logger.log("Preload script starting...");
try {
contextBridge.exposeInMainWorld("electronAPI", {
// Path utilities
getPath,
// IPC functions
send: (channel, data) => {
const validChannels = ["toMain"];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
const validChannels = ["fromMain"];
if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
},
// Environment info
env: {
isElectron: true,
isDev: process.env.NODE_ENV === "development",
},
// Path utilities
getBasePath: () => {
return process.env.NODE_ENV === "development" ? "/" : "./";
},
});
logger.log("Preload script completed successfully");
} catch (error) {
logger.error("Error in preload script:", error);
}

59
src/interfaces/absurd-sql.d.ts vendored Normal file
View File

@@ -0,0 +1,59 @@
import type { QueryExecResult, SqlValue } from "./database";
declare module "@jlongster/sql.js" {
interface SQL {
Database: new (path: string, options?: { filename: boolean }) => AbsurdSqlDatabase;
FS: {
mkdir: (path: string) => void;
mount: (fs: any, options: any, path: string) => void;
open: (path: string, flags: string) => any;
close: (stream: any) => void;
};
register_for_idb: (fs: any) => void;
}
interface AbsurdSqlDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
run: (
sql: string,
params?: unknown[],
) => Promise<{ changes: number; lastId?: number }>;
}
const initSqlJs: (options?: {
locateFile?: (file: string) => string;
}) => Promise<SQL>;
export default initSqlJs;
}
declare module "absurd-sql" {
import type { SQL } from "@jlongster/sql.js";
export class SQLiteFS {
constructor(fs: any, backend: any);
}
}
declare module "absurd-sql/dist/indexeddb-backend" {
export default class IndexedDBBackend {
constructor();
}
}
declare module "absurd-sql/dist/indexeddb-main-thread" {
export interface SQLiteOptions {
filename?: string;
autoLoad?: boolean;
debug?: boolean;
}
export interface SQLiteDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
close: () => Promise<void>;
}
export function initSqlJs(options?: any): Promise<any>;
export function createDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
export function openDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
}

View File

@@ -1,15 +1,24 @@
import { GenericVerifiableCredential } from "./common";
/**
* Types of Claims
*
* Note that these are for the claims that get signed.
* Records that are the latest edited entities are in the records.ts file.
*
*/
export interface AgreeVerifiableCredential {
"@context": string;
import { ClaimObject } from "./common";
export interface AgreeActionClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": string;
object: Record<string, unknown>;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string;
export interface GiveActionClaim extends ClaimObject {
// context is optional because it might be embedded in another claim, eg. an AgreeAction
"@context"?: "https://schema.org";
"@type": "GiveAction";
agent?: { identifier: string };
description?: string;
@@ -17,16 +26,25 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential;
provider?: ClaimObject;
recipient?: { identifier: string };
}
export interface JoinActionClaim extends ClaimObject {
agent?: { identifier: string };
event?: { organizer?: { name: string }; name?: string; startTime?: string };
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string;
export interface OfferClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": "Offer";
agent?: { identifier: string };
description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string;
image?: string;
includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: {
description?: string;
@@ -37,14 +55,18 @@ export interface OfferVerifiableCredential extends GenericVerifiableCredential {
name?: string;
};
};
offeredBy?: { identifier: string };
offeredBy?: {
type?: "Person";
identifier: string;
};
provider?: ClaimObject;
recipient?: { identifier: string };
validThrough?: string;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
export interface PlanActionClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": "PlanAction";
name: string;
@@ -58,11 +80,18 @@ export interface PlanVerifiableCredential extends GenericVerifiableCredential {
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
"@context": string;
export interface RegisterActionClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": "RegisterAction";
agent: { identifier: string };
identifier?: string;
object: string;
object?: string;
participant?: { identifier: string };
}
export interface TenureClaim extends ClaimObject {
"@context": "https://endorser.ch";
"@type": "Tenure";
party?: { identifier: string };
spatialUnit?: { geo?: { polygon?: string } };
}

View File

@@ -34,3 +34,77 @@ export interface ErrorResult extends ResultWithType {
type: "error";
error: InternalError;
}
export interface KeyMeta {
did: string;
publicKeyHex: string;
derivationPath?: string;
passkeyCredIdHex?: string; // The Webauthn credential ID in hex, if this is from a passkey
}
export interface KeyMetaMaybeWithPrivate extends KeyMeta {
mnemonic?: string; // 12 or 24 words encoding the seed
identity?: string; // Stringified IIdentifier object from Veramo
}
export interface KeyMetaWithPrivate extends KeyMeta {
mnemonic: string; // 12 or 24 words encoding the seed
identity: string; // Stringified IIdentifier object from Veramo
}
export interface QuantitativeValue extends GenericVerifiableCredential {
"@type": "QuantitativeValue";
"@context"?: string;
amountOfThisGood: number;
unitCode: string;
}
export interface AxiosErrorResponse {
message?: string;
response?: {
data?: {
error?: {
message?: string;
};
[key: string]: unknown;
};
status?: number;
config?: unknown;
};
config?: unknown;
[key: string]: unknown;
}
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
export interface CreateAndSubmitClaimResult {
success: boolean;
error?: string;
handleId?: string;
}
export interface Agent {
identifier?: string;
did?: string;
}
export interface ClaimObject {
"@type": string;
"@context"?: string;
[key: string]: unknown;
}
export interface VerifiableCredentialClaim {
"@context"?: string;
"@type": string;
type: string[];
credentialSubject: ClaimObject;
[key: string]: unknown;
}

View File

@@ -1,7 +1,38 @@
export * from "./claims";
export * from "./claims-result";
export * from "./common";
export type {
// From common.ts
GenericCredWrapper,
GenericVerifiableCredential,
KeyMeta,
// Exclude types that are also exported from other files
// GiveVerifiableCredential,
// OfferVerifiableCredential,
// RegisterVerifiableCredential,
// PlanSummaryRecord,
// UserInfo,
} from "./common";
export type {
// From claims.ts
GiveActionClaim,
OfferClaim,
RegisterActionClaim,
} from "./claims";
export type {
// From claims-result.ts
CreateAndSubmitClaimResult,
} from "./claims-result";
export type {
// From records.ts
PlanSummaryRecord,
GiveSummaryRecord,
} from "./records";
export type {
// From user.ts
UserInfo,
} from "./user";
export * from "./limits";
export * from "./records";
export * from "./user";
export * from "./deepLinks";

View File

@@ -1,14 +1,14 @@
import { GiveVerifiableCredential, OfferVerifiableCredential } from "./claims";
import { GiveActionClaim, OfferClaim } from "./claims";
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveVerifiableCredential;
[x: string]: PropertyKey | undefined | GiveActionClaim;
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fullClaim: GiveActionClaim;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
fulfillsType?: string;
@@ -26,7 +26,7 @@ export interface OfferSummaryRecord {
amount: number;
amountGiven: number;
amountGivenConfirmed: number;
fullClaim: OfferVerifiableCredential;
fullClaim: OfferClaim;
fulfillsPlanHandleId: string;
handleId: string;
issuerDid: string;

View File

@@ -159,7 +159,7 @@ export const nextDerivationPath = (origDerivPath: string) => {
};
// Base64 encoding/decoding utilities for browser
function base64ToArrayBuffer(base64: string): Uint8Array {
export function base64ToArrayBuffer(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
@@ -168,7 +168,7 @@ function base64ToArrayBuffer(base64: string): Uint8Array {
return bytes;
}
function arrayBufferToBase64(buffer: ArrayBuffer): string {
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const binary = String.fromCharCode(...new Uint8Array(buffer));
return btoa(binary);
}
@@ -178,7 +178,7 @@ const IV_LENGTH = 12;
const KEY_LENGTH = 256;
const ITERATIONS = 100000;
// Encryption helper function
// Message encryption helper function, used for onboarding meeting messages
export async function encryptMessage(message: string, password: string) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
@@ -226,7 +226,7 @@ export async function encryptMessage(message: string, password: string) {
return btoa(JSON.stringify(result));
}
// Decryption helper function
// Message decryption helper function, used for onboarding meeting messages
export async function decryptMessage(encryptedJson: string, password: string) {
const decoder = new TextDecoder();
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
@@ -273,7 +273,7 @@ export async function decryptMessage(encryptedJson: string, password: string) {
}
// Test function to verify encryption/decryption
export async function testEncryptionDecryption() {
export async function testMessageEncryptionDecryption() {
try {
const testMessage = "Hello, this is a test message! 🚀";
const testPassword = "myTestPassword123";
@@ -299,9 +299,111 @@ export async function testEncryptionDecryption() {
logger.log("\nTesting with wrong password...");
try {
await decryptMessage(encrypted, "wrongPassword");
logger.log("Should not reach here");
logger.log("Incorrectly decrypted with wrong password ❌");
} catch (error) {
logger.log("Correctly failed with wrong password ✅");
logger.log("Correctly failed to decrypt with wrong password ✅");
}
return success;
} catch (error) {
logger.error("Test failed with error:", error);
return false;
}
}
// Simple encryption using Node's crypto, used for the initial encryption of the identity and mnemonic
export async function simpleEncrypt(
text: string,
secret: ArrayBuffer,
): Promise<ArrayBuffer> {
const iv = crypto.getRandomValues(new Uint8Array(16));
// Derive a 256-bit key from the secret using SHA-256
const keyData = await crypto.subtle.digest("SHA-256", secret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
false,
["encrypt"],
);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(text),
);
// Combine IV and encrypted data
const result = new Uint8Array(iv.length + encrypted.byteLength);
result.set(iv);
result.set(new Uint8Array(encrypted), iv.length);
return result.buffer;
}
// Simple decryption using Node's crypto, used for the default decryption of identity and mnemonic
export async function simpleDecrypt(
encryptedText: ArrayBuffer,
secret: ArrayBuffer,
): Promise<string> {
const data = new Uint8Array(encryptedText);
// Extract IV and encrypted data
const iv = data.slice(0, 16);
const encrypted = data.slice(16);
// Derive the same 256-bit key from the secret using SHA-256
const keyData = await crypto.subtle.digest("SHA-256", secret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
false,
["decrypt"],
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
encrypted,
);
return new TextDecoder().decode(decrypted);
}
// Test function for simple encryption/decryption
export async function testSimpleEncryptionDecryption() {
try {
const testMessage = "Hello, this is a test message! 🚀";
const testSecret = crypto.getRandomValues(new Uint8Array(32));
logger.log("Original message:", testMessage);
// Test encryption
logger.log("Encrypting...");
const encrypted = await simpleEncrypt(testMessage, testSecret);
const encryptedBase64 = arrayBufferToBase64(encrypted);
logger.log("Encrypted result:", encryptedBase64);
// Test decryption
logger.log("Decrypting...");
const encryptedArrayBuffer = base64ToArrayBuffer(encryptedBase64);
const decrypted = await simpleDecrypt(encryptedArrayBuffer, testSecret);
logger.log("Decrypted result:", decrypted);
// Verify
const success = testMessage === decrypted;
logger.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
logger.log("Messages match:", success);
// Test with wrong secret
logger.log("\nTesting with wrong secret...");
try {
await simpleDecrypt(encryptedArrayBuffer, new Uint8Array(32));
logger.log("Incorrectly decrypted with wrong secret ❌");
} catch (error) {
logger.log("Correctly failed to decrypt with wrong secret ✅");
}
return success;

View File

@@ -17,29 +17,12 @@ import { didEthLocalResolver } from "./did-eth-local-resolver";
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util";
import { KeyMeta, KeyMetaWithPrivate } from "../../../interfaces/common";
export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver });
/**
@@ -51,7 +34,7 @@ export function isFromPasskey(keyMeta?: KeyMeta): boolean {
}
export async function createEndorserJwtForKey(
account: KeyMeta,
account: KeyMetaWithPrivate,
payload: object,
expiresIn?: number,
) {

View File

@@ -1,7 +1,6 @@
import { Buffer } from "buffer/";
import { JWTPayload } from "did-jwt";
import { DIDResolutionResult } from "did-resolver";
import { sha256 } from "ethereum-cryptography/sha256.js";
import { p256 } from "@noble/curves/p256";
import {
startAuthentication,
startRegistration,
@@ -11,12 +10,13 @@ import {
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
VerifyAuthenticationResponseOpts,
} from "@simplewebauthn/server";
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
import {
Base64URLString,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
AuthenticatorAssertionResponse,
} from "@simplewebauthn/types";
import { AppString } from "../../../constants/app";
@@ -194,16 +194,19 @@ export class PeerSetup {
},
};
const credential = await navigator.credentials.get(options);
const credential = (await navigator.credentials.get(
options,
)) as PublicKeyCredential;
// console.log("nav credential get", credential);
this.authenticatorData = credential?.response.authenticatorData;
const response = credential?.response as AuthenticatorAssertionResponse;
this.authenticatorData = response?.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData as ArrayBuffer,
);
this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
credential?.response.clientDataJSON,
response?.clientDataJSON,
);
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
@@ -228,9 +231,7 @@ export class PeerSetup {
.replace(/\//g, "_")
.replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature).toString(
"base64",
);
const origSignature = Buffer.from(response?.signature).toString("base64");
this.signature = origSignature
.replace(/\+/g, "-")
.replace(/\//g, "_")
@@ -315,24 +316,18 @@ export async function createDidPeerJwt(
// ... and this import:
// import { p256 } from "@noble/curves/p256";
export async function verifyJwtP256(
credIdHex: string,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
// Use challenge in preimage construction
const preimage = Buffer.concat([authDataFromBase, Buffer.from(challenge)]);
const isValid = p256.verify(
finalSigBuffer,
@@ -383,122 +378,37 @@ export async function verifyJwtSimplewebauthn(
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature
export async function verifyJwtWebCrypto(
credId: Base64URLString,
issuerDid: string,
authenticatorData: ArrayBuffer,
challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString,
) {
const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer);
// Hash the client data
const hash = sha256(clientDataFromBase);
// Use challenge in preimage construction
const preimage = Buffer.concat([authDataFromBase, Buffer.from(challenge)]);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
if (!did.startsWith("did:peer:0z")) {
throw new Error(
"This only verifies a peer DID, method 0, encoded base58btc.",
);
}
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
// (another reference is the @aviarytech/did-peer resolver)
// Remove unused functions:
// - peerDidToDidDocument
// - COSEtoPEM
// - base64urlDecodeArrayBuffer
// - base64urlEncodeArrayBuffer
// - pemToCryptoKey
/**
* Looks like JsonWebKey2020 isn't too difficult:
* - change context security/suites link to jws-2020/v1
* - change publicKeyMultibase to publicKeyJwk generated with cborToKeys
* - change type to JsonWebKey2020
*/
const id = did.split(":")[2];
const multibase = id.slice(1);
const encnumbasis = multibase.slice(1);
const didDocument = {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
],
assertionMethod: [did + "#" + encnumbasis],
authentication: [did + "#" + encnumbasis],
capabilityDelegation: [did + "#" + encnumbasis],
capabilityInvocation: [did + "#" + encnumbasis],
id: did,
keyAgreement: undefined,
service: undefined,
verificationMethod: [
{
controller: did,
id: did + "#" + encnumbasis,
publicKeyMultibase: multibase,
type: "EcdsaSecp256k1VerificationKey2019",
},
],
};
return {
didDocument,
didDocumentMetadata: {},
didResolutionMetadata: { contentType: "application/did+ld+json" },
};
}
// convert COSE public key to PEM format
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate
const y = cose[-3]; // y-coordinate
// Ensure the coordinates are in the correct format
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error because it complains about the type of x and y
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format
const pem = `-----BEGIN PUBLIC KEY-----
${pubKeyBuffer.toString("base64")}
-----END PUBLIC KEY-----`;
return pem;
}
// tried the base64url library but got an error using their Buffer
// Keep only the used functions:
export function base64urlDecodeString(input: string) {
return atob(input.replace(/-/g, "+").replace(/_/g, "/"));
}
// tried the base64url library but got an error using their Buffer
export function base64urlEncodeString(input: string) {
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlDecodeArrayBuffer(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
const str = atob(input + pad);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes.buffer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return base64urlEncodeString(str);
}
// from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer);
@@ -523,28 +433,3 @@ function base64URLStringToArrayBuffer(base64URLString: string) {
}
return buffer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function pemToCryptoKey(pem: string) {
const binaryDerString = atob(
pem
.split("\n")
.filter((x) => !x.includes("-----"))
.join(""),
);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
// console.log("binaryDer", binaryDer.buffer);
return await window.crypto.subtle.importKey(
"spki",
binaryDer.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["verify"],
);
}

View File

@@ -26,29 +26,42 @@ import {
DEFAULT_IMAGE_API_SERVER,
NotificationIface,
APP_SERVER,
USE_DEXIE_DB,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { accessToken, deriveAddress, nextDerivationPath } from "../libs/crypto";
import { logConsoleAndDb, NonsensitiveDexie } from "../db/index";
import { NonsensitiveDexie } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
import {
retrieveAccountMetadata,
retrieveFullyDecryptedAccount,
getPasskeyExpirationSeconds,
} from "../libs/util";
import { createEndorserJwtForKey, KeyMeta } from "../libs/crypto/vc";
import { createEndorserJwtForKey } from "../libs/crypto/vc";
import {
GiveActionClaim,
JoinActionClaim,
OfferClaim,
PlanActionClaim,
RegisterActionClaim,
TenureClaim,
} from "../interfaces/claims";
import {
GiveVerifiableCredential,
OfferVerifiableCredential,
RegisterVerifiableCredential,
GenericVerifiableCredential,
GenericCredWrapper,
PlanSummaryRecord,
GenericVerifiableCredential,
AxiosErrorResponse,
UserInfo,
CreateAndSubmitClaimResult,
} from "../interfaces";
ClaimObject,
VerifiableCredentialClaim,
QuantitativeValue,
KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
/**
* Standard context for schema.org data
@@ -100,7 +113,10 @@ export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{
claim: { "@type": "" },
claim: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "",
},
handleId: "",
id: "",
issuedAt: "",
@@ -180,37 +196,21 @@ export function isEmptyOrHiddenDid(did?: string): boolean {
* };
* testRecursivelyOnStrings(isHiddenDid, obj); // Returns: true
*/
function testRecursivelyOnStrings(
func: (arg0: unknown) => boolean,
const testRecursivelyOnStrings = (
input: unknown,
): boolean {
// Test direct string values
if (Object.prototype.toString.call(input) === "[object String]") {
return func(input);
test: (s: string) => boolean,
): boolean => {
if (typeof input === "string") {
return test(input);
} else if (Array.isArray(input)) {
return input.some((item) => testRecursivelyOnStrings(item, test));
} else if (input && typeof input === "object") {
return Object.values(input as Record<string, unknown>).some((value) =>
testRecursivelyOnStrings(value, test),
);
}
// Recursively test objects and arrays
else if (input instanceof Object) {
if (!Array.isArray(input)) {
// Handle plain objects
for (const key in input) {
if (testRecursivelyOnStrings(func, input[key])) {
return true;
}
}
} else {
// Handle arrays
for (const value of input) {
if (testRecursivelyOnStrings(func, value)) {
return true;
}
}
}
return false;
} else {
// Non-string, non-object values can't contain strings
return false;
}
}
return false;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function containsHiddenDid(obj: any) {
@@ -551,7 +551,11 @@ export async function setPlanInCache(
* @returns {string|undefined} User-friendly message or undefined if none found
*/
export function serverMessageForUser(error: unknown): string | undefined {
return error?.response?.data?.error?.message;
if (error && typeof error === "object" && "response" in error) {
const err = error as AxiosErrorResponse;
return err.response?.data?.error?.message;
}
return undefined;
}
/**
@@ -573,18 +577,27 @@ export function errorStringForLog(error: unknown) {
// --- property '_value' closes the circle
}
let fullError = "" + error + " - JSON: " + stringifiedError;
const errorResponseText = JSON.stringify(error.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
// add error.response stuff
if (R.equals(error?.config, error?.response?.config)) {
// but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify(
R.omit(["config"] as never[], error.response),
);
fullError += " - .response w/o same config JSON: " + newErrorResponseText;
} else {
fullError += " - .response JSON: " + errorResponseText;
if (error && typeof error === "object" && "response" in error) {
const err = error as AxiosErrorResponse;
const errorResponseText = JSON.stringify(err.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
// add error.response stuff
if (
err.response?.config &&
err.config &&
R.equals(err.config, err.response.config)
) {
// but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify(
R.omit(["config"] as never[], err.response),
);
fullError +=
" - .response w/o same config JSON: " + newErrorResponseText;
} else {
fullError += " - .response JSON: " + errorResponseText;
}
}
}
return fullError;
@@ -642,7 +655,7 @@ export async function getNewOffersToUserProjects(
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateGive(
vcClaimOrig?: GiveVerifiableCredential,
vcClaimOrig?: GiveActionClaim,
fromDid?: string,
toDid?: string,
description?: string,
@@ -650,14 +663,12 @@ export function hydrateGive(
unitCode?: string,
fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string,
isTrade: boolean = false, // remove, because this app is all for gifting
isTrade: boolean = false,
imageUrl?: string,
providerPlanHandleId?: string,
lastClaimId?: string,
): GiveVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: GiveVerifiableCredential = vcClaimOrig
): GiveActionClaim {
const vcClaim: GiveActionClaim = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
@@ -665,55 +676,72 @@ export function hydrateGive(
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
vcClaim.agent = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
if (fromDid) {
vcClaim.agent = { identifier: fromDid };
}
if (toDid) {
vcClaim.recipient = { identifier: toDid };
}
vcClaim.description = description || undefined;
vcClaim.object =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
// ensure fulfills is an array
if (amount && !isNaN(amount)) {
const quantitativeValue: QuantitativeValue = {
"@type": "QuantitativeValue",
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
};
vcClaim.object = quantitativeValue;
}
// Initialize fulfills array if not present
if (!Array.isArray(vcClaim.fulfills)) {
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
}
// ... and replace or add each element, ending with Trade or Donate
// I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action.
// Filter and add fulfills elements
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction",
(elem: { "@type": string }) => elem["@type"] !== "PlanAction",
);
if (fulfillsProjectHandleId) {
vcClaim.fulfills.push({
"@type": "PlanAction",
identifier: fulfillsProjectHandleId,
});
}
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "Offer",
(elem: { "@type": string }) => elem["@type"] !== "Offer",
);
if (fulfillsOfferHandleId) {
vcClaim.fulfills.push({
"@type": "Offer",
identifier: fulfillsOfferHandleId,
});
}
// do Trade/Donate last because current endorser.ch only looks at the first for plans & offers
vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) =>
(elem: { "@type": string }) =>
elem["@type"] !== "DonateAction" && elem["@type"] !== "TradeAction",
);
vcClaim.fulfills.push({ "@type": isTrade ? "TradeAction" : "DonateAction" });
vcClaim.fulfills.push({
"@type": isTrade ? "TradeAction" : "DonateAction",
});
vcClaim.image = imageUrl || undefined;
vcClaim.provider = providerPlanHandleId
? { "@type": "PlanAction", identifier: providerPlanHandleId }
: undefined;
if (providerPlanHandleId) {
vcClaim.provider = {
"@type": "PlanAction",
identifier: providerPlanHandleId,
};
}
return vcClaim;
}
@@ -774,7 +802,7 @@ export async function createAndSubmitGive(
export async function editAndSubmitGive(
axios: Axios,
apiServer: string,
fullClaim: GenericCredWrapper<GiveVerifiableCredential>,
fullClaim: GenericCredWrapper<GiveActionClaim>,
issuerDid: string,
fromDid?: string,
toDid?: string,
@@ -815,7 +843,7 @@ export async function editAndSubmitGive(
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateOffer(
vcClaimOrig?: OfferVerifiableCredential,
vcClaimOrig?: OfferClaim,
fromDid?: string,
toDid?: string,
itemDescription?: string,
@@ -825,10 +853,8 @@ export function hydrateOffer(
fulfillsProjectHandleId?: string,
validThrough?: string,
lastClaimId?: string,
): OfferVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: OfferVerifiableCredential = vcClaimOrig
): OfferClaim {
const vcClaim: OfferClaim = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
@@ -841,14 +867,20 @@ export function hydrateOffer(
delete vcClaim.identifier;
}
vcClaim.offeredBy = fromDid ? { identifier: fromDid } : undefined;
vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
if (fromDid) {
vcClaim.offeredBy = { identifier: fromDid };
}
if (toDid) {
vcClaim.recipient = { identifier: toDid };
}
vcClaim.description = conditionDescription || undefined;
vcClaim.includesObject =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
if (amount && !isNaN(amount)) {
vcClaim.includesObject = {
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
};
}
if (itemDescription || fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {};
@@ -860,6 +892,7 @@ export function hydrateOffer(
};
}
}
vcClaim.validThrough = validThrough || undefined;
return vcClaim;
@@ -899,7 +932,7 @@ export async function createAndSubmitOffer(
undefined,
);
return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential,
vcClaim as OfferClaim,
issuerDid,
apiServer,
axios,
@@ -909,7 +942,7 @@ export async function createAndSubmitOffer(
export async function editAndSubmitOffer(
axios: Axios,
apiServer: string,
fullClaim: GenericCredWrapper<OfferVerifiableCredential>,
fullClaim: GenericCredWrapper<OfferClaim>,
issuerDid: string,
itemDescription: string,
amount?: number,
@@ -932,7 +965,7 @@ export async function editAndSubmitOffer(
fullClaim.id,
);
return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential,
vcClaim as OfferClaim,
issuerDid,
apiServer,
axios,
@@ -968,11 +1001,12 @@ export async function createAndSubmitClaim(
axios: Axios,
): Promise<CreateAndSubmitClaimResult> {
try {
const vcPayload = {
const vcPayload: { vc: VerifiableCredentialClaim } = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"@context": "https://www.w3.org/2018/credentials/v1",
"@type": "VerifiableCredential",
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
credentialSubject: vcClaim as unknown as ClaimObject,
},
};
@@ -988,26 +1022,25 @@ export async function createAndSubmitClaim(
},
});
return { type: "success", response };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
return { success: true, handleId: response.data?.handleId };
} catch (error: unknown) {
logger.error("Error submitting claim:", error);
const errorMessage: string =
serverMessageForUser(error) ||
error.message ||
(error && typeof error === "object" && "message" in error
? String(error.message)
: undefined) ||
"Got some error submitting the claim. Check your permissions, network, and error logs.";
return {
type: "error",
error: {
error: errorMessage,
},
success: false,
error: errorMessage,
};
}
}
export async function generateEndorserJwtUrlForAccount(
account: KeyMeta,
account: KeyMetaMaybeWithPrivate,
isRegistered: boolean,
givenName: string,
profileImageUrl: string,
@@ -1031,12 +1064,9 @@ export async function generateEndorserJwtUrlForAccount(
}
// Add the next key -- not recommended for the QR code for such a high resolution
if (isContact && account?.mnemonic && account?.derivationPath) {
const newDerivPath = nextDerivationPath(account.derivationPath as string);
const nextPublicHex = deriveAddress(
account.mnemonic as string,
newDerivPath,
)[2];
if (isContact && account.derivationPath && account.mnemonic) {
const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 =
@@ -1056,7 +1086,11 @@ export async function createEndorserJwtForDid(
expiresIn?: number,
) {
const account = await retrieveFullyDecryptedAccount(issuerDid);
return createEndorserJwtForKey(account as KeyMeta, payload, expiresIn);
return createEndorserJwtForKey(
account as KeyMetaWithPrivate,
payload,
expiresIn,
);
}
/**
@@ -1104,21 +1138,21 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
similar code is also contained in endorser-mobile
**/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const claimSummary = (
claim: GenericCredWrapper<GenericVerifiableCredential>,
claim:
| GenericVerifiableCredential
| GenericCredWrapper<GenericVerifiableCredential>,
) => {
if (!claim) {
// to differentiate from "something" above
return "something";
}
let specificClaim:
| GenericVerifiableCredential
| GenericCredWrapper<GenericVerifiableCredential> = claim;
if (claim.claim) {
// probably a Verified Credential
// eslint-disable-next-line @typescript-eslint/no-explicit-any
specificClaim = claim.claim;
let specificClaim: GenericVerifiableCredential;
if ("claim" in claim) {
// It's a GenericCredWrapper
specificClaim = claim.claim as GenericVerifiableCredential;
} else {
// It's already a GenericVerifiableCredential
specificClaim = claim;
}
if (Array.isArray(specificClaim)) {
if (specificClaim.length === 1) {
@@ -1153,88 +1187,112 @@ export const claimSpecialDescription = (
identifiers: Array<string>,
contacts: Array<Contact>,
) => {
let claim = record.claim;
if (claim.claim) {
// it's probably a Verified Credential
claim = claim.claim;
let claim:
| GenericVerifiableCredential
| GenericCredWrapper<GenericVerifiableCredential> = record.claim;
if ("claim" in claim) {
// it's a nested GenericCredWrapper
claim = claim.claim as GenericVerifiableCredential;
}
const issuer = didInfo(record.issuer, activeDid, identifiers, contacts);
const type = claim["@type"] || "UnknownType";
if (type === "AgreeAction") {
return issuer + " agreed with " + claimSummary(claim.object);
return (
issuer +
" agreed with " +
claimSummary(claim.object as GenericVerifiableCredential)
);
} else if (isAccept(claim)) {
return issuer + " accepted " + claimSummary(claim.object);
return (
issuer +
" accepted " +
claimSummary(claim.object as GenericVerifiableCredential)
);
} else if (type === "GiveAction") {
// agent.did is for legacy data, before March 2023
const giver = claim.agent?.identifier || claim.agent?.did;
const giveClaim = claim as GiveActionClaim;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyGiverDid = giveClaim.agent?.did;
const giver = giveClaim.agent?.identifier || legacyGiverDid;
const giverInfo = didInfo(giver, activeDid, identifiers, contacts);
let gaveAmount = claim.object?.amountOfThisGood
? displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
let gaveAmount = giveClaim.object?.amountOfThisGood
? displayAmount(
giveClaim.object.unitCode as string,
giveClaim.object.amountOfThisGood as number,
)
: "";
if (claim.description) {
if (giveClaim.description) {
if (gaveAmount) {
gaveAmount = gaveAmount + ", and also: ";
}
gaveAmount = gaveAmount + claim.description;
gaveAmount = gaveAmount + giveClaim.description;
}
if (!gaveAmount) {
gaveAmount = "something not described";
}
// recipient.did is for legacy data, before March 2023
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyRecipDid = giveClaim.recipient?.did;
const gaveRecipientId = giveClaim.recipient?.identifier || legacyRecipDid;
const gaveRecipientInfo = gaveRecipientId
? " to " + didInfo(gaveRecipientId, activeDid, identifiers, contacts)
: "";
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
} else if (type === "JoinAction") {
// agent.did is for legacy data, before March 2023
const agent = claim.agent?.identifier || claim.agent?.did;
const joinClaim = claim as JoinActionClaim;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyDid = joinClaim.agent?.did;
const agent = joinClaim.agent?.identifier || legacyDid;
const contactInfo = didInfo(agent, activeDid, identifiers, contacts);
let eventOrganizer =
claim.event && claim.event.organizer && claim.event.organizer.name;
joinClaim.event &&
joinClaim.event.organizer &&
joinClaim.event.organizer.name;
eventOrganizer = eventOrganizer || "";
let eventName = claim.event && claim.event.name;
let eventName = joinClaim.event && joinClaim.event.name;
eventName = eventName ? " " + eventName : "";
let fullEvent = eventOrganizer + eventName;
fullEvent = fullEvent ? " attended the " + fullEvent : "";
let eventDate = claim.event && claim.event.startTime;
let eventDate = joinClaim.event && joinClaim.event.startTime;
eventDate = eventDate ? " at " + eventDate : "";
return contactInfo + fullEvent + eventDate;
} else if (isOffer(claim)) {
const offerer = claim.offeredBy?.identifier;
const offerClaim = claim as OfferClaim;
const offerer = offerClaim.offeredBy?.identifier;
const contactInfo = didInfo(offerer, activeDid, identifiers, contacts);
let offering = "";
if (claim.includesObject) {
if (offerClaim.includesObject) {
offering +=
" " +
displayAmount(
claim.includesObject.unitCode,
claim.includesObject.amountOfThisGood,
offerClaim.includesObject.unitCode,
offerClaim.includesObject.amountOfThisGood,
);
}
if (claim.itemOffered?.description) {
offering += ", saying: " + claim.itemOffered?.description;
if (offerClaim.itemOffered?.description) {
offering += ", saying: " + offerClaim.itemOffered?.description;
}
// recipient.did is for legacy data, before March 2023
const offerRecipientId =
claim.recipient?.identifier || claim.recipient?.did;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyDid = offerClaim.recipient?.did;
const offerRecipientId = offerClaim.recipient?.identifier || legacyDid;
const offerRecipientInfo = offerRecipientId
? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts)
: "";
return contactInfo + " offered" + offering + offerRecipientInfo;
} else if (type === "PlanAction") {
const claimer = claim.agent?.identifier || record.issuer;
const planClaim = claim as PlanActionClaim;
const claimer = planClaim.agent?.identifier || record.issuer;
const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts);
return claimerInfo + " announced a project: " + claim.name;
return claimerInfo + " announced a project: " + planClaim.name;
} else if (type === "Tenure") {
// party.did is for legacy data, before March 2023
const claimer = claim.party?.identifier || claim.party?.did;
const tenureClaim = claim as TenureClaim;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyDid = tenureClaim.party?.did;
const claimer = tenureClaim.party?.identifier || legacyDid;
const contactInfo = didInfo(claimer, activeDid, identifiers, contacts);
const polygon = claim.spatialUnit?.geo?.polygon || "";
const polygon = tenureClaim.spatialUnit?.geo?.polygon || "";
return (
contactInfo +
" possesses [" +
@@ -1242,11 +1300,7 @@ export const claimSpecialDescription = (
"...]"
);
} else {
return (
issuer +
" declared " +
claimSummary(claim as GenericCredWrapper<GenericVerifiableCredential>)
);
return issuer + " declared " + claimSummary(claim);
}
};
@@ -1278,7 +1332,7 @@ export async function createEndorserJwtVcFromClaim(
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"@context": "https://www.w3.org/2018/credentials/v1",
type: ["VerifiableCredential"],
credentialSubject: claim,
},
@@ -1286,32 +1340,42 @@ export async function createEndorserJwtVcFromClaim(
return createEndorserJwtForDid(issuerDid, vcPayload);
}
/**
* Create a JWT for a RegisterAction claim.
*
* @param activeDid - The DID of the user creating the invite
* @param contact - The contact to register, with a 'did' field (all optional for invites)
* @param identifier - The identifier for the invite, usually random
* @param expiresIn - The number of seconds until the invite expires
* @returns The JWT for the RegisterAction claim
*/
export async function createInviteJwt(
activeDid: string,
contact?: Contact,
inviteId?: string,
expiresIn?: number,
identifier?: string,
expiresIn?: number, // in seconds
): Promise<string> {
const vcClaim: RegisterVerifiableCredential = {
const vcClaim: RegisterActionClaim = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "RegisterAction",
agent: { identifier: activeDid },
object: SERVICE_ID,
identifier: identifier,
};
if (contact) {
if (contact?.did) {
vcClaim.participant = { identifier: contact.did };
}
if (inviteId) {
vcClaim.identifier = inviteId;
}
// Make a payload for the claim
const vcPayload = {
const vcPayload: { vc: VerifiableCredentialClaim } = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"@context": "https://www.w3.org/2018/credentials/v1",
"@type": "VerifiableCredential",
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
credentialSubject: vcClaim as unknown as ClaimObject,
},
};
// Create a signature using private key of identity
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload, expiresIn);
return vcJwt;
@@ -1323,21 +1387,44 @@ export async function register(
axios: Axios,
contact: Contact,
): Promise<{ success?: boolean; error?: string }> {
const vcJwt = await createInviteJwt(activeDid, contact);
try {
const vcJwt = await createInviteJwt(activeDid, contact);
const url = apiServer + "/api/v2/claim";
const resp = await axios.post<{
success?: {
handleId?: string;
embeddedRecordError?: string;
};
error?: string;
message?: string;
}>(url, { jwtEncoded: vcJwt });
const url = apiServer + "/api/v2/claim";
const resp = await axios.post(url, { jwtEncoded: vcJwt });
if (resp.data?.success?.handleId) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
let message =
"There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError == "string") {
message += " " + resp.data.success.embeddedRecordError;
if (resp.data?.success?.handleId) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
let message =
"There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError === "string") {
message += " " + resp.data.success.embeddedRecordError;
}
return { error: message };
} else {
logger.error("Registration error:", JSON.stringify(resp.data));
return { error: "Got a server error when registering." };
}
} catch (error: unknown) {
if (error && typeof error === "object") {
const err = error as AxiosErrorResponse;
const errorMessage =
err.message ||
(err.response?.data &&
typeof err.response.data === "object" &&
"message" in err.response.data
? (err.response.data as { message: string }).message
: undefined);
logger.error("Registration error:", errorMessage || JSON.stringify(err));
return { error: errorMessage || "Got a server error when registering." };
}
return { error: message };
} else {
logger.error(resp);
return { error: "Got a server error when registering." };
}
}
@@ -1363,7 +1450,14 @@ export async function setVisibilityUtil(
if (resp.status === 200) {
const success = resp.data.success;
if (success) {
db.contacts.update(contact.did, { seesMe: visibility });
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET seesMe = ? WHERE did = ?",
[visibility, contact.did],
);
if (USE_DEXIE_DB) {
db.contacts.update(contact.did, { seesMe: visibility });
}
}
return { success };
} else {

View File

@@ -5,30 +5,43 @@ import { Buffer } from "buffer";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
import {
DEFAULT_PUSH_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import {
accountsDBPromise,
retrieveSettingsForActiveAccount,
updateAccountSettings,
updateDefaultSettings,
} from "../db/index";
import databaseService from "../services/database";
import { Account } from "../db/tables/accounts";
import { Account, AccountEncrypted } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto";
import * as serverUtil from "../libs/endorserServer";
import {
containsHiddenDid,
arrayBufferToBase64,
base64ToArrayBuffer,
deriveAddress,
generateSeed,
newIdentifier,
simpleDecrypt,
simpleEncrypt,
} from "../libs/crypto";
import * as serverUtil from "../libs/endorserServer";
import { containsHiddenDid } from "../libs/endorserServer";
import {
GenericCredWrapper,
GenericVerifiableCredential,
GiveSummaryRecord,
OfferVerifiableCredential,
} from "../libs/endorserServer";
import { KeyMeta } from "../libs/crypto/vc";
KeyMetaWithPrivate,
} from "../interfaces/common";
import { GiveSummaryRecord } from "../interfaces/records";
import { OfferClaim } from "../interfaces/claims";
import { createPeerDid } from "../libs/crypto/vc/didPeer";
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
export interface GiverReceiverInputInfo {
did?: string;
@@ -365,16 +378,19 @@ export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
* @param veriClaim is expected to have fields: claim and issuer
*/
export function offerGiverDid(
veriClaim: GenericCredWrapper<OfferVerifiableCredential>,
veriClaim: GenericCredWrapper<OfferClaim>,
): string | undefined {
let giver;
if (
veriClaim.claim.offeredBy?.identifier &&
!serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string)
) {
giver = veriClaim.claim.offeredBy.identifier;
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
giver = veriClaim.issuer;
const innerClaim = veriClaim.claim as OfferClaim;
let giver: string | undefined = undefined;
giver = innerClaim.offeredBy?.identifier;
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
}
giver = veriClaim.issuer;
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
}
return giver;
}
@@ -388,7 +404,7 @@ export const canFulfillOffer = (
) => {
return (
veriClaim.claimType === "Offer" &&
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferClaim>)
);
};
@@ -458,73 +474,235 @@ export function findAllVisibleToDids(
*
**/
export interface AccountKeyInfo extends Account, KeyMeta {}
export type AccountKeyInfo = Account & KeyMetaWithPrivate;
export const retrieveAccountCount = async (): Promise<number> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
return await accountsDB.accounts.count();
let result = 0;
const platformService = PlatformServiceFactory.getInstance();
const dbResult = await platformService.dbQuery(
`SELECT COUNT(*) FROM accounts`,
);
if (dbResult?.values?.[0]?.[0]) {
result = dbResult.values[0][0] as number;
}
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
result = await accountsDB.accounts.count();
}
return result;
};
export const retrieveAccountDids = async (): Promise<string[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
const allDids = allAccounts.map((acc) => acc.did);
const platformService = PlatformServiceFactory.getInstance();
const dbAccounts = await platformService.dbQuery(`SELECT did FROM accounts`);
let allDids =
databaseUtil
.mapQueryResultToValues(dbAccounts)
?.map((row) => row[0] as string) || [];
if (USE_DEXIE_DB) {
// this is the old way
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
allDids = allAccounts.map((acc) => acc.did);
}
return allDids;
};
// This is provided and recommended when the full key is not necessary so that
// future work could separate this info from the sensitive key material.
/**
* This is provided and recommended when the full key is not necessary so that
* future work could separate this info from the sensitive key material.
*
* If you need the private key data, use retrieveFullyDecryptedAccount instead.
*/
export const retrieveAccountMetadata = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
): Promise<Account | undefined> => {
let result: Account | undefined = undefined;
const platformService = PlatformServiceFactory.getInstance();
const dbAccount = await platformService.dbQuery(
`SELECT * FROM accounts WHERE did = ?`,
[activeDid],
);
const account = databaseUtil.mapQueryResultToValues(dbAccount)[0] as Account;
if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
result = metadata;
} else {
return undefined;
result = undefined;
}
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
result = metadata;
} else {
result = undefined;
}
}
return result;
};
/**
* This contains sensitive data. If possible, use retrieveAccountMetadata instead.
*
* @param activeDid
* @returns account info with private key data decrypted
*/
export const retrieveFullyDecryptedAccount = async (
activeDid: string,
): Promise<Account | undefined> => {
let result: Account | undefined = undefined;
const platformService = PlatformServiceFactory.getInstance();
const dbSecrets = await platformService.dbQuery(
`SELECT secretBase64 from secret`,
);
if (
!dbSecrets ||
dbSecrets.values.length === 0 ||
dbSecrets.values[0].length === 0
) {
throw new Error(
"No secret found. We recommend you clear your data and start over.",
);
}
const secretBase64 = dbSecrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const dbAccount = await platformService.dbQuery(
`SELECT * FROM accounts WHERE did = ?`,
[activeDid],
);
if (
!dbAccount ||
dbAccount.values.length === 0 ||
dbAccount.values[0].length === 0
) {
throw new Error("Account not found.");
}
const fullAccountData = databaseUtil.mapQueryResultToValues(
dbAccount,
)[0] as AccountEncrypted;
const identityEncr = base64ToArrayBuffer(fullAccountData.identityEncrBase64);
const mnemonicEncr = base64ToArrayBuffer(fullAccountData.mnemonicEncrBase64);
fullAccountData.identity = await simpleDecrypt(identityEncr, secret);
fullAccountData.mnemonic = await simpleDecrypt(mnemonicEncr, secret);
result = fullAccountData;
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
result = account;
}
return result;
};
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const array = await accountsDB.accounts.toArray();
return array.map((account) => {
const platformService = PlatformServiceFactory.getInstance();
const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
let result = accounts.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
return metadata as Account;
});
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const array = await accountsDB.accounts.toArray();
result = array.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
}
return result;
};
export const retrieveFullyDecryptedAccount = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
return account;
};
/**
* Saves a new identity to both SQL and Dexie databases
*/
export async function saveNewIdentity(
identity: string,
mnemonic: string,
newId: { did: string; keys: Array<{ publicKeyHex: string }> },
derivationPath: string,
): Promise<void> {
try {
// add to the new sql db
const platformService = PlatformServiceFactory.getInstance();
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
// let's try and eliminate this
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountKeyInfo>
> => {
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
return allAccounts;
};
// If no secret exists, create one
let secretBase64: string;
if (!secrets?.values?.length || !secrets.values[0]?.length) {
// Generate a new secret
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
secretBase64 = arrayBufferToBase64(randomBytes);
// Store the new secret
await platformService.dbExec(
`INSERT INTO secret (id, secretBase64) VALUES (1, ?)`,
[secretBase64],
);
} else {
secretBase64 = secrets.values[0][0] as string;
}
const secret = base64ToArrayBuffer(secretBase64);
const encryptedIdentity = await simpleEncrypt(identity, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
await platformService.dbExec(
`INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`,
[
new Date().toISOString(),
derivationPath,
newId.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
newId.keys[0].publicKeyHex,
],
);
await databaseUtil.updateDefaultSettings({ activeDid: newId.did });
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await updateDefaultSettings({ activeDid: newId.did });
}
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
}
}
/**
* Generates a new identity, saves it to the database, and sets it as the active identity.
@@ -539,40 +717,11 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId);
// one of the few times we use accountsDBPromise directly; try to avoid more usage
try {
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
// add to the new sql db
await databaseService.run(
`INSERT INTO accounts (dateCreated, derivationPath, did, identity, mnemonic, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`,
[
new Date().toISOString(),
derivationPath,
newId.did,
identity,
mnemonic,
newId.keys[0].publicKeyHex,
],
);
await updateDefaultSettings({ activeDid: newId.did });
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
await saveNewIdentity(identity, mnemonic, newId, derivationPath);
await databaseUtil.updateAccountSettings(newId.did, { isRegistered: false });
if (USE_DEXIE_DB) {
await updateAccountSettings(newId.did, { isRegistered: false });
}
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;
};
@@ -590,9 +739,19 @@ export const registerAndSavePasskey = async (
passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
};
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add(account);
const insertStatement = databaseUtil.generateInsertStatement(
account,
"accounts",
);
await PlatformServiceFactory.getInstance().dbExec(
insertStatement.sql,
insertStatement.params,
);
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add(account);
}
return account;
};
@@ -600,13 +759,22 @@ export const registerSaveAndActivatePasskey = async (
keyName: string,
): Promise<Account> => {
const account = await registerAndSavePasskey(keyName);
await updateDefaultSettings({ activeDid: account.did });
await updateAccountSettings(account.did, { isRegistered: false });
await databaseUtil.updateDefaultSettings({ activeDid: account.did });
await databaseUtil.updateAccountSettings(account.did, {
isRegistered: false,
});
if (USE_DEXIE_DB) {
await updateDefaultSettings({ activeDid: account.did });
await updateAccountSettings(account.did, { isRegistered: false });
}
return account;
};
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
return (
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
60
@@ -622,7 +790,10 @@ export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean,
): Promise<AxiosResponse> => {
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
let pushUrl: string = DEFAULT_PUSH_SERVER as string;
if (settings?.webPushServer) {
pushUrl = settings.webPushServer;

View File

@@ -34,7 +34,7 @@ import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logConsoleAndDb } from "./db";
import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger";
logger.log("[Capacitor] Starting initialization");

View File

@@ -2,6 +2,7 @@ import { createPinia } from "pinia";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
import App from "./App.vue";
import router from "./router";
// Use the browser version of axios for web builds
import axios from "axios";
import VueAxios from "vue-axios";
import Notifications from "notiwind";
@@ -10,6 +11,12 @@ import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.log("Platform", { platform });
logger.log("PWA enabled", { pwa_enabled });
// Global Error Handler
function setupGlobalErrorHandler(app: VueApp) {
logger.log("[App Init] Setting up global error handler");

View File

@@ -1,4 +1,301 @@
import { initializeApp } from "./main.common";
import { logger } from "./utils/logger";
import { SQLiteQueryResult } from "./services/platforms/ElectronPlatformService";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.info("[Main Electron] Initializing app");
logger.info("[Main Electron] Platform:", { platform });
logger.info("[Main Electron] PWA enabled:", { pwa_enabled });
if (pwa_enabled) {
logger.warn("[Main Electron] PWA is enabled, but not supported in electron");
}
// Initialize app and SQLite
const app = initializeApp();
app.mount("#app");
// Create a promise that resolves when SQLite is ready
const sqliteReady = new Promise<void>((resolve, reject) => {
let retryCount = 0;
let initializationTimeout: NodeJS.Timeout;
const attemptInitialization = () => {
// Clear any existing timeout
if (initializationTimeout) {
clearTimeout(initializationTimeout);
}
// Set timeout for this attempt
initializationTimeout = setTimeout(() => {
if (retryCount < 3) {
// Use same retry count as ElectronPlatformService
retryCount++;
logger.warn(
`[Main Electron] SQLite initialization attempt ${retryCount} timed out, retrying...`,
);
setTimeout(attemptInitialization, 1000); // Use same delay as ElectronPlatformService
} else {
logger.error(
"[Main Electron] SQLite initialization failed after all retries",
);
reject(new Error("SQLite initialization timeout after all retries"));
}
}, 10000); // Use same timeout as ElectronPlatformService
// Wait for electron bridge to be available
const checkElectronBridge = () => {
if (!window.electron?.ipcRenderer) {
// Check again in 100ms if bridge isn't ready
setTimeout(checkElectronBridge, 100);
return;
}
// At this point we know ipcRenderer exists
const ipcRenderer = window.electron.ipcRenderer;
logger.info("[Main Electron] [IPC:bridge] IPC renderer bridge available");
// Listen for SQLite ready signal
logger.debug(
"[Main Electron] [IPC:sqlite-ready] Registering listener for SQLite ready signal",
);
ipcRenderer.once("sqlite-ready", () => {
clearTimeout(initializationTimeout);
logger.info(
"[Main Electron] [IPC:sqlite-ready] Received SQLite ready signal",
);
resolve();
});
// Also listen for database errors
logger.debug(
"[Main Electron] [IPC:database-status] Registering listener for database status",
);
ipcRenderer.once("database-status", (...args: unknown[]) => {
clearTimeout(initializationTimeout);
const status = args[0] as { status: string; error?: string };
if (status.status === "error") {
logger.error(
"[Main Electron] [IPC:database-status] Database error:",
{
error: status.error,
channel: "database-status",
},
);
reject(new Error(status.error || "Database initialization failed"));
}
});
// Check if SQLite is already available
logger.debug(
"[Main Electron] [IPC:sqlite-is-available] Checking SQLite availability",
);
ipcRenderer
.invoke("sqlite-is-available")
.then(async (result: unknown) => {
const isAvailable = Boolean(result);
if (isAvailable) {
logger.info(
"[Main Electron] [IPC:sqlite-is-available] SQLite is available",
);
try {
// First create a database connection
logger.debug(
"[Main Electron] [IPC:get-path] Requesting database path",
);
const dbPath = await ipcRenderer.invoke("get-path");
logger.info(
"[Main Electron] [IPC:get-path] Database path received:",
{ dbPath },
);
// Create the database connection
logger.debug(
"[Main Electron] [IPC:sqlite-create-connection] Creating database connection",
);
await ipcRenderer.invoke("sqlite-create-connection", {
database: "timesafari",
version: 1,
});
logger.info(
"[Main Electron] [IPC:sqlite-create-connection] Database connection created",
);
// Explicitly open the database
logger.debug(
"[Main Electron] [IPC:sqlite-open] Opening database",
);
await ipcRenderer.invoke("sqlite-open", {
database: "timesafari",
});
logger.info(
"[Main Electron] [IPC:sqlite-open] Database opened successfully",
);
// Verify the database is open
logger.debug(
"[Main Electron] [IPC:sqlite-is-db-open] Verifying database is open",
);
const isOpen = await ipcRenderer.invoke("sqlite-is-db-open", {
database: "timesafari",
});
logger.info(
"[Main Electron] [IPC:sqlite-is-db-open] Database open status:",
{ isOpen },
);
if (!isOpen) {
throw new Error("Database failed to open");
}
// Now execute the test query
logger.debug(
"[Main Electron] [IPC:sqlite-query] Executing test query",
);
const testQuery = (await ipcRenderer.invoke("sqlite-query", {
database: "timesafari",
statement: "SELECT 1 as test;", // Safe test query
})) as SQLiteQueryResult;
logger.info(
"[Main Electron] [IPC:sqlite-query] Test query successful:",
{
hasResults: Boolean(testQuery?.values),
resultCount: testQuery?.values?.length,
},
);
// Signal that SQLite is ready - database stays open
logger.debug(
"[Main Electron] [IPC:sqlite-status] Sending SQLite ready status",
);
await ipcRenderer.invoke("sqlite-status", {
status: "ready",
database: "timesafari",
timestamp: Date.now(),
});
logger.info(
"[Main Electron] SQLite ready status sent, database connection maintained",
);
// Remove the close operations - database stays open for component use
// Database will be closed during app shutdown
} catch (error) {
logger.error(
"[Main Electron] [IPC:*] SQLite test operation failed:",
{
error,
lastOperation: "sqlite-test-query",
database: "timesafari",
},
);
// Try to close everything if anything was opened
try {
logger.debug(
"[Main Electron] [IPC:cleanup] Attempting database cleanup after error",
);
await ipcRenderer
.invoke("sqlite-close", {
database: "timesafari",
})
.catch((closeError) => {
logger.warn(
"[Main Electron] [IPC:sqlite-close] Failed to close database during cleanup:",
closeError,
);
});
await ipcRenderer
.invoke("sqlite-close-connection", {
database: "timesafari",
})
.catch((closeError) => {
logger.warn(
"[Main Electron] [IPC:sqlite-close-connection] Failed to close connection during cleanup:",
closeError,
);
});
logger.info(
"[Main Electron] [IPC:cleanup] Database cleanup completed after error",
);
} catch (closeError) {
logger.error(
"[Main Electron] [IPC:cleanup] Failed to cleanup database:",
{
error: closeError,
database: "timesafari",
},
);
}
// Don't reject here - we still want to wait for the ready signal
}
}
})
.catch((error: Error) => {
logger.error(
"[Main Electron] [IPC:sqlite-is-available] Failed to check SQLite availability:",
{
error,
channel: "sqlite-is-available",
},
);
// Don't reject here - wait for either ready signal or timeout
});
};
// Start checking for bridge
checkElectronBridge();
};
// Start first initialization attempt
attemptInitialization();
});
// Wait for SQLite to be ready before initializing router and mounting app
sqliteReady
.then(async () => {
logger.info("[Main Electron] SQLite ready, initializing router...");
// Initialize router after SQLite is ready
const router = await import("./router").then((m) => m.default);
app.use(router);
logger.info("[Main Electron] Router initialized");
// Now mount the app
logger.info("[Main Electron] Mounting app...");
app.mount("#app");
logger.info("[Main Electron] App mounted successfully");
})
.catch((error) => {
logger.error(
"[Main Electron] Failed to initialize SQLite:",
error instanceof Error ? error.message : "Unknown error",
);
// Show error to user with retry option
const errorDiv = document.createElement("div");
errorDiv.style.cssText =
"position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #ffebee; color: #c62828; padding: 20px; border-radius: 4px; text-align: center; max-width: 80%; z-index: 9999;";
errorDiv.innerHTML = `
<h2>Failed to Initialize Application</h2>
<p>There was an error initializing the database. This could be due to:</p>
<ul style="text-align: left; margin: 10px 0;">
<li>Database file is locked by another process</li>
<li>Insufficient permissions to access the database</li>
<li>Database file is corrupted</li>
</ul>
<p>Error details: ${error instanceof Error ? error.message : "Unknown error"}</p>
<div style="margin-top: 15px;">
<button onclick="window.location.reload()" style="margin: 0 5px; padding: 8px 16px; background: #c62828; color: white; border: none; border-radius: 4px; cursor: pointer;">
Retry
</button>
<button onclick="window.electron.ipcRenderer.send('sqlite-status', { action: 'reset' })" style="margin: 0 5px; padding: 8px 16px; background: #f57c00; color: white; border: none; border-radius: 4px; cursor: pointer;">
Reset Database
</button>
</div>
`;
document.body.appendChild(errorDiv);
});

View File

@@ -1,6 +1,17 @@
import { initBackend } from "absurd-sql/dist/indexeddb-main-thread";
import { initializeApp } from "./main.common";
import "./registerServiceWorker"; // Web PWA support
import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.error("[Web] PWA enabled", { pwa_enabled });
logger.error("[Web] Platform", { platform });
// Only import service worker for web builds
if (platform !== "electron" && pwa_enabled) {
import("./registerServiceWorker"); // Web PWA support
}
const app = initializeApp();
@@ -17,6 +28,10 @@ function sqlInit() {
// workers through the main thread
initBackend(worker);
}
sqlInit();
if (platform === "web" || platform === "development") {
sqlInit();
} else {
logger.info("[Web] SQL not initialized for platform", { platform });
}
app.mount("#app");

View File

@@ -1,4 +1,4 @@
import databaseService from "./services/database";
import databaseService from "./services/AbsurdSqlDatabaseService";
async function run() {
await databaseService.initialize();

View File

@@ -2,8 +2,18 @@
import { register } from "register-service-worker";
// Only register service worker if explicitly enabled and in production
// Check if we're in an Electron environment
const isElectron =
process.env.VITE_PLATFORM === "electron" ||
process.env.VITE_DISABLE_PWA === "true" ||
window.navigator.userAgent.toLowerCase().includes("electron");
// Only register service worker if:
// 1. Not in Electron
// 2. PWA is explicitly enabled
// 3. In production mode
if (
!isElectron &&
process.env.VITE_PWA_ENABLED === "true" &&
process.env.NODE_ENV === "production"
) {
@@ -34,6 +44,12 @@ if (
});
} else {
console.log(
"Service worker registration skipped - not enabled or not in production",
`Service worker registration skipped - ${
isElectron
? "running in Electron"
: process.env.VITE_PWA_ENABLED !== "true"
? "PWA not enabled"
: "not in production mode"
}`,
);
}

View File

@@ -2,35 +2,11 @@ import {
createRouter,
createWebHistory,
createMemoryHistory,
NavigationGuardNext,
RouteLocationNormalized,
RouteRecordRaw,
} from "vue-router";
import { accountsDBPromise } from "../db/index";
import { logger } from "../utils/logger";
/**
*
* @param to :RouteLocationNormalized
* @param from :RouteLocationNormalized
* @param next :NavigationGuardNext
*/
const enterOrStart = async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
) => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const num_accounts = await accountsDB.accounts.count();
if (num_accounts > 0) {
next();
} else {
next({ name: "start" });
}
};
const routes: Array<RouteRecordRaw> = [
{
path: "/account",
@@ -216,7 +192,6 @@ const routes: Array<RouteRecordRaw> = [
path: "/projects",
name: "projects",
component: () => import("../views/ProjectsView.vue"),
beforeEnter: enterOrStart,
},
{
path: "/quick-action-bvc",
@@ -302,18 +277,31 @@ const initialPath = isElectron
? window.location.pathname.split("/dist-electron/www/")[1] || "/"
: window.location.pathname;
logger.info("[Router] Initializing router", { isElectron, initialPath });
const history = isElectron
? createMemoryHistory() // Memory history for Electron
: createWebHistory("/"); // Add base path for web apps
/** @type {*} */
const router = createRouter({
history,
routes,
});
// Set initial route
router.beforeEach((to, from, next) => {
logger.info("[Router] Navigation", { to: to.path, from: from.path });
next();
});
// Replace initial URL to start at `/` if necessary
router.replace(initialPath || "/");
if (initialPath === "/" || !initialPath) {
logger.info("[Router] Setting initial route to /");
router.replace("/");
} else {
logger.info("[Router] Setting initial route to", initialPath);
router.replace(initialPath);
}
const errorHandler = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -1,17 +1,20 @@
// Add type declarations for external modules
declare module "@jlongster/sql.js";
declare module "absurd-sql";
declare module "absurd-sql/dist/indexeddb-backend";
import initSqlJs from "@jlongster/sql.js";
import { SQLiteFS } from "absurd-sql";
import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend";
import { runMigrations } from "../db-sql/migration";
import type { QueryExecResult } from "../interfaces/database";
import type { DatabaseService, QueryExecResult } from "../interfaces/database";
import { logger } from "@/utils/logger";
interface SQLDatabase {
interface QueuedOperation {
type: "run" | "query" | "getOneRow" | "getAll";
sql: string;
params: unknown[];
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
interface AbsurdSqlDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
run: (
sql: string,
@@ -19,22 +22,24 @@ interface SQLDatabase {
) => Promise<{ changes: number; lastId?: number }>;
}
class DatabaseService {
private static instance: DatabaseService | null = null;
private db: SQLDatabase | null;
class AbsurdSqlDatabaseService implements DatabaseService {
private static instance: AbsurdSqlDatabaseService | null = null;
private db: AbsurdSqlDatabase | null;
private initialized: boolean;
private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false;
private constructor() {
this.db = null;
this.initialized = false;
}
static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
static getInstance(): AbsurdSqlDatabaseService {
if (!AbsurdSqlDatabaseService.instance) {
AbsurdSqlDatabaseService.instance = new AbsurdSqlDatabaseService();
}
return DatabaseService.instance;
return AbsurdSqlDatabaseService.instance;
}
async initialize(): Promise<void> {
@@ -53,7 +58,7 @@ class DatabaseService {
try {
await this.initializationPromise;
} catch (error) {
logger.error(`DatabaseService initialize method failed:`, error);
logger.error(`AbsurdSqlDatabaseService initialize method failed:`, error);
this.initializationPromise = null; // Reset on failure
throw error;
}
@@ -79,7 +84,7 @@ class DatabaseService {
SQL.FS.mkdir("/sql");
SQL.FS.mount(sqlFS, {}, "/sql");
const path = "/sql/db.sqlite";
const path = "/sql/timesafari.absurd-sql";
if (typeof SharedArrayBuffer === "undefined") {
const stream = SQL.FS.open(path, "a+");
await stream.node.contents.readIfFallback();
@@ -93,6 +98,7 @@ class DatabaseService {
);
}
// An error is thrown without this pragma: "File has invalid page size. (the first block of a new file must be written first)"
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
const sqlExec = this.db.exec.bind(this.db);
@@ -100,6 +106,78 @@ class DatabaseService {
await runMigrations(sqlExec);
this.initialized = true;
// Start processing the queue after initialization
this.processQueue();
}
private async processQueue(): Promise<void> {
if (this.isProcessingQueue || !this.initialized || !this.db) {
return;
}
this.isProcessingQueue = true;
while (this.operationQueue.length > 0) {
const operation = this.operationQueue.shift();
if (!operation) continue;
try {
let queryResult: QueryExecResult[] = [];
let result: unknown;
switch (operation.type) {
case "run":
result = await this.db.run(operation.sql, operation.params);
break;
case "query":
result = await this.db.exec(operation.sql, operation.params);
break;
case "getOneRow":
queryResult = await this.db.exec(operation.sql, operation.params);
result = queryResult[0]?.values[0];
break;
case "getAll":
queryResult = await this.db.exec(operation.sql, operation.params);
result = queryResult[0]?.values || [];
break;
}
operation.resolve(result);
} catch (error) {
logger.error(
"Error while processing SQL queue:",
error,
" ... for sql:",
operation.sql,
" ... with params:",
operation.params,
);
operation.reject(error);
}
}
this.isProcessingQueue = false;
}
private async queueOperation<R>(
type: QueuedOperation["type"],
sql: string,
params: unknown[] = [],
): Promise<R> {
return new Promise<R>((resolve, reject) => {
const operation: QueuedOperation = {
type,
sql,
params,
resolve: (value: unknown) => resolve(value as R),
reject,
};
this.operationQueue.push(operation);
// If we're already initialized, start processing the queue
if (this.initialized && this.db) {
this.processQueue();
}
});
}
private async waitForInitialization(): Promise<void> {
@@ -132,13 +210,17 @@ class DatabaseService {
params: unknown[] = [],
): Promise<{ changes: number; lastId?: number }> {
await this.waitForInitialization();
return this.db!.run(sql, params);
return this.queueOperation<{ changes: number; lastId?: number }>(
"run",
sql,
params,
);
}
// Note that the resulting array may be empty if there are no results from the query
async query(sql: string, params: unknown[] = []): Promise<QueryExecResult[]> {
await this.waitForInitialization();
return this.db!.exec(sql, params);
return this.queueOperation<QueryExecResult[]>("query", sql, params);
}
async getOneRow(
@@ -146,18 +228,16 @@ class DatabaseService {
params: unknown[] = [],
): Promise<unknown[] | undefined> {
await this.waitForInitialization();
const result = await this.db!.exec(sql, params);
return result[0]?.values[0];
return this.queueOperation<unknown[] | undefined>("getOneRow", sql, params);
}
async all(sql: string, params: unknown[] = []): Promise<unknown[][]> {
async getAll(sql: string, params: unknown[] = []): Promise<unknown[][]> {
await this.waitForInitialization();
const result = await this.db!.exec(sql, params);
return result[0]?.values || [];
return this.queueOperation<unknown[][]>("getAll", sql, params);
}
}
// Create a singleton instance
const databaseService = DatabaseService.getInstance();
const databaseService = AbsurdSqlDatabaseService.getInstance();
export default databaseService;

View File

@@ -1,3 +1,13 @@
import { QueryExecResult } from "@/interfaces/database";
/**
* Query execution result interface
*/
export interface QueryExecResult<T = unknown> {
columns: string[];
values: T[];
}
/**
* Represents the result of an image capture or selection operation.
* Contains both the image data as a Blob and the associated filename.
@@ -98,4 +108,26 @@ export interface PlatformService {
* @returns Promise that resolves when the deep link has been handled
*/
handleDeepLink(url: string): Promise<void>;
/**
* Execute a database query and return the results
* @param sql SQL query to execute
* @param params Query parameters
* @returns Query results with columns and values
*/
dbQuery<T = unknown>(
sql: string,
params?: unknown[],
): Promise<QueryExecResult<T>>;
/**
* Executes a create/update/delete on the database.
* @param sql - The SQL statement to execute
* @param params - The parameters to pass to the statement
* @returns Promise resolving to the result of the statement
*/
dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }>;
}

View File

@@ -4,7 +4,13 @@ import {
StartScanOptions,
LensFacing,
} from "@capacitor-mlkit/barcode-scanning";
import { QRScannerService, ScanListener, QRScannerOptions } from "./types";
import {
QRScannerService,
ScanListener,
QRScannerOptions,
CameraStateListener,
CameraState,
} from "./types";
import { logger } from "@/utils/logger";
export class CapacitorQRScanner implements QRScannerService {
@@ -12,6 +18,9 @@ export class CapacitorQRScanner implements QRScannerService {
private isScanning = false;
private listenerHandles: Array<() => Promise<void>> = [];
private cleanupPromise: Promise<void> | null = null;
private cameraStateListeners: Set<CameraStateListener> = new Set();
private currentState: CameraState = "off";
private currentStateMessage?: string;
async checkPermissions(): Promise<boolean> {
try {
@@ -79,8 +88,11 @@ export class CapacitorQRScanner implements QRScannerService {
}
try {
this.updateCameraState("initializing", "Starting camera...");
// Ensure we have permissions before starting
if (!(await this.checkPermissions())) {
this.updateCameraState("permission_denied", "Camera permission denied");
logger.debug("Requesting camera permissions");
const granted = await this.requestPermissions();
if (!granted) {
@@ -90,11 +102,16 @@ export class CapacitorQRScanner implements QRScannerService {
// Check if scanning is supported
if (!(await this.isSupported())) {
this.updateCameraState(
"error",
"QR scanning not supported on this device",
);
throw new Error("QR scanning not supported on this device");
}
logger.info("Starting MLKit scanner");
this.isScanning = true;
this.updateCameraState("active", "Camera is active");
const scanOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode],
@@ -126,6 +143,7 @@ export class CapacitorQRScanner implements QRScannerService {
stack: wrappedError.stack,
});
this.isScanning = false;
this.updateCameraState("error", wrappedError.message);
await this.cleanup();
this.scanListener?.onError?.(wrappedError);
throw wrappedError;
@@ -140,6 +158,7 @@ export class CapacitorQRScanner implements QRScannerService {
try {
logger.debug("Stopping QR scanner");
this.updateCameraState("off", "Camera stopped");
await BarcodeScanner.stopScan();
logger.info("QR scanner stopped successfully");
} catch (error) {
@@ -149,6 +168,7 @@ export class CapacitorQRScanner implements QRScannerService {
error: wrappedError.message,
stack: wrappedError.stack,
});
this.updateCameraState("error", wrappedError.message);
this.scanListener?.onError?.(wrappedError);
throw wrappedError;
} finally {
@@ -207,4 +227,23 @@ export class CapacitorQRScanner implements QRScannerService {
// No-op for native scanner
callback(null);
}
addCameraStateListener(listener: CameraStateListener): void {
this.cameraStateListeners.add(listener);
// Immediately notify the new listener of current state
listener.onStateChange(this.currentState, this.currentStateMessage);
}
removeCameraStateListener(listener: CameraStateListener): void {
this.cameraStateListeners.delete(listener);
}
private updateCameraState(state: CameraState, message?: string): void {
this.currentState = state;
this.currentStateMessage = message;
// Notify all listeners of state change
for (const listener of this.cameraStateListeners) {
listener.onStateChange(state, message);
}
}
}

View File

@@ -30,14 +30,16 @@ export class WebInlineQRScanner implements QRScannerService {
private cameraStateListeners: Set<CameraStateListener> = new Set();
private currentState: CameraState = "off";
private currentStateMessage?: string;
private options: QRScannerOptions;
constructor(private options?: QRScannerOptions) {
constructor(options?: QRScannerOptions) {
// Generate a short random ID for this scanner instance
this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
this.options = options ?? {};
logger.error(
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
{
...options,
...this.options,
buildId: BUILD_ID,
targetFps: this.TARGET_FPS,
},
@@ -494,26 +496,34 @@ export class WebInlineQRScanner implements QRScannerService {
}
}
async startScan(): Promise<void> {
async startScan(options?: QRScannerOptions): Promise<void> {
if (this.isScanning) {
logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`);
return;
}
// Update options if provided
if (options) {
this.options = { ...this.options, ...options };
}
try {
this.isScanning = true;
this.scanAttempts = 0;
this.lastScanTime = Date.now();
this.updateCameraState("initializing", "Starting camera...");
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`);
logger.error(
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
this.options,
);
// Get camera stream
// Get camera stream with options
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
);
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: "environment",
facingMode: this.options.camera === "front" ? "user" : "environment",
width: { ideal: 1280 },
height: { ideal: 720 },
},
@@ -527,11 +537,18 @@ export class WebInlineQRScanner implements QRScannerService {
label: t.label,
readyState: t.readyState,
})),
options: this.options,
});
// Set up video element
if (this.video) {
this.video.srcObject = this.stream;
// Only show preview if showPreview is true
if (this.options.showPreview) {
this.video.style.display = "block";
} else {
this.video.style.display = "none";
}
await this.video.play();
logger.error(
`[WebInlineQRScanner:${this.id}] Video element started playing`,

View File

@@ -0,0 +1,132 @@
import { logger } from "../../utils/logger";
import { SQLiteDBConnection } from "@capacitor-community/sqlite";
interface ConnectionState {
connection: SQLiteDBConnection;
lastUsed: number;
inUse: boolean;
}
export class DatabaseConnectionPool {
private static instance: DatabaseConnectionPool | null = null;
private connections: Map<string, ConnectionState> = new Map();
private readonly MAX_CONNECTIONS = 1; // We only need one connection for SQLite
private readonly MAX_IDLE_TIME = 5 * 60 * 1000; // 5 minutes
private readonly CLEANUP_INTERVAL = 60 * 1000; // 1 minute
private cleanupInterval: NodeJS.Timeout | null = null;
private constructor() {
// Start cleanup interval
this.cleanupInterval = setInterval(
() => this.cleanup(),
this.CLEANUP_INTERVAL,
);
}
public static getInstance(): DatabaseConnectionPool {
if (!DatabaseConnectionPool.instance) {
DatabaseConnectionPool.instance = new DatabaseConnectionPool();
}
return DatabaseConnectionPool.instance;
}
public async getConnection(
dbName: string,
createConnection: () => Promise<SQLiteDBConnection>,
): Promise<SQLiteDBConnection> {
// Check if we have an existing connection
const existing = this.connections.get(dbName);
if (existing && !existing.inUse) {
existing.inUse = true;
existing.lastUsed = Date.now();
logger.debug(
`[ConnectionPool] Reusing existing connection for ${dbName}`,
);
return existing.connection;
}
// If we have too many connections, wait for one to be released
if (this.connections.size >= this.MAX_CONNECTIONS) {
logger.debug(`[ConnectionPool] Waiting for connection to be released...`);
await this.waitForConnection();
}
// Create new connection
try {
const connection = await createConnection();
this.connections.set(dbName, {
connection,
lastUsed: Date.now(),
inUse: true,
});
logger.debug(`[ConnectionPool] Created new connection for ${dbName}`);
return connection;
} catch (error) {
logger.error(
`[ConnectionPool] Failed to create connection for ${dbName}:`,
error,
);
throw error;
}
}
public async releaseConnection(dbName: string): Promise<void> {
const connection = this.connections.get(dbName);
if (connection) {
connection.inUse = false;
connection.lastUsed = Date.now();
logger.debug(`[ConnectionPool] Released connection for ${dbName}`);
}
}
private async waitForConnection(): Promise<void> {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (this.connections.size < this.MAX_CONNECTIONS) {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
}
private async cleanup(): Promise<void> {
const now = Date.now();
for (const [dbName, state] of this.connections.entries()) {
if (!state.inUse && now - state.lastUsed > this.MAX_IDLE_TIME) {
try {
await state.connection.close();
this.connections.delete(dbName);
logger.debug(
`[ConnectionPool] Cleaned up idle connection for ${dbName}`,
);
} catch (error) {
logger.warn(
`[ConnectionPool] Error closing idle connection for ${dbName}:`,
error,
);
}
}
}
}
public async closeAll(): Promise<void> {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
for (const [dbName, state] of this.connections.entries()) {
try {
await state.connection.close();
logger.debug(`[ConnectionPool] Closed connection for ${dbName}`);
} catch (error) {
logger.warn(
`[ConnectionPool] Error closing connection for ${dbName}:`,
error,
);
}
}
this.connections.clear();
}
}

View File

@@ -52,7 +52,7 @@ import {
routeSchema,
DeepLinkRoute,
} from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db";
import { logConsoleAndDb } from "../db/databaseUtil";
import type { DeepLinkError } from "../interfaces/deepLinks";
/**
@@ -119,6 +119,15 @@ export class DeepLinkHandler {
const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/");
// Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) {
throw {
code: "INVALID_ROUTE",
message: `Invalid route path: ${routePath}`,
details: { routePath },
};
}
const query: Record<string, string> = {};
if (queryString) {
new URLSearchParams(queryString).forEach((value, key) => {
@@ -128,11 +137,9 @@ export class DeepLinkHandler {
const params: Record<string, string> = {};
if (param) {
if (this.ROUTE_MAP[routePath].paramKey) {
params[this.ROUTE_MAP[routePath].paramKey] = param;
} else {
params["id"] = param;
}
// Now we know routePath exists in ROUTE_MAP
const routeConfig = this.ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = param;
}
return { path: routePath, params, query };
}

View File

@@ -54,16 +54,11 @@ export class MigrationService {
// Run pending migrations in order
for (const migration of this.migrations) {
if (!executedMigrations.has(migration.name)) {
try {
await sqlExec(migration.sql);
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.log(`Migration ${migration.name} executed successfully`);
} catch (error) {
logger.error(`Error executing migration ${migration.name}:`, error);
throw error;
}
await sqlExec(migration.sql);
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.log(`Migration ${migration.name} executed successfully`);
}
}
}

View File

@@ -6,7 +6,20 @@ import {
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { Share } from "@capacitor/share";
import {
SQLiteConnection,
SQLiteDBConnection,
CapacitorSQLite,
Changes,
} from "@capacitor-community/sqlite";
import { logger } from "../../utils/logger";
import { QueryExecResult, SqlValue } from "@/interfaces/database";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
interface Migration {
name: string;
sql: string;
}
/**
* Platform service implementation for Capacitor (mobile) platform.
@@ -14,8 +27,168 @@ import { logger } from "../../utils/logger";
* - File system operations
* - Camera and image picker
* - Platform-specific features
* - SQLite database operations
*/
export class CapacitorPlatformService implements PlatformService {
private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null;
private dbName = "timesafari.db";
private initialized = false;
constructor() {
this.sqlite = new SQLiteConnection(CapacitorSQLite);
}
private async initializeDatabase(): Promise<void> {
if (this.initialized) {
return;
}
try {
// Create/Open database
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
await this.db.open();
// Set journal mode to WAL for better performance
await this.db.execute("PRAGMA journal_mode=WAL;");
// Run migrations
await this.runMigrations();
this.initialized = true;
logger.log("SQLite database initialized successfully");
} catch (error) {
logger.error("Error initializing SQLite database:", error);
throw new Error("Failed to initialize database");
}
}
private async runMigrations(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
// Create migrations table if it doesn't exist
await this.db.execute(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of executed migrations
const result = await this.db.query("SELECT name FROM migrations;");
const executedMigrations = new Set(
result.values?.map((row) => row[0]) || [],
);
// Run pending migrations in order
const migrations: Migration[] = [
{
name: "001_initial",
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identityEncrBase64 TEXT,
mnemonicEncrBase64 TEXT,
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secretBase64 TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
activeDid TEXT,
apiServer TEXT,
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT,
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT,
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT,
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT,
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN
);
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE IF NOT EXISTS logs (
date TEXT PRIMARY KEY,
message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS temp (
id TEXT PRIMARY KEY,
blobB64 TEXT
);
`,
},
];
for (const migration of migrations) {
if (!executedMigrations.has(migration.name)) {
await this.db.execute(migration.sql);
await this.db.run("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.log(`Migration ${migration.name} executed successfully`);
}
}
}
/**
* Gets the capabilities of the Capacitor platform
* @returns Platform capabilities object
@@ -185,6 +358,9 @@ export class CapacitorPlatformService implements PlatformService {
*/
async writeFile(fileName: string, content: string): Promise<void> {
try {
// Check storage permissions before proceeding
await this.checkStoragePermissions();
const logData = {
targetFileName: fileName,
contentLength: content.length,
@@ -326,6 +502,9 @@ export class CapacitorPlatformService implements PlatformService {
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
// Check storage permissions before proceeding
await this.checkStoragePermissions();
const { uri } = await Filesystem.writeFile({
path: fileName,
data: content,
@@ -476,4 +655,55 @@ export class CapacitorPlatformService implements PlatformService {
// This is just a placeholder for the interface
return Promise.resolve();
}
/**
* @see PlatformService.dbQuery
*/
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
await this.initializeDatabase();
if (!this.db) {
throw new Error("Database not initialized");
}
try {
const result = await this.db.query(sql, params || []);
const values = result.values || [];
return {
columns: [], // SQLite plugin doesn't provide column names in query result
values: values as SqlValue[][],
};
} catch (error) {
logger.error("Error executing query:", error);
throw new Error(
`Database query failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* @see PlatformService.dbExec
*/
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
await this.initializeDatabase();
if (!this.db) {
throw new Error("Database not initialized");
}
try {
const result = await this.db.run(sql, params || []);
const changes = result.changes as Changes;
return {
changes: changes?.changes || 0,
lastId: changes?.lastId,
};
} catch (error) {
logger.error("Error executing statement:", error);
throw new Error(
`Database execution failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}

View File

@@ -2,22 +2,500 @@ import {
ImageResult,
PlatformService,
PlatformCapabilities,
QueryExecResult,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { ElectronAPI } from "../../utils/debug-electron";
import {
verifyElectronAPI,
testSQLiteOperations,
} from "../../utils/debug-electron";
// Extend the global Window interface
declare global {
interface Window {
electron: ElectronAPI;
}
}
interface Migration {
name: string;
sql: string;
}
// Define the SQLite query result type
interface SQLiteQueryResult {
changes: number;
lastId: number;
rows?: unknown[];
values?: Record<string, unknown>[];
}
// Update the QueryExecResult type to include success and changes
interface ElectronQueryExecResult {
success: boolean;
changes: number;
lastId?: number;
rows?: unknown[];
}
/**
* Shared SQLite initialization state
* Used to coordinate initialization between main and service
*
* @author Matthew Raymer
*/
export interface SQLiteInitState {
isReady: boolean;
isInitializing: boolean;
error?: Error;
lastReadyCheck?: number;
}
// Singleton instance for shared state
const sqliteInitState: SQLiteInitState = {
isReady: false,
isInitializing: false,
lastReadyCheck: 0,
};
/**
* Interface defining SQLite database operations
* @author Matthew Raymer
*/
interface SQLiteOperations {
createConnection: (options: {
database: string;
encrypted: boolean;
mode: string;
}) => Promise<void>;
query: (options: {
database: string;
statement: string;
values?: unknown[];
}) => Promise<{ values?: unknown[] }>;
execute: (options: { database: string; statements: string }) => Promise<void>;
run: (options: {
database: string;
statement: string;
values?: unknown[];
}) => Promise<{ changes?: { changes: number; lastId?: number } }>;
}
// Add at the top of the file after imports
const formatLogObject = (obj: unknown): string => {
try {
return JSON.stringify(obj, null, 2);
} catch (error) {
return `[Object could not be stringified: ${error instanceof Error ? error.message : String(error)}]`;
}
};
/**
* Platform service implementation for Electron (desktop) platform.
* Note: This is a placeholder implementation with most methods currently unimplemented.
* Implements the PlatformService interface but throws "Not implemented" errors for most operations.
* Provides native desktop functionality through Electron and Capacitor plugins for:
* - File system operations (TODO)
* - Camera integration (TODO)
* - SQLite database operations
* - System-level features (TODO)
*
* @remarks
* This service is intended for desktop application functionality through Electron.
* Future implementations should provide:
* - Native file system access
* - Desktop camera integration
* - System-level features
* @author Matthew Raymer
*/
export class ElectronPlatformService implements PlatformService {
private sqlite: SQLiteOperations | null = null;
private dbName = "timesafari";
private isInitialized = false;
private dbFatalError = false;
private sqliteReadyPromise: Promise<void> | null = null;
private initializationTimeout: NodeJS.Timeout | null = null;
private isConnectionOpen = false;
private operationQueue: Promise<unknown> = Promise.resolve();
private queueLock = false;
private connectionState:
| "disconnected"
| "connecting"
| "connected"
| "error" = "disconnected";
private connectionPromise: Promise<void> | null = null;
// SQLite initialization configuration
private static readonly SQLITE_CONFIG = {
INITIALIZATION: {
TIMEOUT_MS: 5000, // Increase timeout to 5 seconds
RETRY_ATTEMPTS: 3,
RETRY_DELAY_MS: 1000,
READY_CHECK_INTERVAL_MS: 100,
},
};
constructor() {
this.sqliteReadyPromise = new Promise<void>((resolve, reject) => {
let retryCount = 0;
const cleanup = () => {
if (this.initializationTimeout) {
clearTimeout(this.initializationTimeout);
this.initializationTimeout = null;
}
};
const checkExistingReadiness = async (): Promise<boolean> => {
try {
if (!window.electron?.ipcRenderer) {
return false;
}
// Check if SQLite is already available
const isAvailable = await window.electron.ipcRenderer.invoke(
"sqlite-is-available",
);
if (!isAvailable) {
return false;
}
// Check if database is already open
const isOpen = await window.electron.ipcRenderer.invoke(
"sqlite-is-db-open",
{
database: this.dbName,
},
);
if (isOpen) {
logger.info(
"[ElectronPlatformService] SQLite is already ready and database is open",
);
sqliteInitState.isReady = true;
sqliteInitState.isInitializing = false;
sqliteInitState.lastReadyCheck = Date.now();
return true;
}
return false;
} catch (error) {
logger.warn(
"[ElectronPlatformService] Error checking existing readiness:",
error,
);
return false;
}
};
const attemptInitialization = async () => {
cleanup();
// Check if SQLite is already ready
if (await checkExistingReadiness()) {
this.isInitialized = true;
resolve();
return;
}
// If someone else is initializing, wait for them
if (sqliteInitState.isInitializing) {
logger.info(
"[ElectronPlatformService] Another initialization in progress, waiting...",
);
setTimeout(
attemptInitialization,
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.READY_CHECK_INTERVAL_MS,
);
return;
}
try {
sqliteInitState.isInitializing = true;
// Verify Electron API exposure first
await verifyElectronAPI();
logger.info(
"[ElectronPlatformService] Electron API verification successful",
);
if (!window.electron?.ipcRenderer) {
logger.warn("[ElectronPlatformService] IPC renderer not available");
reject(new Error("IPC renderer not available"));
return;
}
// Set up ready signal handler BEFORE setting timeout
window.electron.ipcRenderer.once("sqlite-ready", async () => {
cleanup();
logger.info(
"[ElectronPlatformService] Received SQLite ready signal",
);
try {
// Test SQLite operations after receiving ready signal
await testSQLiteOperations();
logger.info(
"[ElectronPlatformService] SQLite operations test successful",
);
this.isInitialized = true;
sqliteInitState.isReady = true;
sqliteInitState.isInitializing = false;
sqliteInitState.lastReadyCheck = Date.now();
resolve();
} catch (error) {
sqliteInitState.error = error as Error;
sqliteInitState.isInitializing = false;
logger.error(
"[ElectronPlatformService] SQLite operations test failed:",
error,
);
reject(error);
}
});
// Set up error handler
window.electron.ipcRenderer.once(
"database-status",
(...args: unknown[]) => {
cleanup();
const status = args[0] as { status: string; error?: string };
if (status.status === "error") {
this.dbFatalError = true;
sqliteInitState.error = new Error(
status.error || "Database initialization failed",
);
sqliteInitState.isInitializing = false;
reject(sqliteInitState.error);
}
},
);
// Set timeout for this attempt AFTER setting up handlers
this.initializationTimeout = setTimeout(() => {
if (
retryCount <
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.RETRY_ATTEMPTS
) {
retryCount++;
logger.warn(
`[ElectronPlatformService] SQLite initialization attempt ${retryCount} timed out, retrying...`,
);
setTimeout(
attemptInitialization,
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.RETRY_DELAY_MS,
);
} else {
cleanup();
sqliteInitState.isInitializing = false;
sqliteInitState.error = new Error(
"SQLite initialization timeout after all retries",
);
logger.error(
"[ElectronPlatformService] SQLite initialization failed after all retries",
);
reject(sqliteInitState.error);
}
}, ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION.TIMEOUT_MS);
} catch (error) {
cleanup();
sqliteInitState.error = error as Error;
sqliteInitState.isInitializing = false;
logger.error(
"[ElectronPlatformService] Initialization failed:",
error,
);
reject(error);
}
};
// Start first initialization attempt
attemptInitialization();
});
}
private async initializeDatabase(): Promise<void> {
if (this.isInitialized) return;
if (this.sqliteReadyPromise) await this.sqliteReadyPromise;
if (!window.electron?.sqlite) {
throw new Error("SQLite IPC bridge not available");
}
// Use IPC bridge with specific methods
this.sqlite = {
createConnection: async (options) => {
await window.electron.ipcRenderer.invoke("sqlite-create-connection", {
...options,
database: this.dbName,
});
},
query: async (options) => {
return await window.electron.ipcRenderer.invoke("sqlite-query", {
...options,
database: this.dbName,
});
},
run: async (options) => {
return await window.electron.ipcRenderer.invoke("sqlite-run", {
...options,
database: this.dbName,
});
},
execute: async (options) => {
await window.electron.ipcRenderer.invoke("sqlite-execute", {
...options,
database: this.dbName,
statements: [{ statement: options.statements }],
});
},
} as SQLiteOperations;
// Create the connection (idempotent)
await this.sqlite!.createConnection({
database: this.dbName,
encrypted: false,
mode: "no-encryption",
});
// Optionally, test the connection
await this.sqlite!.query({
database: this.dbName,
statement: "SELECT 1",
});
// Run migrations if needed
await this.runMigrations();
logger.info("[ElectronPlatformService] Database initialized successfully");
}
private async runMigrations(): Promise<void> {
if (!this.sqlite) {
throw new Error("SQLite not initialized");
}
// Create migrations table if it doesn't exist
await this.sqlite.execute({
database: this.dbName,
statements: `CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
});
// Get list of executed migrations
const result = await this.sqlite.query({
database: this.dbName,
statement: "SELECT name FROM migrations;",
});
const executedMigrations = new Set(
(result.values as unknown[][])?.map(
(row: unknown[]) => row[0] as string,
) || [],
);
// Run pending migrations in order
const migrations: Migration[] = [
{
name: "001_initial",
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identityEncrBase64 TEXT,
mnemonicEncrBase64 TEXT,
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secretBase64 TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
activeDid TEXT,
apiServer TEXT,
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT,
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT,
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT,
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT,
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN
);
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE IF NOT EXISTS logs (
date TEXT PRIMARY KEY,
message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS temp (
id TEXT PRIMARY KEY,
blobB64 TEXT
);
`,
},
];
for (const migration of migrations) {
if (!executedMigrations.has(migration.name)) {
await this.sqlite.execute({
database: this.dbName,
statements: migration.sql,
});
await this.sqlite.run({
database: this.dbName,
statement: "INSERT INTO migrations (name) VALUES (?)",
values: [migration.name],
});
logger.log(`Migration ${migration.name} executed successfully`);
}
}
}
/**
* Gets the capabilities of the Electron platform
* @returns Platform capabilities object
@@ -55,6 +533,17 @@ export class ElectronPlatformService implements PlatformService {
throw new Error("Not implemented");
}
/**
* Writes content to a file and opens the system share dialog.
* @param _fileName - Name of the file to create
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement using Electron's dialog and file system APIs
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Deletes a file from the filesystem.
* @param _path - Path to the file to delete
@@ -108,4 +597,320 @@ export class ElectronPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in Electron platform");
throw new Error("Not implemented");
}
private async enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
// Wait for any existing operations to complete
await this.operationQueue;
// Create a new promise for this operation
const operationPromise = (async () => {
try {
// Acquire lock
while (this.queueLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
this.queueLock = true;
// Execute operation
return await operation();
} finally {
// Release lock
this.queueLock = false;
}
})();
// Update the queue
this.operationQueue = operationPromise;
return operationPromise;
}
private async getConnection(): Promise<void> {
// If we already have a connection promise, return it
if (this.connectionPromise) {
return this.connectionPromise;
}
// If we're already connected, return immediately
if (this.connectionState === "connected") {
return Promise.resolve();
}
// Create new connection promise
this.connectionPromise = (async () => {
try {
this.connectionState = "connecting";
// Wait for any existing operations
await this.operationQueue;
// Create connection
await window.electron!.ipcRenderer.invoke("sqlite-create-connection", {
database: this.dbName,
encrypted: false,
mode: "no-encryption",
});
logger.debug("[ElectronPlatformService] Database connection created");
// Open database
await window.electron!.ipcRenderer.invoke("sqlite-open", {
database: this.dbName,
});
logger.debug("[ElectronPlatformService] Database opened");
// Verify database is open
const isOpen = await window.electron!.ipcRenderer.invoke(
"sqlite-is-db-open",
{
database: this.dbName,
},
);
if (!isOpen) {
throw new Error("[ElectronPlatformService] Database failed to open");
}
this.connectionState = "connected";
this.isConnectionOpen = true;
} catch (error) {
this.connectionState = "error";
this.connectionPromise = null;
throw error;
}
})();
return this.connectionPromise;
}
private async releaseConnection(): Promise<void> {
if (this.connectionState !== "connected") {
return;
}
try {
// Close database
await window.electron!.ipcRenderer.invoke("sqlite-close", {
database: this.dbName,
});
logger.debug("[ElectronPlatformService] Database closed");
// Close connection
await window.electron!.ipcRenderer.invoke("sqlite-close-connection", {
database: this.dbName,
});
logger.debug("[ElectronPlatformService] Database connection closed");
this.connectionState = "disconnected";
this.isConnectionOpen = false;
} catch (error) {
logger.error(
"[ElectronPlatformService] Failed to close database:",
error,
);
this.connectionState = "error";
} finally {
this.connectionPromise = null;
}
}
/**
* Executes a database query with proper connection lifecycle management.
* Opens connection, executes query, and ensures proper cleanup.
*
* @param sql - SQL query to execute
* @param params - Optional parameters for the query
* @returns Promise resolving to query results
* @throws Error if database operations fail
*/
async dbQuery<T = unknown>(
sql: string,
params: unknown[] = [],
): Promise<QueryExecResult<T>> {
if (this.dbFatalError) {
throw new Error(
"Database is in a fatal error state. Please restart the app.",
);
}
logger.debug('[ElectronPlatformService] [dbQuery] Enqueuing operation', {
sql: sql.substring(0, 100) + (sql.length > 100 ? '...' : ''),
paramCount: params.length,
timestamp: new Date().toISOString()
});
return this.enqueueOperation(async () => {
try {
// Get connection (will wait for existing connection if any)
console.log("[ElectronPlatformService] [dbQuery] Getting connection");
await this.getConnection();
console.log("[ElectronPlatformService] [dbQuery] Connection acquired");
// Execute query
console.log(
"[ElectronPlatformService] [dbQuery] Executing query",
{ sql, params },
);
const result = (await window.electron!.ipcRenderer.invoke(
"sqlite-query",
{
database: this.dbName,
statement: sql,
values: params,
},
)) as SQLiteQueryResult;
console.log(
"[ElectronPlatformService] [dbQuery] Query executed successfully",
{ result },
);
// Process results
const columns = result.values?.[0] ? Object.keys(result.values[0]) : [];
const processedResult = {
columns,
values: (result.values || []).map(
(row: Record<string, unknown>) => row as T,
),
};
console.log(
"[ElectronPlatformService] [dbQuery] Query processed successfully",
{ processedResult },
);
return processedResult;
} catch (error) {
console.error(
"[ElectronPlatformService] [dbQuery] Query failed:",
error,
);
throw error;
} finally {
// Release connection after query
await this.releaseConnection();
}
});
}
/**
* @see PlatformService.dbExec
*/
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
console.log("[ElectronPlatformService] [dbExec] Executing query", {
sql,
params,
});
if (this.dbFatalError) {
throw new Error(
"Database is in a fatal error state. Please restart the app.",
);
}
return this.enqueueOperation(async () => {
try {
// Get connection (will wait for existing connection if any)
console.log("[ElectronPlatformService] [dbExec] Getting connection");
await this.getConnection();
console.log("[ElectronPlatformService] [dbExec] Connection acquired");
// Execute query
console.log(
"[ElectronPlatformService] [dbExec] Executing query",
{ sql, params },
);
const result = (await window.electron!.ipcRenderer.invoke(
"sqlite-run",
{
database: this.dbName,
statement: sql,
values: params,
},
)) as SQLiteQueryResult;
console.log(
"[ElectronPlatformService] [dbExec] Query executed successfully",
{ result },
);
return {
changes: result.changes,
lastId: result.lastId,
};
} catch (error) {
console.error(
"[ElectronPlatformService] [dbExec] Query failed:",
error,
);
throw error;
} finally {
// Release connection after query
await this.releaseConnection();
}
});
}
async initialize(): Promise<void> {
await this.initializeDatabase();
}
async execute(sql: string, params: unknown[] = []): Promise<void> {
await this.initializeDatabase();
if (this.dbFatalError) {
throw new Error(
"Database is in a fatal error state. Please restart the app.",
);
}
if (!this.sqlite) {
throw new Error("SQLite not initialized");
}
await this.sqlite.run({
database: this.dbName,
statement: sql,
values: params,
});
}
async close(): Promise<void> {
// Optionally implement close logic if needed
}
async run(sql: string, params: unknown[] = []): Promise<ElectronQueryExecResult> {
logger.debug('[ElectronPlatformService] [dbRun] Executing SQL:', {
sql: sql.substring(0, 100) + (sql.length > 100 ? '...' : ''),
params: formatLogObject(params),
timestamp: new Date().toISOString()
});
try {
const result = (await window.electron!.ipcRenderer.invoke(
'sqlite-run',
{
database: this.dbName,
statement: sql,
values: params,
},
)) as SQLiteQueryResult;
logger.debug('[ElectronPlatformService] [dbRun] SQL execution result:', {
result: formatLogObject(result),
timestamp: new Date().toISOString()
});
return {
success: true,
changes: result.changes,
lastId: result.lastId,
};
} catch (error) {
logger.error('[ElectronPlatformService] [dbRun] SQL execution failed:', {
error: error instanceof Error ? {
name: error.name,
message: error.message,
stack: error.stack
} : formatLogObject(error),
sql,
params: formatLogObject(params),
timestamp: new Date().toISOString()
});
throw error;
}
}
}

View File

@@ -4,6 +4,7 @@ import {
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
/**
* Platform service implementation for PyWebView platform.
@@ -109,4 +110,26 @@ export class PyWebViewPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in PyWebView platform");
throw new Error("Not implemented");
}
dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
throw new Error("Not implemented for " + sql + " with params " + params);
}
dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
throw new Error("Not implemented for " + sql + " with params " + params);
}
/**
* Should write and share a file using the Python backend.
* @param _fileName - Name of the file to write and share
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing and sharing through pywebview's Python-JavaScript bridge
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
logger.error("writeAndShareFile not implemented in PyWebView platform");
throw new Error("Not implemented");
}
}

View File

@@ -4,6 +4,8 @@ import {
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
import databaseService from "../AbsurdSqlDatabaseService";
/**
* Platform service implementation for web browser platform.
@@ -359,4 +361,33 @@ export class WebPlatformService implements PlatformService {
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform");
}
/**
* @see PlatformService.dbQuery
*/
dbQuery(
sql: string,
params?: unknown[],
): Promise<QueryExecResult | undefined> {
return databaseService.query(sql, params).then((result) => result[0]);
}
/**
* @see PlatformService.dbExec
*/
dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
return databaseService.run(sql, params);
}
async dbGetOneRow(
sql: string,
params?: unknown[],
): Promise<unknown[] | undefined> {
return databaseService
.query(sql, params)
.then((result: QueryExecResult[]) => result[0]?.values[0]);
}
}

View File

@@ -1,6 +1,7 @@
import axios from "axios";
import * as didJwt from "did-jwt";
import { AppString } from "../constants/app";
import { AppString, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db";
import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto";
@@ -16,7 +17,10 @@ export async function testServerRegisterUser() {
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
const settings = await retrieveSettingsForActiveAccount();
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
// Make a claim
const vcClaim = {

45
src/types/absurd-sql.d.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
declare module 'absurd-sql/dist/indexeddb-backend' {
export default class IndexedDBBackend {
constructor(options?: {
dbName?: string;
storeName?: string;
onReady?: () => void;
onError?: (error: Error) => void;
});
init(): Promise<void>;
exec(sql: string, params?: any[]): Promise<any>;
close(): Promise<void>;
}
}
declare module 'absurd-sql/dist/indexeddb-main-thread' {
export function initBackend(worker: Worker): Promise<void>;
export default class IndexedDBMainThread {
constructor(options?: {
dbName?: string;
storeName?: string;
onReady?: () => void;
onError?: (error: Error) => void;
});
init(): Promise<void>;
exec(sql: string, params?: any[]): Promise<any>;
close(): Promise<void>;
}
}
declare module 'absurd-sql' {
export class SQLiteFS {
constructor(fs: unknown, backend: IndexedDBBackend);
init(): Promise<void>;
close(): Promise<void>;
exec(sql: string, params?: any[]): Promise<any>;
prepare(sql: string): Promise<any>;
run(sql: string, params?: any[]): Promise<any>;
get(sql: string, params?: any[]): Promise<any>;
all(sql: string, params?: any[]): Promise<any[]>;
}
export * from 'absurd-sql/dist/indexeddb-backend';
export * from 'absurd-sql/dist/indexeddb-main-thread';
}

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