You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

721 lines
22 KiB

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;
}
export interface SQLiteQueryResult {
values?: Record<string, unknown>[];
changes?: { changes: number; lastId?: number };
}
/**
* 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 } }>;
}
/**
* Platform service implementation for Electron (desktop) platform.
* Provides native desktop functionality through Electron and Capacitor plugins for:
* - File system operations (TODO)
* - Camera integration (TODO)
* - SQLite database operations
* - System-level features (TODO)
*
* @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;
// SQLite initialization configuration
private static readonly SQLITE_CONFIG = {
INITIALIZATION: {
TIMEOUT_MS: 1000, // with retries, stay under 5 seconds
RETRY_ATTEMPTS: 3,
RETRY_DELAY_MS: 1000,
READY_CHECK_INTERVAL_MS: 100, // How often to check if SQLite is already ready
},
};
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(); // Clear any existing timeout
// 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 timeout for this attempt
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);
// Set up ready signal handler
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);
}
},
);
} 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);
},
query: async (options) => {
return await window.electron.ipcRenderer.invoke('sqlite-query', options);
},
run: async (options) => {
return await window.electron.ipcRenderer.invoke('sqlite-run', options);
},
execute: async (options) => {
await window.electron.ipcRenderer.invoke('sqlite-execute', {
database: options.database,
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
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false, // Not implemented yet
hasCamera: false, // Not implemented yet
isMobile: false,
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
};
}
/**
* Reads a file from the filesystem.
* @param _path - Path to the file to read
* @returns Promise that should resolve to file contents
* @throws Error with "Not implemented" message
* @todo Implement file reading using Electron's file system API
*/
async readFile(_path: string): Promise<string> {
throw new Error("Not implemented");
}
/**
* Writes content to a file.
* @param _path - Path where to write the file
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing using Electron's file system API
*/
async writeFile(_path: string, _content: string): Promise<void> {
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
* @throws Error with "Not implemented" message
* @todo Implement file deletion using Electron's file system API
*/
async deleteFile(_path: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Lists files in the specified directory.
* @param _directory - Path to the directory to list
* @returns Promise that should resolve to array of filenames
* @throws Error with "Not implemented" message
* @todo Implement directory listing using Electron's file system API
*/
async listFiles(_directory: string): Promise<string[]> {
throw new Error("Not implemented");
}
/**
* Should open system camera to take a picture.
* @returns Promise that should resolve to captured image data
* @throws Error with "Not implemented" message
* @todo Implement camera access using Electron's media APIs
*/
async takePicture(): Promise<ImageResult> {
logger.error("takePicture not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should open system file picker for selecting an image.
* @returns Promise that should resolve to selected image data
* @throws Error with "Not implemented" message
* @todo Implement file picker using Electron's dialog API
*/
async pickImage(): Promise<ImageResult> {
logger.error("pickImage not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should handle deep link URLs for the desktop application.
* @param _url - The deep link URL to handle
* @throws Error with "Not implemented" message
* @todo Implement deep link handling using Electron's protocol handler
*/
async handleDeepLink(_url: string): Promise<void> {
logger.error("handleDeepLink not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* 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>> {
logger.debug(
"[ElectronPlatformService] [dbQuery] TEMPORARY TEST: Returning empty result for query:",
{
sql,
params,
timestamp: new Date().toISOString(),
},
);
// TEMPORARY TEST: Return empty result
return {
columns: [],
values: [],
};
// Original implementation commented out for testing
/*
if (this.dbFatalError) {
throw new Error("Database is in a fatal error state. Please restart the app.");
}
if (!window.electron?.ipcRenderer) {
throw new Error("IPC renderer not available");
}
try {
// Check SQLite availability first
const isAvailable = await window.electron.ipcRenderer.invoke('sqlite-is-available');
if (!isAvailable) {
throw new Error('[ElectronPlatformService] [dbQuery] SQLite is not available');
}
logger.debug("[ElectronPlatformService] [dbQuery] SQLite is available");
// Create database connection
await window.electron.ipcRenderer.invoke('sqlite-create-connection', {
database: this.dbName,
version: 1
});
logger.debug("[ElectronPlatformService] [dbQuery] Database connection created");
// Open database
await window.electron.ipcRenderer.invoke('sqlite-open', {
database: this.dbName
});
logger.debug("[ElectronPlatformService] [dbQuery] 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] [dbQuery] Database failed to open');
}
// Execute query
const result = await window.electron.ipcRenderer.invoke('sqlite-query', {
database: this.dbName,
statement: sql,
values: params
}) as SQLiteQueryResult;
logger.debug("[ElectronPlatformService] [dbQuery] Query executed successfully");
// 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)
};
return processedResult;
} catch (error) {
logger.error("[ElectronPlatformService] [dbQuery] Query failed:", error);
throw error;
} finally {
// Ensure proper cleanup
try {
// Close database
await window.electron.ipcRenderer.invoke('sqlite-close', {
database: this.dbName
});
logger.debug("[ElectronPlatformService] [dbQuery] Database closed");
// Close connection
await window.electron.ipcRenderer.invoke('sqlite-close-connection', {
database: this.dbName
});
logger.debug("[ElectronPlatformService] [dbQuery] Database connection closed");
} catch (closeError) {
logger.error("[ElectronPlatformService] [dbQuery] Failed to cleanup database:", closeError);
// Don't throw here - we want to preserve the original error if any
}
}
*/
}
/**
* @see PlatformService.dbExec
*/
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
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");
}
const result = await this.sqlite.run({
database: this.dbName,
statement: sql,
values: params,
});
return {
changes: result.changes?.changes || 0,
lastId: result.changes?.lastId,
};
}
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
}
}