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", "name": "giftopia",
"version": "1.4.7", "version": "1.4.8-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "giftopia", "name": "giftopia",
"version": "1.4.7", "version": "1.4.8-beta",
"dependencies": { "dependencies": {
"@capacitor-community/electron": "^5.0.1", "@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "^7.0.3", "@capacitor-community/sqlite": "^7.0.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "giftopia", "name": "giftopia",
"version": "1.4.7", "version": "1.4.8-beta",
"description": "Giftopia App", "description": "Giftopia App",
"author": { "author": {
"name": "Gift Economies Team" "name": "Gift Economies Team"
+14 -2
View File
@@ -245,8 +245,11 @@ const MIGRATIONS = [
registered BOOLEAN, registered BOOLEAN,
hideTheirContent BOOLEAN DEFAULT 0 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) 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; DROP TABLE contacts;
ALTER TABLE _contacts_backup_008 RENAME TO 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_label ON contact_labels(label);
CREATE INDEX idx_contact_labels_did ON contact_labels(did); 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; DROP TABLE _contact_labels_backup_008;
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
@@ -320,6 +325,8 @@ function extractSingleValue<T>(result: T): string | number | null {
return null; return null;
} }
let migrationsRegistered = false;
/** /**
* @param sqlExec - A function that executes a SQL statement and returns the result * @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" * @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
@@ -331,10 +338,15 @@ export async function runMigrations<T>(
): Promise<void> { ): Promise<void> {
logger.debug("[Migration] Starting database migrations"); logger.debug("[Migration] Starting database migrations");
// Register once: a retry after a failed initialization calls this again,
// and the registry rejects duplicate names.
if (!migrationsRegistered) {
for (const migration of MIGRATIONS) { for (const migration of MIGRATIONS) {
logger.debug("[Migration] Registering migration:", migration.name); logger.debug("[Migration] Registering migration:", migration.name);
registerMigration(migration); registerMigration(migration);
} }
migrationsRegistered = true;
}
logger.debug("[Migration] Running migration service"); logger.debug("[Migration] Running migration service");
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames); await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
+10
View File
@@ -9,6 +9,10 @@ import "./assets/styles/tailwind.css";
import { FontAwesomeIcon } from "./libs/fontawesome"; import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera"; import Camera from "simple-vue-camera";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import {
isDatabaseStartupError,
recordStartupError,
} from "./utils/startupError";
// Global Error Handler // Global Error Handler
function setupGlobalErrorHandler(app: VueApp) { function setupGlobalErrorHandler(app: VueApp) {
@@ -22,6 +26,12 @@ function setupGlobalErrorHandler(app: VueApp) {
info, info,
component: instance?.$options.name || "unknown", 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( alert(
(err instanceof Error ? err.message : "Something bad happened") + (err instanceof Error ? err.message : "Something bad happened") +
" - Try reloading or restarting the app.", " - Try reloading or restarting the app.",
+21 -20
View File
@@ -6,6 +6,10 @@ import {
RouteRecordRaw, RouteRecordRaw,
} from "vue-router"; } from "vue-router";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import {
isDatabaseStartupError,
recordStartupError,
} from "../utils/startupError";
import { import {
retrieveAccountDids, retrieveAccountDids,
generateSaveAndActivateIdentity, generateSaveAndActivateIdentity,
@@ -91,6 +95,15 @@ const routes: Array<RouteRecordRaw> = [
requiresAuth: false, requiresAuth: false,
}, },
}, },
{
path: "/dev/notifications",
name: "dev-notifications",
component: () => import("../views/dev/NotificationDebugView.vue"),
meta: {
title: "Notification Debug",
requiresAuth: false,
},
},
{ {
path: "/did/:did?", path: "/did/:did?",
name: "did", name: "did",
@@ -290,15 +303,6 @@ const routes: Array<RouteRecordRaw> = [
name: "user-profile", name: "user-profile",
component: () => import("../views/UserProfileView.vue"), 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 // Catch-all route for 404 errors - must be last
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
@@ -337,6 +341,12 @@ const errorHandler = (
) => { ) => {
// Handle the error here // Handle the error here
logger.error("Caught in top level error handler:", error, to, from); 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."); 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 // 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(), timestamp: new Date().toISOString(),
}); });
// Store error details so StartView can display them // Store error details so StartView can display them with recovery options
const errorMessage = error instanceof Error ? error.message : String(error); recordStartupError(error);
const errorStack = error instanceof Error ? error.stack || "" : "";
try {
sessionStorage.setItem(
"startupError",
JSON.stringify({ message: errorMessage, stack: errorStack }),
);
} catch {
// sessionStorage may be unavailable
}
// Keep diagnostic pages reachable when identity creation fails (e.g. a // Keep diagnostic pages reachable when identity creation fails (e.g. a
// failed migration leaves every DB call throwing), so the user can open // 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>; 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) --- // --- PWA/Web-only methods (optional, only implemented on web) ---
/** /**
* Registers the service worker for PWA support (web only) * Registers the service worker for PWA support (web only)
@@ -21,9 +21,17 @@ import {
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger, safeStringify } from "../../utils/logger";
import { BaseDatabaseService } from "./BaseDatabaseService"; 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 { interface QueuedOperation {
type: "run" | "query" | "rawQuery"; type: "run" | "query" | "rawQuery";
sql: string; sql: string;
@@ -54,6 +62,8 @@ export class CapacitorPlatformService
private initializationPromise: Promise<void> | null = null; private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = []; private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false; private isProcessingQueue: boolean = false;
/** Set by resetDatabase so a stray query cannot recreate the file mid-reset. */
private resetInProgress = false;
constructor() { constructor() {
super(); super();
@@ -71,6 +81,12 @@ export class CapacitorPlatformService
return this.initializationPromise; return this.initializationPromise;
} }
if (this.resetInProgress) {
throw new Error(
"[CapacitorPlatformService] The database is being reset; restart the app.",
);
}
try { try {
// Start initialization // Start initialization
this.initializationPromise = this._initialize(); this.initializationPromise = this._initialize();
@@ -90,6 +106,8 @@ export class CapacitorPlatformService
return; return;
} }
// Which step failed is the first thing support needs to know.
let step = "create connection";
try { try {
// Try to create/Open database connection // Try to create/Open database connection
try { try {
@@ -164,6 +182,7 @@ export class CapacitorPlatformService
} }
// Open the connection if it's not already open // Open the connection if it's not already open
step = "open database";
try { try {
await this.db.open(); await this.db.open();
} catch (openError: unknown) { } catch (openError: unknown) {
@@ -182,6 +201,7 @@ export class CapacitorPlatformService
// await this.db.execute("PRAGMA journal_mode=WAL;"); // await this.db.execute("PRAGMA journal_mode=WAL;");
// Run migrations // Run migrations
step = "run migrations";
await this.runCapacitorMigrations(); await this.runCapacitorMigrations();
this.initialized = true; this.initialized = true;
@@ -196,8 +216,9 @@ export class CapacitorPlatformService
"[CapacitorPlatformService] Error initializing SQLite database:", "[CapacitorPlatformService] Error initializing SQLite database:",
error, error,
); );
// Keep the underlying cause in the message: it is all a user can send us.
throw new Error( 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 * Detect if an object is a Proxy object that cannot be serialized
* Proxy objects cause "An object could not be cloned" errors in Capacitor * Proxy objects cause "An object could not be cloned" errors in Capacitor
+4 -2
View File
@@ -3,7 +3,7 @@ import {
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger, safeStringify } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database"; import { QueryExecResult } from "@/interfaces/database";
import { BaseDatabaseService } from "./BaseDatabaseService"; import { BaseDatabaseService } from "./BaseDatabaseService";
// Dynamic import of initBackend to prevent worker context errors // Dynamic import of initBackend to prevent worker context errors
@@ -123,7 +123,9 @@ export class WebPlatformService
logger.error("[WebPlatformService] Failed to initialize worker:", error); logger.error("[WebPlatformService] Failed to initialize worker:", error);
this.workerReady = false; this.workerReady = false;
this.workerInitPromise = null; 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); console.warn(message, ...args);
} }
// Database logging // Database logging, plus memory so problems can be shown even when the database is unusable
const argsString = const argsString =
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : ""; args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
appendToMemoryLogs(`[WARN] ${message}${argsString}`);
logToDatabase(message + argsString, "warn"); logToDatabase(message + argsString, "warn");
}, },
@@ -231,9 +232,10 @@ export const logger = {
console.error(message, ...args); console.error(message, ...args);
} }
// Database logging // Database logging, plus memory so problems can be shown even when the database is unusable
const argsString = const argsString =
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : ""; args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
appendToMemoryLogs(`[ERROR] ${message}${argsString}`);
logToDatabase(message + argsString, "error"); 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;
}
}
+178 -33
View File
@@ -6,9 +6,11 @@
<!-- Sub View Heading --> <!-- Sub View Heading -->
<div id="SubViewHeading" class="flex gap-4 items-start mb-8"> <div id="SubViewHeading" class="flex gap-4 items-start mb-8">
<h1 class="grow text-xl text-center font-semibold leading-tight"> <h1 class="grow text-xl text-center font-semibold leading-tight">
Generate an Identity {{ startupError ? "Time Safari Cannot Start" : "Generate an Identity" }}
</h1> </h1>
<!-- Back and Help lead nowhere while the database is down -->
<template v-if="!startupError">
<!-- Back --> <!-- Back -->
<a <a
class="order-first text-lg text-center leading-none p-1" class="order-first text-lg text-center leading-none p-1"
@@ -24,35 +26,101 @@
> >
<font-awesome icon="question" class="block text-center w-[1em]" /> <font-awesome icon="question" class="block text-center w-[1em]" />
</router-link> </router-link>
</template>
</div> </div>
<!-- Startup error banner --> <!-- Startup error: recovery options instead of identity creation -->
<div <div v-if="startupError" class="max-w-3xl mx-auto">
v-if="startupError" <div class="p-4 bg-red-50 border border-red-300 rounded-lg">
class="max-w-3xl mx-auto mb-6 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
<h2 class="text-red-800 font-semibold text-lg mb-2">Startup Error</h2> work until this is fixed. Reinstalling usually does not help, because
<p class="text-red-700 mb-3"> the phone restores the same data from its backup.
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">{{
SUPPORT_EMAIL
}}</a>
so we can help resolve it.
</p> </p>
<details class="bg-white border border-red-200 rounded p-3"> <details class="mt-3 bg-white border border-red-200 rounded p-3">
<summary class="cursor-pointer text-red-700 font-medium"> <summary class="cursor-pointer text-red-700 font-medium">
Error details Error details
</summary> </summary>
<pre <pre
class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words" class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words"
>{{ startupError }}</pre >{{ errorReport }}</pre
> >
</details> </details>
</div> </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 fix it for everyone. If the email is cut short, copy the
details and paste them in.
</p>
<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"
>
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 --> <!-- id used by puppeteer test script -->
<div id="start-question"> <div v-else id="start-question">
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<p class="text-center text-xl font-light"> <p class="text-center text-xl font-light">
How do you want to create this identifier? How do you want to create this identifier?
@@ -161,9 +229,13 @@
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import * as Package from "../../package.json";
import { AppString, PASSKEYS_ENABLED, SUPPORT_EMAIL } from "../constants/app"; 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 { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { logger } from "../utils/logger"; import { getMemoryLogs, logger } from "../utils/logger";
import { takeStartupError } from "../utils/startupError";
import { import {
registerSaveAndActivatePasskey, registerSaveAndActivatePasskey,
@@ -185,6 +257,11 @@ export default class StartView extends Vue {
givenName = ""; givenName = "";
numAccounts = 0; numAccounts = 0;
startupError = ""; startupError = "";
copied = false;
confirmingReset = false;
recoveryMessage = "";
recoveryError = "";
canRecoverFiles = false;
/** /**
* Computed property for primary action button styling * 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"; 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 * Computed property for migration button styling
* Provides consistent classes for database migration button * 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 * identity creation options based on user preferences and existing accounts
*/ */
async mounted() { async mounted() {
try { const startupError = takeStartupError();
const raw = sessionStorage.getItem("startupError"); if (startupError) {
if (raw) { this.startupError = [startupError.message, startupError.stack]
sessionStorage.removeItem("startupError"); .filter(Boolean)
try { .join("\n\n");
const parsed = JSON.parse(raw); const platformService = PlatformServiceFactory.getInstance();
const parts = [parsed.message, parsed.stack].filter(Boolean); this.canRecoverFiles =
this.startupError = parts.length > 0 ? parts.join("\n\n") : raw; typeof platformService.resetDatabase === "function" &&
logger.error("[StartView] Displaying startup error to user", parsed); typeof platformService.shareDatabaseFile === "function" &&
} catch { platformService.isCapacitor();
this.startupError = raw; logger.error(
} "[StartView] Displaying startup error to user",
} startupError,
} catch { );
// sessionStorage or JSON parse may fail; non-critical // Loading settings would only fail again against the same database.
return;
} }
try { 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 * Handle new seed identity generation selection
* Routes user to new identifier creation flow with seed-based approach * Routes user to new identifier creation flow with seed-based approach