diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index ed483a8d..5c14d285 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -6,8 +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 } from "@/interfaces/database"; +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. @@ -15,8 +27,168 @@ import { QueryExecResult } from "@/interfaces/database"; * - 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 { + 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 { + 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 @@ -478,13 +650,54 @@ export class CapacitorPlatformService implements PlatformService { return Promise.resolve(); } - dbQuery(sql: string, params?: unknown[]): Promise { - throw new Error("Not implemented for " + sql + " with params " + params); + /** + * @see PlatformService.dbQuery + */ + async dbQuery(sql: string, params?: unknown[]): Promise { + 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)}`, + ); + } } - dbExec( + + /** + * @see PlatformService.dbExec + */ + async dbExec( sql: string, params?: unknown[], ): Promise<{ changes: number; lastId?: number }> { - throw new Error("Not implemented for " + sql + " with params " + params); + 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)}`, + ); + } } } diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 07b6d5ed..5bdd455f 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/src/services/platforms/ElectronPlatformService.ts @@ -4,21 +4,188 @@ import { PlatformCapabilities, } from "../PlatformService"; import { logger } from "../../utils/logger"; -import { QueryExecResult } from "@/interfaces/database"; +import { QueryExecResult, SqlValue } from "@/interfaces/database"; +import { + SQLiteConnection, + SQLiteDBConnection, + CapacitorSQLite, + Changes, +} from "@capacitor-community/sqlite"; +import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; + +interface Migration { + name: string; + sql: string; +} /** * 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. - * - * @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 + * Provides native desktop functionality through Electron and Capacitor plugins for: + * - File system operations (TODO) + * - Camera integration (TODO) + * - SQLite database operations + * - System-level features (TODO) */ export class ElectronPlatformService 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 { + 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 { + 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 Electron platform * @returns Platform capabilities object @@ -56,6 +223,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 { + throw new Error("Not implemented"); + } + /** * Deletes a file from the filesystem. * @param _path - Path to the file to delete @@ -110,13 +288,54 @@ export class ElectronPlatformService implements PlatformService { throw new Error("Not implemented"); } - dbQuery(sql: string, params?: unknown[]): Promise { - throw new Error("Not implemented for " + sql + " with params " + params); + /** + * @see PlatformService.dbQuery + */ + async dbQuery(sql: string, params?: unknown[]): Promise { + 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)}`, + ); + } } - dbExec( + + /** + * @see PlatformService.dbExec + */ + async dbExec( sql: string, params?: unknown[], ): Promise<{ changes: number; lastId?: number }> { - throw new Error("Not implemented for " + sql + " with params " + params); + 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)}`, + ); + } } }