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",
|
"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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
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");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user