From d6ccf0e4ae2439f0149ff6883e1a1fa8fa9e9ce1 Mon Sep 17 00:00:00 2001 From: Trent Larson Date: Sat, 10 Oct 2026 19:21:45 -0600 Subject: [PATCH] add more checks and handlers for the insidious startup DB error, bump version and add "-beta" --- package-lock.json | 4 +- package.json | 2 +- src/db-sql/migration.ts | 22 +- src/main.common.ts | 10 + src/router/index.ts | 41 +-- src/services/PlatformService.ts | 17 ++ .../platforms/CapacitorPlatformService.ts | 138 +++++++++- src/services/platforms/WebPlatformService.ts | 6 +- src/test/migrations.test.ts | 143 ++++++++++ src/utils/logger.ts | 6 +- src/utils/startupError.ts | 52 ++++ src/views/StartView.vue | 247 ++++++++++++++---- 12 files changed, 603 insertions(+), 85 deletions(-) create mode 100644 src/test/migrations.test.ts create mode 100644 src/utils/startupError.ts diff --git a/package-lock.json b/package-lock.json index fe8bc2a4..d2958241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "giftopia", - "version": "1.4.7", + "version": "1.4.8-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "giftopia", - "version": "1.4.7", + "version": "1.4.8-beta", "dependencies": { "@capacitor-community/electron": "^5.0.1", "@capacitor-community/sqlite": "^7.0.3", diff --git a/package.json b/package.json index 8bc63fd6..198374e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "giftopia", - "version": "1.4.7", + "version": "1.4.8-beta", "description": "Giftopia App", "author": { "name": "Gift Economies Team" diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 35c0969f..b4649e32 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -245,8 +245,11 @@ const MIGRATIONS = [ registered BOOLEAN, hideTheirContent BOOLEAN DEFAULT 0 ); + -- Older versions could save the same DID more than once (nothing was unique on contacts.did), + -- which made the unique index below fail on every startup. Keep only the newest row per DID. INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent) - SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts; + SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts + WHERE id IN (SELECT MAX(id) FROM contacts GROUP BY did); DROP TABLE contacts; ALTER TABLE _contacts_backup_008 RENAME TO contacts; @@ -263,7 +266,9 @@ const MIGRATIONS = [ ); CREATE INDEX idx_contact_labels_label ON contact_labels(label); CREATE INDEX idx_contact_labels_did ON contact_labels(did); - INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008; + -- The PRAGMA above is a no-op inside the migration's transaction, so foreign keys are enforced here: + -- skip any label whose contact no longer exists. + INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008 WHERE did IN (SELECT did FROM contacts); DROP TABLE _contact_labels_backup_008; PRAGMA foreign_keys = ON; @@ -320,6 +325,8 @@ function extractSingleValue(result: T): string | number | null { return null; } +let migrationsRegistered = false; + /** * @param sqlExec - A function that executes a SQL statement and returns the result * @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations" @@ -331,9 +338,14 @@ export async function runMigrations( ): Promise { logger.debug("[Migration] Starting database migrations"); - for (const migration of MIGRATIONS) { - logger.debug("[Migration] Registering migration:", migration.name); - registerMigration(migration); + // Register once: a retry after a failed initialization calls this again, + // and the registry rejects duplicate names. + if (!migrationsRegistered) { + for (const migration of MIGRATIONS) { + logger.debug("[Migration] Registering migration:", migration.name); + registerMigration(migration); + } + migrationsRegistered = true; } logger.debug("[Migration] Running migration service"); diff --git a/src/main.common.ts b/src/main.common.ts index e792ec6a..bd906a60 100644 --- a/src/main.common.ts +++ b/src/main.common.ts @@ -9,6 +9,10 @@ import "./assets/styles/tailwind.css"; import { FontAwesomeIcon } from "./libs/fontawesome"; import Camera from "simple-vue-camera"; import { logger } from "./utils/logger"; +import { + isDatabaseStartupError, + recordStartupError, +} from "./utils/startupError"; // Global Error Handler function setupGlobalErrorHandler(app: VueApp) { @@ -22,6 +26,12 @@ function setupGlobalErrorHandler(app: VueApp) { info, component: instance?.$options.name || "unknown", }); + if (isDatabaseStartupError(err)) { + // Nothing works without the database: show the recovery options instead. + recordStartupError(err); + router.replace("/start"); + return; + } alert( (err instanceof Error ? err.message : "Something bad happened") + " - Try reloading or restarting the app.", diff --git a/src/router/index.ts b/src/router/index.ts index 167056d9..806eb656 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -6,6 +6,10 @@ import { RouteRecordRaw, } from "vue-router"; import { logger } from "../utils/logger"; +import { + isDatabaseStartupError, + recordStartupError, +} from "../utils/startupError"; import { retrieveAccountDids, generateSaveAndActivateIdentity, @@ -91,6 +95,15 @@ const routes: Array = [ requiresAuth: false, }, }, + { + path: "/dev/notifications", + name: "dev-notifications", + component: () => import("../views/dev/NotificationDebugView.vue"), + meta: { + title: "Notification Debug", + requiresAuth: false, + }, + }, { path: "/did/:did?", name: "did", @@ -290,15 +303,6 @@ const routes: Array = [ name: "user-profile", component: () => import("../views/UserProfileView.vue"), }, - { - path: "/dev/notifications", - name: "dev-notifications", - component: () => import("../views/dev/NotificationDebugView.vue"), - meta: { - title: "Notification Debug", - requiresAuth: false, - }, - }, // Catch-all route for 404 errors - must be last { path: "/:pathMatch(.*)*", @@ -337,6 +341,12 @@ const errorHandler = ( ) => { // Handle the error here logger.error("Caught in top level error handler:", error, to, from); + if (isDatabaseStartupError(error)) { + // Nothing works without the database: show the recovery options instead. + recordStartupError(error); + router.replace("/start"); + return; + } alert("Something is very wrong. Try reloading or restarting the app."); // You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page @@ -421,17 +431,8 @@ router.beforeEach(async (to, _from, next) => { timestamp: new Date().toISOString(), }); - // Store error details so StartView can display them - const errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack || "" : ""; - try { - sessionStorage.setItem( - "startupError", - JSON.stringify({ message: errorMessage, stack: errorStack }), - ); - } catch { - // sessionStorage may be unavailable - } + // Store error details so StartView can display them with recovery options + recordStartupError(error); // Keep diagnostic pages reachable when identity creation fails (e.g. a // failed migration leaves every DB call throwing), so the user can open diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index a8ae9ee7..cd10fedc 100644 --- a/src/services/PlatformService.ts +++ b/src/services/PlatformService.ts @@ -209,6 +209,23 @@ export interface PlatformService { */ retrieveSettingsForActiveAccount(): Promise | null>; + // --- Startup-failure recovery (optional, only implemented on native mobile) --- + /** + * Opens the share sheet with a copy of the raw database file, so a user + * whose database cannot be opened can still save it or send it to support. + * It contains their keys, so the UI must say so. + */ + shareDatabaseFile?(): Promise; + + /** + * Copies the database file aside (into the app's files directory) and then + * deletes it, so the next start builds a fresh database. The caller is + * expected to reload the app afterward. + * @returns the backup file's URI, or undefined when there was no file to back up + * @throws if the backup copy fails (the original is never deleted without one) + */ + resetDatabase?(): Promise; + // --- PWA/Web-only methods (optional, only implemented on web) --- /** * Registers the service worker for PWA support (web only) diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index d54f43b7..ab0cb56d 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/src/services/platforms/CapacitorPlatformService.ts @@ -21,9 +21,17 @@ import { PlatformService, PlatformCapabilities, } from "../PlatformService"; -import { logger } from "../../utils/logger"; +import { logger, safeStringify } from "../../utils/logger"; import { BaseDatabaseService } from "./BaseDatabaseService"; +/** Readable one-line description of anything thrown, including plugin rejections. */ +function describeError(error: unknown): string { + if (error instanceof Error) { + return String(error); + } + return typeof error === "string" ? error : safeStringify(error); +} + interface QueuedOperation { type: "run" | "query" | "rawQuery"; sql: string; @@ -54,6 +62,8 @@ export class CapacitorPlatformService private initializationPromise: Promise | null = null; private operationQueue: Array = []; private isProcessingQueue: boolean = false; + /** Set by resetDatabase so a stray query cannot recreate the file mid-reset. */ + private resetInProgress = false; constructor() { super(); @@ -71,6 +81,12 @@ export class CapacitorPlatformService return this.initializationPromise; } + if (this.resetInProgress) { + throw new Error( + "[CapacitorPlatformService] The database is being reset; restart the app.", + ); + } + try { // Start initialization this.initializationPromise = this._initialize(); @@ -90,6 +106,8 @@ export class CapacitorPlatformService return; } + // Which step failed is the first thing support needs to know. + let step = "create connection"; try { // Try to create/Open database connection try { @@ -164,6 +182,7 @@ export class CapacitorPlatformService } // Open the connection if it's not already open + step = "open database"; try { await this.db.open(); } catch (openError: unknown) { @@ -182,6 +201,7 @@ export class CapacitorPlatformService // await this.db.execute("PRAGMA journal_mode=WAL;"); // Run migrations + step = "run migrations"; await this.runCapacitorMigrations(); this.initialized = true; @@ -196,8 +216,9 @@ export class CapacitorPlatformService "[CapacitorPlatformService] Error initializing SQLite database:", error, ); + // Keep the underlying cause in the message: it is all a user can send us. throw new Error( - "[CapacitorPlatformService] Failed to initialize database", + `[CapacitorPlatformService] Failed to initialize database (step: ${step}): ${describeError(error)}`, ); } } @@ -420,6 +441,119 @@ export class CapacitorPlatformService } } + /** File name the SQLite plugin stores this.dbName under (it appends "SQLite.db"). */ + private get databaseFileName(): string { + return `${this.dbName}SQLite.db`; + } + + /** URI of the directory where the SQLite plugin keeps its database files. */ + private async databaseDirectoryUri(): Promise { + const platform = Capacitor.getPlatform(); + if (platform === "android") { + // The plugin uses Context.getDatabasePath(), the "databases" sibling of + // getFilesDir() (which is Directory.Data). + const { uri } = await Filesystem.getUri({ + path: "", + directory: Directory.Data, + }); + if (!/\/files\/?$/.test(uri)) { + throw new Error(`Unexpected app data directory: ${uri}`); + } + return uri.replace(/\/files\/?$/, "/databases"); + } + if (platform === "ios") { + // Matches iosDatabaseLocation "Library/CapacitorDatabase" in capacitor.config + const { uri } = await Filesystem.getUri({ + path: "CapacitorDatabase", + directory: Directory.Library, + }); + return uri.replace(/\/$/, ""); + } + throw new Error(`Database file access is not supported on ${platform}.`); + } + + private async databaseFileExists(fileUri: string): Promise { + try { + await Filesystem.stat({ path: fileUri }); + return true; + } catch { + return false; + } + } + + async shareDatabaseFile(): Promise { + const source = `${await this.databaseDirectoryUri()}/${this.databaseFileName}`; + if (!(await this.databaseFileExists(source))) { + throw new Error("There is no database file on this device."); + } + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const { uri } = await Filesystem.copy({ + from: source, + to: `timesafari-database-${stamp}.sqlite`, + toDirectory: Directory.Cache, + }); + await Share.share({ + title: "Time Safari database", + url: uri, + dialogTitle: "Save or send your database file", + }); + } + + async resetDatabase(): Promise { + this.resetInProgress = true; + try { + try { + await this.sqlite.closeConnection(this.dbName, false); + } catch (closeError) { + // Expected when the connection was never created + logger.debug( + "[CapacitorPlatformService] Close before reset failed (may be expected):", + closeError, + ); + } + this.db = null; + this.initialized = false; + this.initializationPromise = null; + + const source = `${await this.databaseDirectoryUri()}/${this.databaseFileName}`; + let backupUri: string | undefined; + if (await this.databaseFileExists(source)) { + // Never delete without a copy: this file may hold the only copy of the user's keys. + const backupDir = "database-backups"; + try { + await Filesystem.mkdir({ + path: backupDir, + directory: Directory.Data, + recursive: true, + }); + } catch { + // Already exists + } + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const { uri } = await Filesystem.copy({ + from: source, + to: `${backupDir}/${this.databaseFileName}-${stamp}`, + toDirectory: Directory.Data, + }); + backupUri = uri; + await Filesystem.deleteFile({ path: source }); + } + for (const suffix of ["-journal", "-wal", "-shm"]) { + if (await this.databaseFileExists(source + suffix)) { + await Filesystem.deleteFile({ path: source + suffix }); + } + } + logger.warn( + `[CapacitorPlatformService] Database reset; backup at ${backupUri ?? "(no file existed)"}`, + ); + // resetInProgress stays set: the caller reloads the app. + return backupUri; + } catch (error) { + this.resetInProgress = false; + throw new Error(`Could not reset the database: ${describeError(error)}`); + } + } + /** * Detect if an object is a Proxy object that cannot be serialized * Proxy objects cause "An object could not be cloned" errors in Capacitor diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index da573837..b53ce4d9 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/src/services/platforms/WebPlatformService.ts @@ -3,7 +3,7 @@ import { PlatformService, PlatformCapabilities, } from "../PlatformService"; -import { logger } from "../../utils/logger"; +import { logger, safeStringify } from "../../utils/logger"; import { QueryExecResult } from "@/interfaces/database"; import { BaseDatabaseService } from "./BaseDatabaseService"; // Dynamic import of initBackend to prevent worker context errors @@ -123,7 +123,9 @@ export class WebPlatformService logger.error("[WebPlatformService] Failed to initialize worker:", error); this.workerReady = false; this.workerInitPromise = null; - throw new Error("Failed to initialize database worker"); + throw new Error( + `Failed to initialize database worker: ${error instanceof Error ? String(error) : safeStringify(error)}`, + ); } } diff --git a/src/test/migrations.test.ts b/src/test/migrations.test.ts new file mode 100644 index 00000000..736a9bd5 --- /dev/null +++ b/src/test/migrations.test.ts @@ -0,0 +1,143 @@ +/** + * Runs the real migrations against sql.js, executing multi-statement SQL the + * way CapacitorPlatformService does: "--" comments stripped, split on ";", + * and run in one transaction with foreign keys enforced. + */ + +jest.mock("@/utils/logger", () => ({ + logger: { + debug: jest.fn(), + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock("@/constants/app", () => ({ + DEFAULT_ENDORSER_API_SERVER: "https://api.example", +})); +jest.mock("@/libs/crypto", () => ({ + arrayBufferToBase64: (buffer: ArrayBuffer) => + Buffer.from(buffer).toString("base64"), +})); + +import { runMigrations } from "@/db-sql/migration"; + +// sql.js has no type declarations installed here +// eslint-disable-next-line @typescript-eslint/no-var-requires +const initSqlJs = require("sql.js"); + +type QueryResult = Array<{ columns: string[]; values: unknown[][] }>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SqlJsDatabase = any; + +const MIGRATION_008 = "008_remove_iViewContent_from_contacts"; + +async function newDatabase(): Promise { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run("PRAGMA foreign_keys = ON"); + return db; +} + +async function migrate(db: SqlJsDatabase): Promise { + const sqlExec = async (sql: string, params?: unknown[]): Promise => { + if (params && params.length > 0) { + db.run(sql, params); + return; + } + const statements = sql + .replace(/--[^\n]*/g, "") + .split(";") + .map((s) => s.trim()) + .filter((s) => s); + if (statements.length > 1) { + // Like the plugin's executeSet: all or nothing + db.run("BEGIN"); + try { + statements.forEach((statement) => db.run(statement)); + db.run("COMMIT"); + } catch (error) { + db.run("ROLLBACK"); + throw error; + } + } else { + db.run(sql); + } + }; + const sqlQuery = async ( + sql: string, + params?: unknown[], + ): Promise => db.exec(sql, params); + const extractMigrationNames = (result: QueryResult): Set => + new Set((result[0]?.values || []).map((row) => row[0] as string)); + await runMigrations(sqlExec, sqlQuery, extractMigrationNames); +} + +function rows(db: SqlJsDatabase, sql: string): unknown[][] { + return (db.exec(sql) as QueryResult)[0]?.values || []; +} + +/** + * Puts a fully migrated database back into the pre-008 shape that older app + * versions left behind: no unique index on contacts.did, so duplicates and + * dangling labels can exist. + */ +function revertTo007(db: SqlJsDatabase): void { + db.run("PRAGMA foreign_keys = OFF"); + db.run("DROP INDEX idx_contacts_did"); + db.run("CREATE INDEX idx_contacts_did ON contacts(did)"); + db.run(`DELETE FROM migrations WHERE name = '${MIGRATION_008}'`); +} + +describe("migrations", () => { + it("migrates a fresh database", async () => { + const db = await newDatabase(); + await migrate(db); + expect( + rows(db, `SELECT name FROM migrations WHERE name = '${MIGRATION_008}'`), + ).toHaveLength(1); + }); + + it("008 keeps the newest contact when a DID was saved twice", async () => { + const db = await newDatabase(); + await migrate(db); + revertTo007(db); + db.run( + "INSERT INTO contacts (did, name) VALUES ('did:a', 'A old'), ('did:b', 'B'), ('did:a', 'A new')", + ); + db.run("INSERT INTO contact_labels VALUES ('did:a', 'friends')"); + db.run("PRAGMA foreign_keys = ON"); + + await migrate(db); + + expect(rows(db, "SELECT did, name FROM contacts ORDER BY did")).toEqual([ + ["did:a", "A new"], + ["did:b", "B"], + ]); + expect(rows(db, "SELECT did, label FROM contact_labels")).toEqual([ + ["did:a", "friends"], + ]); + expect( + rows(db, `SELECT name FROM migrations WHERE name = '${MIGRATION_008}'`), + ).toHaveLength(1); + }); + + it("008 drops labels whose contact no longer exists", async () => { + const db = await newDatabase(); + await migrate(db); + revertTo007(db); + db.run("INSERT INTO contacts (did, name) VALUES ('did:a', 'A')"); + db.run( + "INSERT INTO contact_labels VALUES ('did:a', 'friends'), ('did:gone', 'orphan')", + ); + db.run("PRAGMA foreign_keys = ON"); + + await migrate(db); + + expect(rows(db, "SELECT did, label FROM contact_labels")).toEqual([ + ["did:a", "friends"], + ]); + expect(rows(db, "PRAGMA foreign_key_check")).toEqual([]); + }); +}); diff --git a/src/utils/logger.ts b/src/utils/logger.ts index f0f4977e..3b4c3801 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -219,9 +219,10 @@ export const logger = { console.warn(message, ...args); } - // Database logging + // Database logging, plus memory so problems can be shown even when the database is unusable const argsString = args.length > 0 ? " - " + args.map(safeStringify).join(", ") : ""; + appendToMemoryLogs(`[WARN] ${message}${argsString}`); logToDatabase(message + argsString, "warn"); }, @@ -231,9 +232,10 @@ export const logger = { console.error(message, ...args); } - // Database logging + // Database logging, plus memory so problems can be shown even when the database is unusable const argsString = args.length > 0 ? " - " + args.map(safeStringify).join(", ") : ""; + appendToMemoryLogs(`[ERROR] ${message}${argsString}`); logToDatabase(message + argsString, "error"); }, diff --git a/src/utils/startupError.ts b/src/utils/startupError.ts new file mode 100644 index 00000000..4de36336 --- /dev/null +++ b/src/utils/startupError.ts @@ -0,0 +1,52 @@ +/** + * Hand-off of a fatal startup error (usually a database that will not open) + * from wherever it is caught to StartView, which shows recovery options. + */ + +const STORAGE_KEY = "startupError"; + +export interface StartupError { + message: string; + stack: string; +} + +/** True for the errors thrown when the platform database cannot be opened. */ +export function isDatabaseStartupError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("Failed to initialize database") || + message.includes("The database could not be initialized") || + message.includes("The database is being reset") + ); +} + +export function recordStartupError(error: unknown): void { + const value: StartupError = { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack || "" : "", + }; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value)); + } catch { + // sessionStorage may be unavailable + } +} + +/** Returns the recorded error, if any, and clears it. */ +export function takeStartupError(): StartupError | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + sessionStorage.removeItem(STORAGE_KEY); + try { + const parsed = JSON.parse(raw); + return { message: parsed.message || raw, stack: parsed.stack || "" }; + } catch { + return { message: raw, stack: "" }; + } + } catch { + return null; + } +} diff --git a/src/views/StartView.vue b/src/views/StartView.vue index f012cbb1..595cfd48 100644 --- a/src/views/StartView.vue +++ b/src/views/StartView.vue @@ -6,53 +6,121 @@

- Generate an Identity + {{ startupError ? "Time Safari Cannot Start" : "Generate an Identity" }}

- - - - + +
- -
-

Startup Error

-

- The app encountered a critical error during startup. This is often - caused by a database problem. Please send the details below to - {{ + +

+ + + + +

+ {{ recoveryMessage }} +

+

{{ recoveryError }}

-
+

How do you want to create this identifier? @@ -161,9 +229,13 @@ import { Component, Vue } from "vue-facing-decorator"; import { Router } from "vue-router"; +import * as Package from "../../package.json"; import { AppString, PASSKEYS_ENABLED, SUPPORT_EMAIL } from "../constants/app"; +import { copyToClipboard } from "../services/ClipboardService"; +import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceMixin } from "../utils/PlatformServiceMixin"; -import { logger } from "../utils/logger"; +import { getMemoryLogs, logger } from "../utils/logger"; +import { takeStartupError } from "../utils/startupError"; import { registerSaveAndActivatePasskey, @@ -185,6 +257,11 @@ export default class StartView extends Vue { givenName = ""; numAccounts = 0; startupError = ""; + copied = false; + confirmingReset = false; + recoveryMessage = ""; + recoveryError = ""; + canRecoverFiles = false; /** * Computed property for primary action button styling @@ -202,6 +279,34 @@ export default class StartView extends Vue { return "block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer"; } + get dangerButtonClass() { + return "block w-full text-center text-md uppercase bg-gradient-to-b from-red-500 to-red-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer"; + } + + /** + * Everything support needs: the error, recent warnings and errors (the + * database log table is unreachable in this state), version, and device. + */ + get errorReport(): string { + const recentLogs = getMemoryLogs().slice(-30); + return [ + this.startupError, + `App version: ${Package.version} (${import.meta.env.VITE_GIT_HASH || "unknown commit"})`, + `Device: ${navigator.userAgent}`, + "Recent warnings and errors:\n" + (recentLogs.join("\n") || "(none)"), + ].join("\n\n"); + } + + get supportMailto(): string { + // Mail apps truncate long mailto links; Copy Details carries the full text. + const body = this.errorReport.slice(0, 1800); + return ( + `mailto:${SUPPORT_EMAIL}` + + `?subject=${encodeURIComponent("Time Safari startup error")}` + + `&body=${encodeURIComponent(body)}` + ); + } + /** * Computed property for migration button styling * Provides consistent classes for database migration button @@ -224,21 +329,22 @@ export default class StartView extends Vue { * identity creation options based on user preferences and existing accounts */ async mounted() { - try { - const raw = sessionStorage.getItem("startupError"); - if (raw) { - sessionStorage.removeItem("startupError"); - try { - const parsed = JSON.parse(raw); - const parts = [parsed.message, parsed.stack].filter(Boolean); - this.startupError = parts.length > 0 ? parts.join("\n\n") : raw; - logger.error("[StartView] Displaying startup error to user", parsed); - } catch { - this.startupError = raw; - } - } - } catch { - // sessionStorage or JSON parse may fail; non-critical + const startupError = takeStartupError(); + if (startupError) { + this.startupError = [startupError.message, startupError.stack] + .filter(Boolean) + .join("\n\n"); + const platformService = PlatformServiceFactory.getInstance(); + this.canRecoverFiles = + typeof platformService.resetDatabase === "function" && + typeof platformService.shareDatabaseFile === "function" && + platformService.isCapacitor(); + logger.error( + "[StartView] Displaying startup error to user", + startupError, + ); + // Loading settings would only fail again against the same database. + return; } try { @@ -259,6 +365,45 @@ export default class StartView extends Vue { } } + public tryAgain() { + window.location.reload(); + } + + public async copyErrorReport() { + try { + await copyToClipboard(this.errorReport); + this.copied = true; + } catch (error) { + this.recoveryError = "Could not copy: " + String(error); + } + } + + public async saveDatabaseCopy() { + this.recoveryError = ""; + try { + await PlatformServiceFactory.getInstance().shareDatabaseFile?.(); + } catch (error) { + logger.error("[StartView] Could not share database file", error); + this.recoveryError = "Could not save a copy: " + String(error); + } + } + + public async startOver() { + this.recoveryError = ""; + this.confirmingReset = false; + try { + const backupUri = + await PlatformServiceFactory.getInstance().resetDatabase?.(); + logger.warn("[StartView] User reset the database", { backupUri }); + this.recoveryMessage = "Starting over..."; + // Reload from the root so startup runs against the fresh database. + window.location.replace("/"); + } catch (error) { + logger.error("[StartView] Database reset failed", error); + this.recoveryError = String(error); + } + } + /** * Handle new seed identity generation selection * Routes user to new identifier creation flow with seed-based approach