Compare commits

...

18 Commits

Author SHA1 Message Date
2ca2c1d4b3 docs: add notes on iOS installs & use of window.location.href 2025-11-11 18:58:54 -07:00
Matthew Raymer
cb8d8bed4c fix: improve deep link handling and consolidate cleanup methods
- Fix window.location.href usage in DeepLinkRedirectView for Capacitor
  context: check if already in native app and use router navigation
  instead of window.location.href (which doesn't work in Capacitor)
- Consolidate teardown() and _cleanupOnFailure() by extracting shared
  cleanup logic to _performCleanup() method
- Make teardown() private since it's unused (kept for potential future use)
- Ensure both cleanup paths reset initializationPromise for consistency

This fixes navigation issues when DeepLinkRedirectView is accessed from
within the Capacitor app and removes code duplication in cleanup logic.
2025-11-10 01:29:56 +00:00
Matthew Raymer
11658140ae refactor: reduce logging noise and simplify database service code
- Removed excessive debug/success logs from CapacitorPlatformService
- Removed verbose singleton tracking logs from PlatformServiceFactory
- Removed unnecessary platform !== 'web' check in applyPragmas (service only loads for Capacitor)
- Compressed try-catch blocks in applyPragmas for cleaner code
- Extracted executor adapters to createExecutor() methods in both CapacitorPlatformService and AbsurdSqlDatabaseService for consistency and testability

This reduces console noise while maintaining essential error logging.
2025-11-09 10:34:43 +00:00
Matthew Raymer
85a64b06d7 fix: replace hard reload with SPA routing in seed phrase reminder
- Replace window.location.href with router.push() for /seed-backup navigation
- Prevents full page reload that was causing app remount and singleton loss
- Maintains singleton persistence and database connection across navigation

This fixes the issue where navigating to seed-backup page via the reminder
modal was triggering a full page reload, causing the PlatformService singleton
to be lost and database to be re-initialized unnecessarily.
2025-11-09 09:17:03 +00:00
Matthew Raymer
72e23a9109 style: fix code formatting in CapacitorPlatformService
- Split long condition across multiple lines for better readability
- Applied by ESLint auto-fix
2025-11-09 08:55:58 +00:00
Matthew Raymer
ceaf4ede11 refactor: implement HMR-safe singleton and improve database connection recovery
- Refactor PlatformServiceFactory to use globalThis-based singleton storage
  - Store singleton in globalThis/window/self for persistence across module reloads
  - Add comprehensive debug logging for singleton lifecycle tracking
  - Implement Proxy-based lazy initialization to avoid circular dependencies
  - Add HMR support to preserve singleton across hot module replacement

- Enhance CapacitorPlatformService database connection recovery
  - Improve getOrCreateConnection() with robust error handling
  - Add recovery path for 'already exists' desync scenarios
  - Wrap retrieveConnection and db.open() calls in try-catch blocks
  - Handle cases where retrieveConnection throws instead of returning null
  - Add 50ms delay after closeConnection to allow native cleanup

- Add shared SQLite connection manager (sqlite.ts)
  - Centralize SQLiteConnection instance creation
  - Prevent connection desync issues across the application

This addresses the issue where PlatformService singleton was being
recreated on navigation, causing repeated database initialization
attempts and connection desync errors.
2025-11-09 08:55:30 +00:00
Matthew Raymer
523f88fd0d refactor: extract shared operation queue to reduce duplication
Extract queue management logic into reusable OperationQueue utility
shared between CapacitorPlatformService and AbsurdSqlDatabaseService.

Changes:
- Create OperationQueue.ts: shared queue handler with executor pattern
- Create types.ts: extract QueuedOperation interface to shared location
- Refactor CapacitorPlatformService: use shared queue, remove 65 lines
- Refactor AbsurdSqlDatabaseService: use shared queue, remove 22 lines
- Remove redundant logging wrapper: use centralized logger directly

Benefits:
- DRY: eliminated ~87 lines of duplicated queue logic
- Maintainability: queue behavior centralized in one place
- Consistency: both services use identical queue processing
- Flexibility: platform-specific parameter conversion preserved

File size reduction:
- CapacitorPlatformService: 1,525 → 1,465 lines (-60 lines)
- AbsurdSqlDatabaseService: 271 → 249 lines (-22 lines)
- New shared code: 150 lines (OperationQueue + types)

Net reduction: ~87 lines of duplicated code eliminated.
2025-11-09 06:27:46 +00:00
Matthew Raymer
ee57fe9ea6 Merge branch 'master' into refactor-initialize 2025-11-09 02:38:33 +00:00
5050156beb fix: a type, plus add the type-check to the mobile build scripts 2025-11-08 08:31:42 -07:00
Matthew Raymer
471bdd6b92 docs: add v3.3.3+ hardening invariants and ordering guards
- Added comprehensive INVARIANTS documentation to _applyPragmasOnExistingConnection()
- Added ORDERING GUARD documentation to _initialize() and _ensureConnection()
- Enhanced iOS compatibility logging for WAL mode failures
- Added invariant reference comments throughout PRAGMA code paths
- Improved error handling with non-fatal WAL failure handling
- All invariants documented to prevent regression:
  1. foreign_keys via run()
  2. journal_mode via query() with iOS compatibility
  3. busy_timeout via query() with fallback
  4. synchronous skipped in fallback path
  5. this.db must be set before returning

This locks in the v3.3.3 fix and ensures stability on Android and iOS.
2025-11-07 12:28:41 +00:00
Matthew Raymer
c26b8daaf7 fix: had to remove a select from migration for Android to migrate. 2025-11-07 12:20:47 +00:00
Matthew Raymer
16db790c5f Update Android build version to 1.1.3-beta (versionCode 48) 2025-11-07 11:59:45 +00:00
Matthew Raymer
59434ff5f7 fix(CapacitorPlatformService): ensure connection opened before PRAGMAs (v3.3.4)
- Fix fallback PRAGMA method to open newly created connections immediately
- Prevents 'database not opened' errors when applying PRAGMAs
- Newly created connections are now opened before PRAGMA application
- Adds explicit logging for connection opening in fallback path
- Ensures journal_mode is correctly set to WAL instead of unknown

This fixes the issue where PRAGMAs were being applied on connections
that weren't yet opened, causing initialization failures. The fix ensures
that after creating a connection in the fallback path, it is immediately
opened before attempting to apply PRAGMAs.

Tested: Connection opens successfully, PRAGMAs apply correctly,
journal_mode set to WAL, initialization completes in ~38ms.
2025-11-07 10:32:28 +00:00
Matthew Raymer
239666e137 refactor(platforms/capacitor): improve database initialization robustness
Refactored _initialize() method to eliminate brittle error message parsing,
add transactional migration safety, and improve observability.

Key improvements:
- Replaced error message parsing with deterministic capability checks
  (_ensureConnection helper)
- Wrapped migrations in BEGIN IMMEDIATE transaction with rollback
- Added platform-aware PRAGMA application (WAL on native, not web)
- Added structured phase timing logs (connect, open, pragmas, migrate, verify)
- Enhanced error cleanup with best-effort connection closure
- Updated integrity verification to throw on critical failures
- Extracted helper methods (_asMessage, _ensureConnection, _applyPragmas)
- Added configuration properties (dbVersion, encryption) to replace literals

Removed single-flight logic from _initialize() (handled by initializeDatabase()).
All connection state handling now uses isConnection() checks instead of
string matching, making initialization deterministic across JS/native
boundaries.
2025-11-07 07:05:55 +00:00
d265a9f78c chore: bump version and add "-beta" 2025-11-06 08:56:33 -07:00
f848de15f1 chore: bump version to 1.1.2 build 47 (for fix to seed backup) 2025-11-06 08:54:11 -07:00
ebaf2dedf0 Merge pull request 'fix: database connection error causing navigation redirect on iOS/Android' (#220) from fix-sqlite-connection-error-mobile into master
Reviewed-on: #220
2025-11-06 09:52:31 -05:00
Jose Olarte III
749204f96b fix: database connection error causing navigation redirect on iOS/Android
Handle "Connection already exists" error when initializing SQLite database
on Capacitor platforms. The native connection can persist across app
restarts while the JavaScript connection Map is empty, causing a mismatch.

When createConnection fails with "already exists":
- Check if connection exists in JavaScript Map and retrieve it if present
- If not in Map, close the native connection and recreate to sync both sides
- Handle "already open" errors gracefully when opening existing connections

This fixes the issue where clicking "Backup Identifier Seed" would redirect
to StartView instead of SeedBackupView due to database initialization
failures in the router navigation guard.

Fixes navigation issue on both iOS and Android platforms.
2025-11-06 21:38:51 +08:00
17 changed files with 937 additions and 503 deletions

View File

@@ -1151,28 +1151,31 @@ If you need to build manually or want to understand the individual steps:
- ... and you may have to fix these, especially with pkgx:
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
cd ios/App
pod install
```
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
```bash
cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
```bash
cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
```
##### 2. Build
Here's prod. Also available: test, dev
Here's prod. Also available: test, dev
```bash
npm run build:ios:prod
```
```bash
npm run build:ios:prod
```
3.1. Use Xcode to build and run on simulator or device.
@@ -1197,7 +1200,8 @@ If you need to build manually or want to understand the individual steps:
- It can take 15 minutes for the build to show up in the list of builds.
- You'll probably have to "Manage" something about encryption, disallowed in France.
- Then "Save" and "Add to Review" and "Resubmit to App Review".
- Eventually it'll be "Ready for Distribution" which means
- Eventually it'll be "Ready for Distribution" which means it's live
- When finished, bump package.json version
### Android Build
@@ -1315,26 +1319,26 @@ The recommended way to build for Android is using the automated build script:
#### Android Manual Build Process
##### 1. Bump the version in package.json, then here: android/app/build.gradle
##### 1. Bump the version in package.json, then update these versions & run:
```bash
perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle
```
```bash
perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
```
##### 2. Build
Here's prod. Also available: test, dev
```bash
npm run build:android:prod
```
```bash
npm run build:android:prod
```
##### 3. Open the project in Android Studio
```bash
npx cap open android
```
```bash
npx cap open android
```
##### 4. Use Android Studio to build and run on emulator or device
@@ -1379,6 +1383,8 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send
those changes or your (closed) testers won't see it.
- When finished, bump package.json version
### Capacitor Operations
```bash

View File

@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03
### Added

View File

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

View File

@@ -403,7 +403,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46;
CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.1;
MARKETING_VERSION = 1.1.2;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.2-beta",
"version": "1.1.3-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"

View File

@@ -436,7 +436,21 @@ fi
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Clean Gradle build
# Step 6: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 6: Build based on type
# Step 7: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi
# Step 7: Sync with Capacitor
# Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8: Generate assets
# Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 9: Build APK/AAB if requested
# Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
@@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
fi
# Step 10: Auto-run app if requested
# Step 11: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android" || {
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
log_success "Android app launched successfully!"
fi
# Step 11: Open Android Studio if requested
# Step 12: Open Android Studio if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Android Studio" "npx cap open android" || exit 8
fi

View File

@@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
log_info "Cleaning dist directory..."
clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode
# Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then
@@ -390,16 +404,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi
# Step 5: Sync with Capacitor
# Step 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Generate assets
# Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 7: Build iOS app
# Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5
# Step 8: Build IPA/App if requested
# Step 9: Build IPA/App if requested
if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..."
cd ios/App
@@ -426,12 +440,12 @@ if [ "$BUILD_APP" = true ]; then
log_success "App bundle built successfully"
fi
# Step 9: Auto-run app if requested
# Step 10: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
fi
# Step 10: Open Xcode if requested
# Step 11: Open Xcode if requested
if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Xcode" "npx cap open ios" || exit 8
fi

View File

@@ -1686,7 +1686,10 @@ export async function register(
"Registration thrown error:",
errorMessage || JSON.stringify(err),
);
return { error: errorMessage || "Got a server error when registering." };
return {
error:
(errorMessage as string) || "Got a server error when registering.",
};
}
return { error: "Got a server error when registering." };
}

View File

@@ -21,14 +21,8 @@ import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend";
import { runMigrations } from "../db-sql/migration";
import type { DatabaseService, QueryExecResult } from "../interfaces/database";
import { logger } from "@/utils/logger";
interface QueuedOperation {
type: "run" | "query";
sql: string;
params: unknown[];
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
import { OperationQueue, QueueExecutor } from "./platforms/OperationQueue";
import { QueuedOperation } from "./platforms/types";
interface AbsurdSqlDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
@@ -43,8 +37,7 @@ class AbsurdSqlDatabaseService implements DatabaseService {
private db: AbsurdSqlDatabase | null;
private initialized: boolean;
private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false;
private operationQueue = new OperationQueue<AbsurdSqlDatabase>();
private constructor() {
this.db = null;
@@ -161,42 +154,30 @@ class AbsurdSqlDatabaseService implements DatabaseService {
this.processQueue();
}
/**
* Create executor adapter for AbsurdSQL API
*/
private createExecutor(): QueueExecutor<AbsurdSqlDatabase> {
return {
executeRun: async (db, sql, params) => {
return await db.run(sql, params);
},
executeQuery: async (db, sql, params) => {
return await db.exec(sql, params);
},
};
}
private async processQueue(): Promise<void> {
if (this.isProcessingQueue || !this.initialized || !this.db) {
if (!this.initialized || !this.db) {
return;
}
this.isProcessingQueue = true;
while (this.operationQueue.length > 0) {
const operation = this.operationQueue.shift();
if (!operation) continue;
try {
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;
}
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;
await this.operationQueue.processQueue(
this.db,
this.createExecutor(),
"AbsurdSqlDatabaseService",
);
}
private async queueOperation<R>(
@@ -204,21 +185,24 @@ class AbsurdSqlDatabaseService implements DatabaseService {
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);
const operation: QueuedOperation = {
type,
sql,
params,
resolve: (_value: unknown) => {
// No-op, will be wrapped by OperationQueue
},
reject: () => {
// No-op, will be wrapped by OperationQueue
},
};
// If we're already initialized, start processing the queue
if (this.initialized && this.db) {
this.processQueue();
}
});
return this.operationQueue.queueOperation<R>(
operation,
this.initialized,
this.db,
() => this.processQueue(),
);
}
private async waitForInitialization(): Promise<void> {

View File

@@ -4,76 +4,144 @@ import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService";
import { ElectronPlatformService } from "./platforms/ElectronPlatformService";
/**
* Factory class for creating platform-specific service implementations.
* Implements the Singleton pattern to ensure only one instance of PlatformService exists.
* HMR-safe global singleton storage for PlatformService
*
* The factory determines which platform implementation to use based on the VITE_PLATFORM
* environment variable. Supported platforms are:
* - capacitor: Mobile platform using Capacitor
* - electron: Desktop platform using Electron with Capacitor
* - web: Default web platform (fallback)
* Uses multiple fallbacks to ensure persistence across module reloads:
* 1. globalThis (standard, works in most environments)
* 2. window (browser fallback)
* 3. self (web worker fallback)
*/
declare global {
// eslint-disable-next-line no-var
var __PLATFORM_SERVICE_SINGLETON__: PlatformService | undefined;
}
/**
* Get the global object for singleton storage
* Uses multiple fallbacks to ensure compatibility
*/
function getGlobal(): typeof globalThis {
if (typeof globalThis !== "undefined") return globalThis;
if (typeof window !== "undefined") return window as typeof globalThis;
if (typeof self !== "undefined") return self as typeof globalThis;
// Fallback for Node.js environments
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {} as any;
}
/**
* Factory function to create platform-specific service implementation
*
* Uses console.log instead of logger to avoid circular dependency
* (logger imports PlatformServiceFactory)
*/
function create(): PlatformService {
const which = import.meta.env?.VITE_PLATFORM ?? "web";
if (which === "capacitor") return new CapacitorPlatformService();
if (which === "electron") return new ElectronPlatformService();
return new WebPlatformService();
}
/**
* Get or create the HMR-safe singleton instance of PlatformService
*
* Uses lazy initialization to avoid circular dependency issues at module load time.
*/
function getPlatformSvc(): PlatformService {
const global = getGlobal();
const exists = global.__PLATFORM_SERVICE_SINGLETON__ !== undefined;
if (!exists) {
global.__PLATFORM_SERVICE_SINGLETON__ = create();
// Verify it was stored
if (!global.__PLATFORM_SERVICE_SINGLETON__) {
// eslint-disable-next-line no-console
console.error(
"[PlatformServiceFactory] ERROR: Singleton creation failed - storage returned undefined",
);
}
}
// Type guard: ensure singleton exists (should never be undefined at this point)
const singleton = global.__PLATFORM_SERVICE_SINGLETON__;
if (!singleton) {
// eslint-disable-next-line no-console
console.error(
"[PlatformServiceFactory] CRITICAL: Singleton is undefined after creation/retrieval",
);
// Fallback: create a new one
global.__PLATFORM_SERVICE_SINGLETON__ = create();
return global.__PLATFORM_SERVICE_SINGLETON__;
}
return singleton;
}
/**
* HMR-safe singleton instance of PlatformService
*
* This is the ONLY way to access PlatformService throughout the application.
* Do not create new instances of platform services directly.
*
* Uses lazy initialization via Proxy to avoid circular dependency issues at module load time.
*
* @example
* ```typescript
* const platformService = PlatformServiceFactory.getInstance();
* await platformService.takePicture();
* import { PlatformSvc } from "./services/PlatformServiceFactory";
* await PlatformSvc.takePicture();
* ```
*/
export class PlatformServiceFactory {
private static instance: PlatformService | null = null;
private static callCount = 0; // Debug counter
private static creationLogged = false; // Only log creation once
export const PlatformSvc = new Proxy({} as PlatformService, {
get(_target, prop) {
const svc = getPlatformSvc();
const value = (svc as unknown as Record<string, unknown>)[prop as string];
// Bind methods to maintain 'this' context
if (typeof value === "function") {
return value.bind(svc);
}
return value;
},
});
// Preserve singleton across Vite HMR
if (import.meta?.hot) {
import.meta.hot.accept(() => {
// Don't recreate on HMR - keep existing instance
const global = getGlobal();
if (!global.__PLATFORM_SERVICE_SINGLETON__) {
// Restore singleton if it was lost during HMR
global.__PLATFORM_SERVICE_SINGLETON__ = getPlatformSvc();
}
});
import.meta.hot.dispose(() => {
// Don't delete - keep the global instance
// The singleton will persist in globalThis/window/self
});
}
/**
* Legacy factory class for backward compatibility
* @deprecated Use `PlatformSvc` directly instead
*/
export class PlatformServiceFactory {
/**
* Gets or creates the singleton instance of PlatformService.
* Creates the appropriate platform-specific implementation based on environment.
*
* @returns {PlatformService} The singleton instance of PlatformService
* Gets the singleton instance of PlatformService.
* @deprecated Use `PlatformSvc` directly instead
*/
public static getInstance(): PlatformService {
PlatformServiceFactory.callCount++;
if (PlatformServiceFactory.instance) {
// Normal case - return existing instance silently
return PlatformServiceFactory.instance;
}
// Only log when actually creating the instance
const platform = process.env.VITE_PLATFORM || "web";
if (!PlatformServiceFactory.creationLogged) {
// Use console for critical startup message to avoid circular dependency
// eslint-disable-next-line no-console
console.log(
`[PlatformServiceFactory] Creating singleton instance for platform: ${platform}`,
);
PlatformServiceFactory.creationLogged = true;
}
switch (platform) {
case "capacitor":
PlatformServiceFactory.instance = new CapacitorPlatformService();
break;
case "electron":
// Use a specialized electron service that extends CapacitorPlatformService
PlatformServiceFactory.instance = new ElectronPlatformService();
break;
case "web":
default:
PlatformServiceFactory.instance = new WebPlatformService();
break;
}
return PlatformServiceFactory.instance;
return PlatformSvc;
}
/**
* Debug method to check singleton usage stats
*/
public static getStats(): { callCount: number; instanceExists: boolean } {
const global = getGlobal();
return {
callCount: PlatformServiceFactory.callCount,
instanceExists: PlatformServiceFactory.instance !== null,
callCount: 0, // Deprecated - no longer tracking
instanceExists: global.__PLATFORM_SERVICE_SINGLETON__ !== undefined,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
/**
* Shared operation queue handler for database services
*
* Provides a reusable queue mechanism for database operations that need to
* wait for initialization before execution.
*/
import { QueuedOperation } from "./types";
import { logger } from "../../utils/logger";
export interface QueueExecutor<TDb> {
executeRun(db: TDb, sql: string, params: unknown[]): Promise<unknown>;
executeQuery(db: TDb, sql: string, params: unknown[]): Promise<unknown>;
executeRawQuery?(db: TDb, sql: string, params: unknown[]): Promise<unknown>;
}
export class OperationQueue<TDb> {
private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false;
/**
* Process queued operations
*/
async processQueue(
db: TDb,
executor: QueueExecutor<TDb>,
serviceName: string,
): Promise<void> {
if (this.isProcessingQueue || !db) {
return;
}
this.isProcessingQueue = true;
while (this.operationQueue.length > 0) {
const operation = this.operationQueue.shift();
if (!operation) continue;
try {
let result: unknown;
switch (operation.type) {
case "run":
result = await executor.executeRun(
db,
operation.sql,
operation.params,
);
break;
case "query":
result = await executor.executeQuery(
db,
operation.sql,
operation.params,
);
break;
case "rawQuery":
if (executor.executeRawQuery) {
result = await executor.executeRawQuery(
db,
operation.sql,
operation.params,
);
} else {
// Fallback to query if rawQuery not supported
result = await executor.executeQuery(
db,
operation.sql,
operation.params,
);
}
break;
}
operation.resolve(result);
} catch (error) {
logger.error(
`[${serviceName}] Error while processing SQL queue:`,
error,
);
logger.error(
`[${serviceName}] Failed operation - Type: ${operation.type}, SQL: ${operation.sql}`,
);
logger.error(
`[${serviceName}] Failed operation - Params:`,
operation.params,
);
operation.reject(error);
}
}
this.isProcessingQueue = false;
}
/**
* Queue an operation for later execution
*
* @param operation - Pre-constructed operation object (allows platform-specific parameter conversion)
* @param initialized - Whether the database is initialized
* @param db - Database connection (if available)
* @param onQueue - Callback to trigger queue processing
*/
queueOperation<R>(
operation: QueuedOperation,
initialized: boolean,
db: TDb | null,
onQueue: () => void,
): Promise<R> {
return new Promise<R>((resolve, reject) => {
// Wrap the operation's resolve/reject to match our Promise
const wrappedOperation: QueuedOperation = {
...operation,
resolve: (value: unknown) => {
operation.resolve(value);
resolve(value as R);
},
reject: (reason: unknown) => {
operation.reject(reason);
reject(reason);
},
};
this.operationQueue.push(wrappedOperation);
// If already initialized, trigger queue processing
if (initialized && db) {
onQueue();
}
});
}
/**
* Get current queue length (for debugging)
*/
getQueueLength(): number {
return this.operationQueue.length;
}
}

View File

@@ -0,0 +1,20 @@
/**
* Shared SQLite connection manager for Capacitor platform
*
* Ensures only one SQLiteConnection instance exists across the application,
* preventing connection desync issues and unnecessary connection recreation.
*/
import { CapacitorSQLite, SQLiteConnection } from "@capacitor-community/sqlite";
/**
* Native Capacitor SQLite plugin instance
* This is the bridge to the native SQLite implementation
*/
export const CAP_SQLITE = CapacitorSQLite;
/**
* Shared SQLite connection manager
* Use this instance throughout the application - do not create new SQLiteConnection instances
*/
export const SQLITE = new SQLiteConnection(CAP_SQLITE);

View File

@@ -0,0 +1,13 @@
/**
* Types for platform services
*/
export interface QueuedOperation {
type: "run" | "query" | "rawQuery";
sql: string;
params: unknown[];
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
export type QueuedOperationType = QueuedOperation["type"];

View File

@@ -1,4 +1,5 @@
import { NotificationIface } from "@/constants/app";
import router from "@/router";
const SEED_REMINDER_KEY = "seedPhraseReminderLastShown";
const REMINDER_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
@@ -53,8 +54,8 @@ export function createSeedReminderNotification(): NotificationIface {
yesText: "Backup Identifier Seed",
noText: "Remind me Later",
onYes: async () => {
// Navigate to seed backup page
window.location.href = "/seed-backup";
// Navigate to seed backup page using SPA routing
await router.push({ path: "/seed-backup" });
},
onNo: async () => {
// Mark as shown so it won't appear again for 24 hours

View File

@@ -157,11 +157,27 @@ export default class DeepLinkRedirectView extends Vue {
}
try {
const capabilities = this.platformService.getCapabilities();
// If we're already in the native app, use router navigation instead
// of window.location.href (which doesn't work properly in Capacitor)
if (capabilities.isNativeApp) {
// Navigate directly using the router
const destinationPath = `/${this.destinationUrl}`;
this.$router.push(destinationPath).catch((error) => {
logger.error("Router navigation failed: " + errorStringForLog(error));
this.pageError =
"Unable to navigate to the destination. Please use a manual option below.";
});
return;
}
// For web contexts, use window.location.href to redirect to app
// For mobile, try the deep link URL; for desktop, use the web URL
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
// Method 1: Try window.location.href (works on most browsers)
window.location.href = redirectUrl;
window.location.href = redirectUrl; // Do not use this on native apps! The channel to Capacitor gets messed up.
// Method 2: Fallback - create and click a link element
setTimeout(() => {