add more checks and handlers for the insidious startup DB error, bump version and add "-beta"
This commit is contained in:
Generated
+2
-2
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user