add more checks and handlers for the insidious startup DB error, bump version and add "-beta"

This commit is contained in:
2026-10-10 19:21:45 -06:00
parent d7f915d7ad
commit d6ccf0e4ae
12 changed files with 603 additions and 85 deletions
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "giftopia",
"version": "1.4.7",
"version": "1.4.8-beta",
"description": "Giftopia App",
"author": {
"name": "Gift Economies Team"
+17 -5
View File
@@ -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<T>(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<T>(
): Promise<void> {
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");
+10
View File
@@ -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.",
+21 -20
View File
@@ -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<RouteRecordRaw> = [
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<RouteRecordRaw> = [
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
+17
View File
@@ -209,6 +209,23 @@ export interface PlatformService {
*/
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | 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<void>;
/**
* 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<string | undefined>;
// --- PWA/Web-only methods (optional, only implemented on web) ---
/**
* Registers the service worker for PWA support (web only)
@@ -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<void> | null = null;
private operationQueue: Array<QueuedOperation> = [];
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<string> {
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<boolean> {
try {
await Filesystem.stat({ path: fileUri });
return true;
} catch {
return false;
}
}
async shareDatabaseFile(): Promise<void> {
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<string | undefined> {
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
+4 -2
View File
@@ -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)}`,
);
}
}
+143
View File
@@ -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<SqlJsDatabase> {
const SQL = await initSqlJs();
const db = new SQL.Database();
db.run("PRAGMA foreign_keys = ON");
return db;
}
async function migrate(db: SqlJsDatabase): Promise<void> {
const sqlExec = async (sql: string, params?: unknown[]): Promise<void> => {
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<QueryResult> => db.exec(sql, params);
const extractMigrationNames = (result: QueryResult): Set<string> =>
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([]);
});
});
+4 -2
View File
@@ -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");
},
+52
View File
@@ -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;
}
}
+196 -51
View File
@@ -6,53 +6,121 @@
<!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight">
Generate an Identity
{{ startupError ? "Time Safari Cannot Start" : "Generate an Identity" }}
</h1>
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Back and Help lead nowhere while the database is down -->
<template v-if="!startupError">
<!-- Back -->
<a
class="order-first text-lg text-center leading-none p-1"
@click="goBack()"
>
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
</a>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
<!-- Help button -->
<router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
>
<font-awesome icon="question" class="block text-center w-[1em]" />
</router-link>
</template>
</div>
<!-- Startup error banner -->
<div
v-if="startupError"
class="max-w-3xl mx-auto mb-6 p-4 bg-red-50 border border-red-300 rounded-lg"
>
<h2 class="text-red-800 font-semibold text-lg mb-2">Startup Error</h2>
<p class="text-red-700 mb-3">
The app encountered a critical error during startup. This is often
caused by a database problem. Please send the details below to
<a :href="`mailto:${SUPPORT_EMAIL}`" class="underline font-semibold">{{
<!-- Startup error: recovery options instead of identity creation -->
<div v-if="startupError" class="max-w-3xl mx-auto">
<div class="p-4 bg-red-50 border border-red-300 rounded-lg">
<p class="text-red-800">
The app could not open its data on this device, so nothing else will
work until this is fixed. Reinstalling usually does not help, because
the phone restores the same data from its backup.
</p>
<details class="mt-3 bg-white border border-red-200 rounded p-3">
<summary class="cursor-pointer text-red-700 font-medium">
Error details
</summary>
<pre
class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words"
>{{ errorReport }}</pre
>
</details>
</div>
<h2 class="font-semibold text-lg mt-6">1. Try again</h2>
<p class="mt-1">Occasionally the problem is temporary.</p>
<a :class="secondaryButtonClass + ' mt-2'" @click="tryAgain()">
Try Again
</a>
<h2 class="font-semibold text-lg mt-6">2. Tell us</h2>
<p class="mt-1">
Send the error details to
<a :href="supportMailto" class="underline font-semibold">{{
SUPPORT_EMAIL
}}</a>
so we can help resolve it.
so we can fix it for everyone. If the email is cut short, copy the
details and paste them in.
</p>
<details class="bg-white border border-red-200 rounded p-3">
<summary class="cursor-pointer text-red-700 font-medium">
Error details
</summary>
<pre
class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words"
>{{ startupError }}</pre
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<a :href="supportMailto" :class="secondaryButtonClass">Email Details</a>
<a :class="secondaryButtonClass" @click="copyErrorReport()">
{{ copied ? "Copied" : "Copy Details" }}
</a>
</div>
<template v-if="canRecoverFiles">
<h2 class="font-semibold text-lg mt-6">3. Keep a copy of your data</h2>
<p class="mt-1">
Saves the database file somewhere outside the app, such as your files
or a drive. It contains your identity keys, so only send it to someone
you trust.
</p>
<a :class="secondaryButtonClass + ' mt-2'" @click="saveDatabaseCopy()">
Save a Copy of My Data
</a>
<h2 class="font-semibold text-lg mt-6">4. Start over</h2>
<p class="mt-1">
Sets the old data aside (a copy stays inside the app) and starts fresh
with a new identity. Your previous identity, contacts, and settings
will not be in the app afterward; you can import that identity again
later only if you have its seed phrase.
</p>
<a
v-if="!confirmingReset"
:class="dangerButtonClass + ' mt-2'"
@click="confirmingReset = true"
>
</details>
Start Over
</a>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
<a :class="dangerButtonClass" @click="startOver()">
Yes, Set My Data Aside
</a>
<a :class="secondaryButtonClass" @click="confirmingReset = false">
Cancel
</a>
</div>
</template>
<template v-else>
<h2 class="font-semibold text-lg mt-6">3. Start over</h2>
<p class="mt-1">
In your browser settings, clear the stored data for this site, then
reload. This removes your identity from this browser, so do it only if
you have your seed phrase or do not need that identity.
</p>
</template>
<p v-if="recoveryMessage" class="mt-4 text-green-800">
{{ recoveryMessage }}
</p>
<p v-if="recoveryError" class="mt-4 text-red-700">{{ recoveryError }}</p>
</div>
<!-- id used by puppeteer test script -->
<div id="start-question">
<div v-else id="start-question">
<div class="max-w-3xl mx-auto">
<p class="text-center text-xl font-light">
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