Compare commits

...

6 Commits

Author SHA1 Message Date
Matthew Raymer 5a6e5289ff fix: update server settings test and initialization 2 months ago
Matthew Raymer 6620311b7d fix: improve feed loading and onboarding dialog handling 2 months ago
Matthew Raymer 6e2bdc69e9 refactor: improve code quality and type safety 2 months ago
Matthew Raymer b8c3517072 feat(ui): optimize EntityIcon component and fix event handling 2 months ago
Matthew Raymer a0cf9ea721 feat(backup): implement platform-specific database backup service 2 months ago
Matthew Raymer 42d706b1fb WIP: Platform-specific service architecture and configuration refactor 2 months ago
  1. 1
      .env.electron
  2. 5
      .env.mobile
  3. 1
      .env.web
  4. 6
      .eslintrc.js
  5. BIN
      android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  6. 4
      android/.gradle/buildOutputCleanup/cache.properties
  7. BIN
      android/.gradle/file-system.probe
  8. 2
      android/app/capacitor.build.gradle
  9. 8
      android/app/src/main/assets/capacitor.plugins.json
  10. 2
      android/app/src/main/assets/public/index.html
  11. 2
      android/build.gradle
  12. 6
      android/capacitor.settings.gradle
  13. 2
      android/gradle/wrapper/gradle-wrapper.properties
  14. 1515
      package-lock.json
  15. 20
      package.json
  16. 10
      playwright.config-local.ts
  17. 8
      playwright.config.ts
  18. 1
      src/components/ActivityListItem.vue
  19. 105
      src/components/EntityIcon.vue
  20. 257
      src/components/ProfileSection.vue
  21. 26
      src/db/index.ts
  22. 3
      src/interfaces/common.ts
  23. 34
      src/interfaces/identifier.ts
  24. 1
      src/interfaces/index.ts
  25. 288
      src/interfaces/service.ts
  26. 4
      src/libs/crypto/vc/index.ts
  27. 2
      src/libs/endorserServer.ts
  28. 67
      src/main.ts
  29. 82
      src/platforms/capacitor/DatabaseBackupService.ts
  30. 361
      src/router/index.ts
  31. 95
      src/services/DatabaseBackupService.ts
  32. 195
      src/services/PlatformServiceFactory.ts
  33. 105
      src/services/ProfileService.ts
  34. 110
      src/services/RateLimitsService.ts
  35. 35
      src/services/platforms/capacitor/DatabaseBackupService.ts
  36. 18
      src/services/platforms/electron/DatabaseBackupService.ts
  37. 20
      src/services/platforms/empty.ts
  38. 33
      src/services/platforms/web/DatabaseBackupService.ts
  39. 64
      src/types/capacitor.d.ts
  40. 7
      src/types/index.ts
  41. 430
      src/types/interfaces.ts
  42. 82
      src/utils/logger.ts
  43. 2261
      src/views/AccountViewView.vue
  44. 47
      src/views/ClaimCertificateView.vue
  45. 114
      src/views/ClaimReportCertificateView.vue
  46. 2
      src/views/ContactGiftingView.vue
  47. 4
      src/views/HelpView.vue
  48. 749
      src/views/HomeView.vue
  49. 2
      src/views/ProjectViewView.vue
  50. 6
      src/views/ProjectsView.vue
  51. 51
      test-playwright/00-noid-tests.spec.ts
  52. 3
      tsconfig.json
  53. 46
      vite.config.base.ts
  54. 12
      vite.config.common.mts
  55. 156
      vite.config.dev.mts.timestamp-1743747195916-245e61245d5ec.mjs
  56. 28
      vite.config.mobile.ts
  57. 60
      vite.config.ts
  58. 113
      vite.config.web.mts

1
.env.electron

@ -0,0 +1 @@
PLATFORM=electron

5
.env.mobile

@ -0,0 +1,5 @@
PLATFORM=capacitor
VITE_ENDORSER_API_URL=https://test-api.endorser.ch/api/v2/claim
VITE_PARTNER_API_URL=https://test-api.partner.ch/api/v2
VITE_IMAGE_API_URL=https://test-api.images.ch/api/v2
VITE_PUSH_SERVER_URL=https://test-api.push.ch/api/v2

1
.env.web

@ -0,0 +1 @@
PLATFORM=web

6
.eslintrc.js

@ -26,6 +26,10 @@ module.exports = {
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off"
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}]
},
};

BIN
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

4
android/.gradle/buildOutputCleanup/cache.properties

@ -1,2 +1,2 @@
#Fri Mar 21 07:27:50 UTC 2025
gradle.version=8.2.1
#Thu Apr 03 10:21:42 UTC 2025
gradle.version=8.11.1

BIN
android/.gradle/file-system.probe

Binary file not shown.

2
android/app/capacitor.build.gradle

@ -10,6 +10,8 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-share')
}

8
android/app/src/main/assets/capacitor.plugins.json

@ -2,5 +2,13 @@
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
},
{
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
}
]

2
android/app/src/main/assets/public/index.html

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<title>TimeSafari</title>
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script>
<script type="module" crossorigin src="/assets/index-KPivi3wg.js"></script>
</head>
<body>
<noscript>

2
android/build.gradle

@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.android.tools.build:gradle:8.9.1'
classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong

6
android/capacitor.settings.gradle

@ -4,3 +4,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')

2
android/gradle/wrapper/gradle-wrapper.properties

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

1515
package-lock.json

File diff suppressed because it is too large

20
package.json

@ -45,10 +45,13 @@
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",
"@capacitor/cli": "^6.2.0",
"@capacitor/core": "^6.2.0",
"@capacitor/core": "^6.2.1",
"@capacitor/filesystem": "^6.0.3",
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@electron/remote": "^2.1.2",
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
@ -98,13 +101,13 @@
"pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
"qrcode.vue": "^3.6.0",
"ramda": "^0.29.1",
"readable-stream": "^4.5.2",
"reflect-metadata": "^0.1.14",
"register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"sqlite3": "^5.1.7",
"stream-browserify": "^3.0.0",
"three": "^0.156.1",
"ua-parser-js": "^1.0.37",
"vue": "^3.5.13",
@ -123,7 +126,7 @@
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/node": "^20.14.11",
"@types/node": "^20.17.30",
"@types/node-fetch": "^2.6.12",
"@types/ramda": "^0.29.11",
"@types/sqlite3": "^3.1.11",
@ -133,8 +136,12 @@
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3",
"assert": "^2.1.0",
"autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0",
"browserify-zlib": "^0.2.0",
"concurrently": "^8.2.2",
"crypto-browserify": "^3.12.1",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"eslint": "^8.57.0",
@ -142,14 +149,21 @@
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.32.0",
"fs-extra": "^11.3.0",
"https-browserify": "^1.0.0",
"markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13",
"path-browserify": "^1.0.1",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"rimraf": "^6.0.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"tailwindcss": "^3.4.1",
"tty-browserify": "^0.0.1",
"typescript": "~5.2.2",
"url": "^0.11.4",
"util": "^0.12.5",
"vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8"
},

10
playwright.config-local.ts

@ -69,11 +69,11 @@ export default defineConfig({
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
use: { ...devices['Desktop Firefox'] },
},
// {
// name: 'firefox',
// testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
// use: { ...devices['Desktop Firefox'] },
// },
/* Test against mobile viewports. */
// {

8
playwright.config.ts

@ -40,10 +40,10 @@ export default defineConfig({
permissions: ["clipboard-read"],
},
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',

1
src/components/ActivityListItem.vue

@ -192,6 +192,7 @@ import ProjectIcon from "./ProjectIcon.vue";
EntityIcon,
ProjectIcon,
},
emits: ["loadClaim", "viewImage", "cacheImage", "confirmClaim"],
})
export default class ActivityListItem extends Vue {
@Prop() record!: GiveRecordWithContactInfo;

105
src/components/EntityIcon.vue

@ -1,42 +1,101 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="w-fit" v-html="generateIcon()"></div>
<div class="w-fit">
<img
v-if="hasImage"
:src="imageUrl"
class="rounded cursor-pointer"
:width="iconSize"
:height="iconSize"
@click="handleClick"
/>
<div v-else class="cursor-pointer" @click="handleClick">
<img
v-if="!identifier"
:src="blankSquareUrl"
class="rounded"
:width="iconSize"
:height="iconSize"
/>
<svg
v-else
:width="iconSize"
:height="iconSize"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<g v-for="(path, index) in avatarPaths" :key="index">
<path :d="path" />
</g>
</svg>
</div>
</div>
</template>
<script lang="ts">
import { createAvatar, StyleOptions } from "@dicebear/core";
import { avataaars } from "@dicebear/collection";
import { Vue, Component, Prop } from "vue-facing-decorator";
import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger";
@Component
export default class EntityIcon extends Vue {
@Prop contact: Contact;
@Prop({ required: false }) contact?: Contact;
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
@Prop iconSize = 0;
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) {
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
} else {
const identifier = this.contact?.did || this.entityId;
if (!identifier) {
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
}
// https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library.
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
// ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = {
seed: (identifier as string) || "",
size: this.iconSize,
};
const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString();
return svgString;
private avatarPaths: string[] = [];
private blankSquareUrl =
import.meta.env.VITE_BASE_URL + "assets/blank-square.svg";
get imageUrl(): string {
return this.contact?.profileImageUrl || this.profileImageUrl;
}
get hasImage(): boolean {
return !!this.imageUrl;
}
get identifier(): string | undefined {
return this.contact?.did || this.entityId;
}
handleClick() {
try {
// Emit a simple event without passing the event object
this.$emit("click");
} catch (error) {
logger.error("Error handling click event:", error);
}
}
generateAvatarPaths(): string[] {
if (!this.identifier) return [];
const options: StyleOptions<object> = {
seed: this.identifier,
size: this.iconSize,
};
const avatar = createAvatar(avataaars, options);
const svgString = avatar.toString();
// Extract paths from SVG string
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, "image/svg+xml");
const paths = Array.from(doc.querySelectorAll("path")).map(
(path) => path.getAttribute("d") || "",
);
return paths;
}
mounted() {
this.avatarPaths = this.generateAvatarPaths();
logger.log("EntityIcon mounted, profileImageUrl:", this.profileImageUrl);
logger.log("EntityIcon mounted, entityId:", this.entityId);
logger.log("EntityIcon mounted, iconSize:", this.iconSize);
}
}
</script>
<style scoped></style>

257
src/components/ProfileSection.vue

@ -0,0 +1,257 @@
/** * @file ProfileSection.vue * @description Component for managing user
profile information * @author Matthew Raymer * @version 1.0.0 */
<template>
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
<div v-if="loading" class="text-center mb-2">
<font-awesome
icon="spinner"
class="fa-spin text-slate-400"
></font-awesome>
Loading profile...
</div>
<div v-else class="flex items-center mb-2">
<span class="font-bold">Public Profile</span>
<font-awesome
icon="circle-info"
class="text-slate-400 fa-fw ml-2 cursor-pointer"
@click="showProfileInfo"
/>
</div>
<textarea
v-model="profileDesc"
class="w-full h-32 p-2 border border-slate-300 rounded-md"
placeholder="Write something about yourself for the public..."
:readonly="loading || saving"
:class="{ 'bg-slate-100': loading || saving }"
></textarea>
<div class="flex items-center mb-4" @click="toggleLocation">
<input v-model="includeLocation" type="checkbox" class="mr-2" />
<label for="includeLocation">Include Location</label>
</div>
<div v-if="includeLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at your
place.
</p>
<l-map
ref="profileMap"
class="!z-40 rounded-md"
@click="handleMapClick"
@ready="onMapReady"
>
<l-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
layer-type="base"
name="OpenStreetMap"
/>
<l-marker
v-if="latitude && longitude"
:lat-lng="[latitude, longitude]"
@click="confirmEraseLocation"
/>
</l-map>
</div>
<div v-if="!loading && !saving">
<div class="flex justify-between items-center">
<button
class="mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loading || saving"
:class="{
'opacity-50 cursor-not-allowed': loading || saving,
}"
@click="saveProfile"
>
Save Profile
</button>
<button
class="mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
:disabled="loading || saving"
:class="{
'opacity-50 cursor-not-allowed':
loading || saving || (!profileDesc && !includeLocation),
}"
@click="confirmDeleteProfile"
>
Delete Profile
</button>
</div>
</div>
<div v-else-if="loading">Loading...</div>
<div v-else>Saving...</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { ProfileService } from "../services/ProfileService";
import { logger } from "../utils/logger";
@Component({
components: {
LMap,
LMarker,
LTileLayer,
},
})
export default class ProfileSection extends Vue {
@Prop({ required: true }) activeDid!: string;
@Prop({ required: true }) partnerApiServer!: string;
@Emit("profile-updated") profileUpdated() {}
loading = true;
saving = false;
profileDesc = "";
latitude = 0;
longitude = 0;
includeLocation = false;
zoom = 2;
async mounted() {
await this.loadProfile();
}
async loadProfile() {
try {
const profile = await ProfileService.loadProfile(
this.activeDid,
this.partnerApiServer,
);
if (profile) {
this.profileDesc = profile.description || "";
this.latitude = profile.location?.lat || 0;
this.longitude = profile.location?.lng || 0;
this.includeLocation = !!(this.latitude && this.longitude);
}
} catch (error) {
logger.error("Error loading profile:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error Loading Profile",
text: "Your server profile is not available.",
});
} finally {
this.loading = false;
}
}
async saveProfile() {
this.saving = true;
try {
await ProfileService.saveProfile(this.activeDid, this.partnerApiServer, {
description: this.profileDesc,
location: this.includeLocation
? {
lat: this.latitude,
lng: this.longitude,
}
: undefined,
});
this.$notify({
group: "alert",
type: "success",
title: "Profile Saved",
text: "Your profile has been updated successfully.",
});
this.profileUpdated();
} catch (error) {
logger.error("Error saving profile:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error Saving Profile",
text: "There was an error saving your profile.",
});
} finally {
this.saving = false;
}
}
toggleLocation() {
this.includeLocation = !this.includeLocation;
if (!this.includeLocation) {
this.latitude = 0;
this.longitude = 0;
this.zoom = 2;
}
}
handleMapClick(event: { latlng: { lat: number; lng: number } }) {
this.latitude = event.latlng.lat;
this.longitude = event.latlng.lng;
}
onMapReady(map: L.Map) {
const zoom = this.latitude && this.longitude ? 12 : 2;
map.setView([this.latitude, this.longitude], zoom);
}
confirmEraseLocation() {
this.$notify({
group: "modal",
type: "confirm",
title: "Erase Marker",
text: "Are you sure you don't want to mark a location? This will erase the current location.",
onYes: () => {
this.latitude = 0;
this.longitude = 0;
this.zoom = 2;
this.includeLocation = false;
},
});
}
async confirmDeleteProfile() {
this.$notify({
group: "modal",
type: "confirm",
title: "Delete Profile",
text: "Are you sure you want to delete your public profile? This will remove your description and location from the server, and it cannot be undone.",
onYes: this.deleteProfile,
});
}
async deleteProfile() {
this.saving = true;
try {
await ProfileService.deleteProfile(this.activeDid, this.partnerApiServer);
this.profileDesc = "";
this.latitude = 0;
this.longitude = 0;
this.includeLocation = false;
this.$notify({
group: "alert",
type: "success",
title: "Profile Deleted",
text: "Your profile has been deleted successfully.",
});
this.profileUpdated();
} catch (error) {
logger.error("Error deleting profile:", error);
this.$notify({
group: "alert",
type: "danger",
title: "Error Deleting Profile",
text: "There was an error deleting your profile.",
});
} finally {
this.saving = false;
}
}
showProfileInfo() {
this.$notify({
group: "alert",
type: "info",
title: "Public Profile Information",
text: "This data will be published for all to see, so be careful what your write. Your ID will only be shared with people who you allow to see your activity.",
});
}
}
</script>

26
src/db/index.ts

@ -1,6 +1,8 @@
import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import { exportDB, ExportOptions } from "dexie-export-import";
import * as R from "ramda";
import Dexie from "dexie";
import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactSchema } from "./tables/contacts";
@ -26,19 +28,26 @@ type NonsensitiveTables = {
};
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T;
export type SecretDexie<T extends Record<string, Dexie.Table> = SecretTable> =
BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
export type SensitiveDexie<
T extends Record<string, Dexie.Table> = SensitiveTables,
> = BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
export type NonsensitiveDexie<
T extends Record<string, Dexie.Table> = NonsensitiveTables,
> = BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
//// Initialize the DBs, starting with the sensitive ones.
// Initialize Dexie database for secret, which is then used to encrypt accountsDB
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
secretDB.version(1).stores(SecretSchema);
secretDB.export = (options) => exportDB(secretDB, options);
// Initialize Dexie database for accounts
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
accountsDexie.version(1).stores(AccountsSchema);
accountsDexie.export = (options) => exportDB(accountsDexie, options);
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
// so that it's clear whether the usage needs the private key inside.
@ -54,8 +63,15 @@ export const accountsDBPromise = useSecretAndInitializeAccountsDB(
//// Now initialize the other DB.
// Initialize Dexie databases for non-sensitive data
// Initialize Dexie database for non-sensitive data
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
db.version(1).stores({
contacts: ContactSchema.contacts,
logs: LogSchema.logs,
settings: SettingsSchema.settings,
temp: TempSchema.temp,
});
db.export = (options) => exportDB(db, options);
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning

3
src/interfaces/common.ts

@ -2,6 +2,9 @@
export interface GenericVerifiableCredential {
"@context"?: string;
"@type": string;
name?: string;
description?: string;
agent?: { identifier: string } | string;
[key: string]: unknown;
}

34
src/interfaces/identifier.ts

@ -0,0 +1,34 @@
/**
* Interface for a Decentralized Identifier (DID)
* @author Matthew Raymer
*/
import { KeyMeta } from "../libs/crypto/vc";
export interface IKey {
id: string;
type: string;
controller: string;
ethereumAddress: string;
publicKeyHex: string;
privateKeyHex: string;
meta?: KeyMeta;
}
export interface IService {
id: string;
type: string;
serviceEndpoint: string;
description?: string;
metadata?: {
version?: string;
capabilities?: string[];
config?: Record<string, unknown>;
};
}
export interface IIdentifier {
did: string;
keys: IKey[];
services: IService[];
}

1
src/interfaces/index.ts

@ -5,3 +5,4 @@ export * from "./limits";
export * from "./records";
export * from "./user";
export * from "./deepLinks";
export * from "./identifier";

288
src/interfaces/service.ts

@ -0,0 +1,288 @@
/**
* @file service.ts
* @description Service interfaces for Decentralized Identifiers (DIDs)
*
* This module defines the service interfaces used in the TimeSafari application.
* Services are associated with DIDs to provide additional functionality and endpoints.
*
* Architecture:
* 1. Base IService interface defines common service properties
* 2. Specialized interfaces extend IService for specific service types
* 3. Services are stored in IIdentifier.services array
* 4. Services are loaded and managed by PlatformServiceFactory
*
* Service Types:
* - EndorserService: Handles claims and endorsements
* - PushNotificationService: Manages web push notifications
* - ProfileService: Handles user profiles and settings
* - BackupService: Manages data backup and restore
*
* @see IIdentifier
* @see PlatformServiceFactory
* @see DatabaseBackupService
*/
/**
* Base interface for all DID services
*
* This interface defines the core properties that all services must implement.
* It follows the W3C DID specification for service endpoints.
*
* @example
* const service: IService = {
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch',
* description: 'Endorser service for claims and endorsements',
* metadata: {
* version: '1.0.0',
* capabilities: ['claims', 'endorsements'],
* config: { apiServer: 'https://api.endorser.ch' }
* }
* };
*/
export interface IService {
/**
* Unique identifier for the service
* @example 'endorser-service'
* @example 'push-notification-service'
*/
id: string;
/**
* Type of service
* @example 'EndorserService'
* @example 'PushNotificationService'
*/
type: string;
/**
* Endpoint URL for the service
* @example 'https://api.endorser.ch'
* @example 'https://push.timesafari.app'
*/
serviceEndpoint: string;
/**
* Optional human-readable description of the service
* @example 'Service for handling claims and endorsements'
*/
description?: string;
/**
* Optional metadata for service configuration
*/
metadata?: {
/**
* Service version in semantic versioning format
* @example '1.0.0'
*/
version?: string;
/**
* Array of service capabilities
* @example ['claims', 'endorsements']
* @example ['notifications', 'alerts']
*/
capabilities?: string[];
/**
* Service-specific configuration
* @example { apiServer: 'https://api.endorser.ch' }
*/
config?: Record<string, unknown>;
};
}
/**
* Service for handling claims and endorsements
*
* This service provides endpoints for:
* - Submitting claims
* - Managing endorsements
* - Checking rate limits
*
* @example
* const endorserService: IEndorserService = {
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch',
* metadata: {
* version: '1.0.0',
* capabilities: ['claims', 'endorsements'],
* config: {
* apiServer: 'https://api.endorser.ch',
* rateLimits: {
* claimsPerDay: 100,
* endorsementsPerDay: 1000
* }
* }
* }
* };
*/
export interface IEndorserService extends IService {
/** @override */
type: "EndorserService";
/** @override */
metadata: {
version: string;
capabilities: ["claims", "endorsements"];
config: {
/**
* API server URL
* @example 'https://api.endorser.ch'
*/
apiServer: string;
/**
* Optional rate limits
*/
rateLimits?: {
/**
* Maximum claims per day
* @default 100
*/
claimsPerDay: number;
/**
* Maximum endorsements per day
* @default 1000
*/
endorsementsPerDay: number;
};
};
};
}
/**
* Service for managing web push notifications
*
* This service provides endpoints for:
* - Registering push subscriptions
* - Sending push notifications
* - Managing notification preferences
*
* @example
* const pushService: IPushNotificationService = {
* id: 'push-service',
* type: 'PushNotificationService',
* serviceEndpoint: 'https://push.timesafari.app',
* metadata: {
* version: '1.0.0',
* capabilities: ['notifications'],
* config: {
* pushServer: 'https://push.timesafari.app',
* vapidPublicKey: '...'
* }
* }
* };
*/
export interface IPushNotificationService extends IService {
/** @override */
type: "PushNotificationService";
/** @override */
metadata: {
version: string;
capabilities: ["notifications"];
config: {
/**
* Push server URL
* @example 'https://push.timesafari.app'
*/
pushServer: string;
/**
* Optional VAPID public key for push notifications
*/
vapidPublicKey?: string;
};
};
}
/**
* Service for managing user profiles and settings
*
* This service provides endpoints for:
* - Managing user profiles
* - Updating user settings
* - Retrieving user preferences
*
* @example
* const profileService: IProfileService = {
* id: 'profile-service',
* type: 'ProfileService',
* serviceEndpoint: 'https://partner-api.endorser.ch',
* metadata: {
* version: '1.0.0',
* capabilities: ['profile', 'settings'],
* config: {
* partnerApiServer: 'https://partner-api.endorser.ch'
* }
* }
* };
*/
export interface IProfileService extends IService {
/** @override */
type: "ProfileService";
/** @override */
metadata: {
version: string;
capabilities: ["profile", "settings"];
config: {
/**
* Partner API server URL
* @example 'https://partner-api.endorser.ch'
*/
partnerApiServer: string;
};
};
}
/**
* Service for managing data backup and restore operations
*
* This service provides endpoints for:
* - Creating backups
* - Restoring from backups
* - Managing backup storage
*
* @example
* const backupService: IBackupService = {
* id: 'backup-service',
* type: 'BackupService',
* serviceEndpoint: 'https://backup.timesafari.app',
* metadata: {
* version: '1.0.0',
* capabilities: ['backup', 'restore'],
* config: {
* storageType: 'cloud',
* encryptionKey: '...'
* }
* }
* };
*/
export interface IBackupService extends IService {
/** @override */
type: "BackupService";
/** @override */
metadata: {
version: string;
capabilities: ["backup", "restore"];
config: {
/**
* Storage type for backups
* @default 'local'
*/
storageType: "local" | "cloud";
/**
* Optional encryption key for backups
*/
encryptionKey?: string;
};
};
}

4
src/libs/crypto/vc/index.ts

@ -38,6 +38,10 @@ export interface KeyMeta {
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
/**
* The derivation path for the key
*/
derivationPath?: string;
}
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver });

2
src/libs/endorserServer.ts

@ -50,6 +50,8 @@ import {
} from "../interfaces";
import { logger } from "../utils/logger";
export type { GenericVerifiableCredential, GenericCredWrapper };
/**
* Standard context for schema.org data
* @constant {string}

67
src/main.ts

@ -200,14 +200,63 @@ function setupGlobalErrorHandler(app: VueApp) {
};
}
const app = createApp(App)
.component("fa", FontAwesomeIcon)
.component("camera", Camera)
.use(createPinia())
.use(VueAxios, axios)
.use(router)
.use(Notifications);
const app = createApp(App);
setupGlobalErrorHandler(app);
// Add global error handler for component registration
app.config.errorHandler = (err, vm, info) => {
logger.error("Vue global error:", {
error:
err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
}
: err,
componentName: vm?.$options?.name || "unknown",
info,
componentData: vm
? {
hasRouter: !!vm.$router,
hasNotify: !!vm.$notify,
hasAxios: !!vm.axios,
}
: "no vm data",
});
};
app.mount("#app");
// Register components and plugins with error handling
try {
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera);
const pinia = createPinia();
app.use(pinia);
logger.log("Pinia store initialized");
app.use(VueAxios, axios);
logger.log("Axios initialized");
app.use(router);
logger.log("Router initialized");
app.use(Notifications);
logger.log("Notifications initialized");
setupGlobalErrorHandler(app);
logger.log("Global error handler setup");
// Mount the app
app.mount("#app");
logger.log("App mounted successfully");
} catch (error) {
logger.error("Critical error during app initialization:", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error,
});
}

82
src/platforms/capacitor/DatabaseBackupService.ts

@ -0,0 +1,82 @@
/**
* @file DatabaseBackupService.ts
* @description Capacitor-specific implementation of database backup service
*
* This service handles database backup operations on Capacitor platforms (Android/iOS)
* using the Filesystem and Share plugins. It creates a temporary backup file,
* writes the backup data to it, and shares the file using the platform's share sheet.
*/
import { Filesystem, Directory } from "@capacitor/filesystem";
import { Share } from "@capacitor/share";
import { DatabaseBackupService as BaseDatabaseBackupService } from "../../services/DatabaseBackupService";
import { log, error } from "../../utils/logger";
export class DatabaseBackupService extends BaseDatabaseBackupService {
/**
* Handles the backup process for Capacitor platforms
*
* @param base64Data - Backup data in base64 format
* @param arrayBuffer - Backup data as ArrayBuffer
* @param blob - Backup data as Blob
*/
protected async handleBackup(
base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
try {
log("Starting backup process for Capacitor platform");
// Create a timestamped backup file name
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupFileName = `timesafari-backup-${timestamp}.json`;
const backupFilePath = `backups/${backupFileName}`;
log("Creating backup file:", {
fileName: backupFileName,
path: backupFilePath,
});
// Write the backup file
const writeResult = (await Filesystem.writeFile({
path: backupFilePath,
data: base64Data,
directory: Directory.Cache,
recursive: true,
})) as unknown as { uri: string };
if (!writeResult.uri) {
throw new Error("Failed to write backup file: No URI returned");
}
log("Backup file written successfully:", { uri: writeResult.uri });
// Share the backup file
log("Sharing backup file");
await Share.share({
title: "TimeSafari Backup",
text: "Your TimeSafari backup file",
url: writeResult.uri,
dialogTitle: "Share TimeSafari Backup",
});
log("Backup shared successfully");
// Clean up the temporary file
try {
await Filesystem.deleteFile({
path: backupFilePath,
directory: Directory.Cache,
});
log("Temporary backup file cleaned up");
} catch (cleanupError) {
error("Failed to clean up temporary backup file:", cleanupError);
// Don't throw here as the backup was successful
}
} catch (err) {
error("Error during backup process:", err);
throw err;
}
}
}

361
src/router/index.ts

@ -6,8 +6,13 @@ import {
RouteLocationNormalized,
RouteRecordRaw,
} from "vue-router";
import { accountsDBPromise } from "../db/index";
import {
accountsDBPromise,
retrieveSettingsForActiveAccount,
} from "../db/index";
import { logger } from "../utils/logger";
import { Component as VueComponent } from "vue-facing-decorator";
import { defineComponent } from "vue";
/**
*
@ -35,7 +40,79 @@ const routes: Array<RouteRecordRaw> = [
{
path: "/account",
name: "account",
component: () => import("../views/AccountViewView.vue"),
component: () => {
logger.log("Starting lazy load of AccountViewView");
return new Promise((resolve) => {
import("../views/AccountViewView.vue")
.then((module) => {
if (!module?.default) {
logger.error(
"AccountViewView module loaded but default export is missing",
{
module: {
hasDefault: !!module?.default,
keys: Object.keys(module || {}),
},
},
);
resolve(createErrorComponent());
return;
}
// Check if the component has the required dependencies
const component = module.default;
logger.log("AccountViewView loaded, checking dependencies...", {
componentName: component.name,
hasVueComponent: component instanceof VueComponent,
hasClass: typeof component === "function",
type: typeof component,
});
resolve(component);
})
.catch((err) => {
logger.error("Failed to load AccountViewView:", {
error:
err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
}
: err,
type: typeof err,
});
resolve(createErrorComponent());
});
});
},
beforeEnter: async (to, from, next) => {
try {
logger.log("Account route beforeEnter guard starting");
// Check if required dependencies are available
const settings = await retrieveSettingsForActiveAccount();
logger.log("Account route: settings loaded", {
hasActiveDid: !!settings.activeDid,
isRegistered: !!settings.isRegistered,
});
next();
} catch (error) {
logger.error("Error in account route beforeEnter:", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error,
});
next({ name: "home" });
}
},
},
{
path: "/claim/:id?",
@ -315,25 +392,271 @@ const router = createRouter({
// Replace initial URL to start at `/` if necessary
router.replace(initialPath || "/");
const errorHandler = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any,
to: RouteLocationNormalized,
from: RouteLocationNormalized,
) => {
// Handle the error here
logger.error("Caught in top level error handler:", error, to, from);
alert("Something is very wrong. Try reloading or restarting the app.");
// Add global error handler
router.onError((error, to, from) => {
logger.error("Router error:", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error,
to: {
name: to.name,
path: to.path,
},
from: {
name: from.name,
path: from.path,
},
});
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
};
// If it's a reference error during account view import, try to handle it gracefully
if (error instanceof ReferenceError && to.name === "account") {
logger.error("Account view import error:", {
error:
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: error,
});
// Instead of redirecting, let the component's error handling take over
return;
}
});
router.onError(errorHandler); // Assign the error handler to the router instance
// Add navigation guard for debugging
router.beforeEach((to, from, next) => {
logger.log("Navigation debug:", {
to: {
fullPath: to.fullPath,
path: to.path,
name: to.name,
params: to.params,
query: to.query,
},
from: {
fullPath: from.fullPath,
path: from.path,
name: from.name,
params: from.params,
query: from.query,
},
});
// For account route, try to preload the component
if (to.name === "account") {
logger.log("Preloading account component...");
// Wrap in try-catch and use Promise
new Promise((resolve) => {
logger.log("Starting dynamic import of AccountViewView");
// Add immediate try-catch to get more context
try {
const importPromise = import("../views/AccountViewView.vue");
logger.log("Import initiated successfully");
importPromise
.then((module) => {
try {
logger.log("Import completed, analyzing module:", {
moduleExists: !!module,
moduleType: typeof module,
moduleKeys: Object.keys(module || {}),
hasDefault: !!module?.default,
defaultType: module?.default
? typeof module.default
: "undefined",
defaultConstructor: module?.default?.constructor?.name,
moduleContent: {
...Object.fromEntries(
Object.entries(module).map(([key, value]) => [
key,
typeof value === "function"
? "function"
: typeof value === "object"
? Object.keys(value || {})
: typeof value,
]),
),
},
});
if (!module?.default) {
logger.error(
"AccountViewView preload: module loaded but default export is missing",
{
module: {
hasDefault: !!module?.default,
keys: Object.keys(module || {}),
moduleType: typeof module,
exports: Object.keys(module || {}).map((key) => ({
key,
type: typeof (module as any)[key],
value:
typeof (module as any)[key] === "function"
? "function"
: typeof (module as any)[key] === "object"
? Object.keys((module as any)[key] || {})
: (module as any)[key],
})),
},
},
);
resolve(null);
return;
}
const component = module.default;
// router.beforeEach((to, from, next) => {
// console.log("Navigating to view:", to.name);
// console.log("From view:", from.name);
// next();
// });
// Try to safely inspect the component
const componentDetails = {
componentName: component.name,
hasVueComponent: component instanceof VueComponent,
hasClass: typeof component === "function",
type: typeof component,
properties: Object.keys(component),
decorators: Object.getOwnPropertyDescriptor(
component,
"__decorators",
),
vueOptions:
(component as any).__vccOpts ||
(component as any).options ||
null,
setup: typeof (component as any).setup === "function",
render: typeof (component as any).render === "function",
components: (component as any).components
? Object.keys((component as any).components)
: null,
imports: Object.keys(module).filter((key) => key !== "default"),
};
logger.log("Successfully analyzed component:", componentDetails);
resolve(component);
} catch (analysisError) {
logger.error("Error during component analysis:", {
error:
analysisError instanceof Error
? {
name: analysisError.name,
message: analysisError.message,
stack: analysisError.stack,
keys: Object.keys(analysisError),
properties: Object.getOwnPropertyNames(analysisError),
}
: analysisError,
type: typeof analysisError,
phase: "analysis",
});
resolve(null);
}
})
.catch((err) => {
logger.error("Failed to preload account component:", {
error:
err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
keys: Object.keys(err),
properties: Object.getOwnPropertyNames(err),
}
: err,
type: typeof err,
context: {
routeName: to.name,
routePath: to.path,
fromRoute: from.name,
},
phase: "module-load",
});
resolve(null);
});
} catch (immediateError) {
logger.error("Immediate error during import initiation:", {
error:
immediateError instanceof Error
? {
name: immediateError.name,
message: immediateError.message,
stack: immediateError.stack,
keys: Object.keys(immediateError),
properties: Object.getOwnPropertyNames(immediateError),
}
: immediateError,
type: typeof immediateError,
context: {
routeName: to.name,
routePath: to.path,
fromRoute: from.name,
importPath: "../views/AccountViewView.vue",
},
phase: "import",
});
resolve(null);
}
}).catch((err) => {
logger.error("Critical error in account component preload:", {
error:
err instanceof Error
? {
name: err.name,
message: err.message,
stack: err.stack,
}
: err,
context: {
routeName: to.name,
routePath: to.path,
fromRoute: from.name,
},
phase: "wrapper",
});
});
}
// Always call next() to continue navigation
next();
});
function createErrorComponent() {
return defineComponent({
name: "AccountViewError",
components: {
// Add any required components here
},
setup() {
const goHome = () => {
router.push({ name: "home" });
};
return {
goHome,
};
},
template: `
<section class="p-6 pb-24 max-w-3xl mx-auto">
<h1 class="text-4xl text-center font-light mb-8">Error Loading Account View</h1>
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong class="font-bold">Failed to load account view.</strong>
<span class="block sm:inline"> Please try refreshing the page.</span>
</div>
<div class="mt-4 text-center">
<button @click="goHome" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Return to Home
</button>
</div>
</section>
`,
});
}
export default router;

95
src/services/DatabaseBackupService.ts

@ -0,0 +1,95 @@
/**
* @file DatabaseBackupService.ts
* @description Base service class for handling database backup operations
*
* This service implements the Template Method pattern to provide a common interface
* for database backup operations across different platforms. It defines the structure
* of backup operations while delegating platform-specific implementations to subclasses.
*
* Build Process Integration:
* 1. Platform-Specific Implementation:
* - Each platform (web, electron, capacitor) has its own implementation
* - Implementations are loaded dynamically via PlatformServiceFactory
* - Located in ./platforms/{platform}/DatabaseBackupService.ts
*
* 2. Build Configuration:
* - Vite config files (vite.config.*.mts) set VITE_PLATFORM
* - PlatformServiceFactory uses this to load correct implementation
* - Build process creates separate chunks for each platform
*
* 3. Data Handling:
* - Supports multiple data formats (base64, ArrayBuffer, Blob)
* - Platform implementations handle format conversion
* - Ensures consistent backup format across platforms
*
* Usage:
* - Create backup: DatabaseBackupService.createAndShareBackup(data)
* - Platform-specific: new WebDatabaseBackupService().handleBackup()
*
* @see PlatformServiceFactory.ts
* @see vite.config.web.mts
* @see vite.config.electron.mts
* @see vite.config.capacitor.mts
*/
import { PlatformServiceFactory } from "./PlatformServiceFactory";
import { log, error } from "../utils/logger";
export class DatabaseBackupService {
/**
* Template method that must be implemented by platform-specific services
* @param base64Data - Backup data in base64 format
* @param arrayBuffer - Backup data as ArrayBuffer
* @param blob - Backup data as Blob
* @throws Error if not implemented by subclass
*/
protected async handleBackup(
_base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
throw new Error(
"handleBackup must be implemented by platform-specific service",
);
}
/**
* Factory method to create and share a backup
* Uses PlatformServiceFactory to get platform-specific implementation
*
* @param base64Data - Backup data in base64 format
* @param arrayBuffer - Backup data as ArrayBuffer
* @param blob - Backup data as Blob
* @returns Promise that resolves when backup is complete
*/
public static async createAndShareBackup(
base64Data: string,
arrayBuffer: ArrayBuffer,
blob: Blob,
): Promise<void> {
try {
log("Creating platform-specific backup service");
const backupService = await this.getPlatformSpecificBackupService();
log("Backup service created successfully");
log("Executing platform-specific backup");
await backupService.handleBackup(base64Data, arrayBuffer, blob);
log("Backup completed successfully");
} catch (err) {
error("Error during backup creation:", err);
if (err instanceof Error) {
error("Error details:", {
name: err.name,
message: err.message,
stack: err.stack,
});
}
throw err;
}
}
private static async getPlatformSpecificBackupService(): Promise<DatabaseBackupService> {
const factory = PlatformServiceFactory.getInstance();
return await factory.createDatabaseBackupService();
}
}

195
src/services/PlatformServiceFactory.ts

@ -0,0 +1,195 @@
/**
* @file PlatformServiceFactory.ts
* @description Factory for creating platform-specific service implementations
* @author Matthew Raymer
* @version 1.0.0
*
* This factory implements the Abstract Factory pattern to create platform-specific
* implementations of services. It uses Vite's dynamic import feature to load the
* appropriate implementation based on the current platform (web, electron, etc.).
*
* Architecture:
* 1. Singleton Pattern:
* - Ensures only one factory instance exists
* - Manages platform-specific service instances
* - Maintains consistent state across the application
*
* 2. Dynamic Loading:
* - Uses Vite's dynamic import for platform-specific code
* - Loads services on-demand based on platform
* - Handles platform detection and service instantiation
*
* 3. Platform Detection:
* - Uses VITE_PLATFORM environment variable
* - Supports web, electron, and capacitor platforms
* - Falls back to 'web' if platform is not specified
*
* Usage:
* ```typescript
* // Get factory instance
* const factory = PlatformServiceFactory.getInstance();
*
* // Create platform-specific service
* const backupService = await factory.createDatabaseBackupService();
* ```
*
* @see vite.config.web.mts
* @see vite.config.electron.mts
* @see vite.config.capacitor.mts
* @see DatabaseBackupService
*/
import { DatabaseBackupService } from "./DatabaseBackupService";
import { DatabaseBackupService as StubDatabaseBackupService } from "./platforms/empty";
import { logger } from "../utils/logger";
/**
* Factory class for creating platform-specific service implementations
*
* This class manages the creation and instantiation of platform-specific
* service implementations. It uses the Abstract Factory pattern to provide
* a consistent interface for creating services across different platforms.
*
* @example
* ```typescript
* // Get factory instance
* const factory = PlatformServiceFactory.getInstance();
*
* // Create platform-specific service
* const backupService = await factory.createDatabaseBackupService();
*
* // Use the service
* await backupService.handleBackup(data);
* ```
*/
export class PlatformServiceFactory {
/**
* Singleton instance of the factory
* @private
*/
private static instance: PlatformServiceFactory;
/**
* Current platform identifier
* @private
*/
private platform: string;
/**
* Private constructor to enforce singleton pattern
*
* Initializes the factory with the current platform from environment variables.
* Falls back to 'web' if no platform is specified.
*
* @private
*/
private constructor() {
this.platform = import.meta.env.VITE_PLATFORM || "web";
}
/**
* Gets the singleton instance of the factory
*
* Creates a new instance if one doesn't exist, otherwise returns
* the existing instance.
*
* @returns {PlatformServiceFactory} The singleton factory instance
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* ```
*/
public static getInstance(): PlatformServiceFactory {
if (!PlatformServiceFactory.instance) {
PlatformServiceFactory.instance = new PlatformServiceFactory();
}
return PlatformServiceFactory.instance;
}
/**
* Creates a platform-specific database backup service
*
* Dynamically loads and instantiates the appropriate implementation
* based on the current platform. The implementation is loaded from
* the platforms/{platform}/DatabaseBackupService.ts file.
*
* @returns {Promise<DatabaseBackupService>} A promise that resolves to a platform-specific backup service
* @throws {Error} If the service fails to load or instantiate
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* try {
* const backupService = await factory.createDatabaseBackupService();
* await backupService.handleBackup(data);
* } catch (error) {
* logger.error('Failed to create backup service:', error);
* }
* ```
*/
public async createDatabaseBackupService(): Promise<DatabaseBackupService> {
// List of supported platforms for web builds
const webSupportedPlatforms = ["web", "capacitor", "electron"];
// Return stub implementation for unsupported platforms
if (!webSupportedPlatforms.includes(this.platform)) {
logger.log(
`Using stub implementation for unsupported platform: ${this.platform}`,
);
return new StubDatabaseBackupService();
}
try {
logger.log(`Loading platform-specific service for ${this.platform}`);
// Use dynamic import with platform-specific path
const module = await import(
/* @vite-ignore */
`./platforms/${this.platform}/DatabaseBackupService.ts`
);
logger.log("Platform service loaded successfully");
return new module.DatabaseBackupService();
} catch (error) {
logger.error(
`[TimeSafari] Failed to load platform-specific service for ${this.platform}:`,
error,
);
// Fallback to stub implementation on error
logger.log("Falling back to stub implementation");
return new StubDatabaseBackupService();
}
}
/**
* Gets the current platform identifier
*
* @returns {string} The current platform identifier
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* logger.log(factory.getPlatform()); // 'web', 'electron', or 'capacitor'
* ```
*/
public getPlatform(): string {
return this.platform;
}
/**
* Sets the current platform identifier
*
* This method is primarily used for testing purposes to override
* the platform detection. Use with caution in production code.
*
* @param {string} platform - The platform identifier to set
*
* @example
* ```typescript
* const factory = PlatformServiceFactory.getInstance();
* factory.setPlatform('electron'); // For testing purposes only
* ```
*/
public setPlatform(platform: string): void {
this.platform = platform;
}
}

105
src/services/ProfileService.ts

@ -0,0 +1,105 @@
/**
* @file ProfileService.ts
* @description Service class for handling user profile operations
* @author Matthew Raymer
* @version 1.0.0
*/
import { logger } from "../utils/logger";
import { getHeaders } from "../libs/endorserServer";
import type { UserProfile } from "@/types/interfaces";
export class ProfileService {
/**
* Saves a user profile to the server
* @param activeDid - The user's active DID
* @param partnerApiServer - The partner API server URL
* @param profile - The profile data to save
* @returns Promise<void>
*/
static async saveProfile(
activeDid: string,
partnerApiServer: string,
profile: Partial<UserProfile>,
): Promise<void> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${partnerApiServer}/api/partner/userProfile`,
{
method: "POST",
headers,
body: JSON.stringify(profile),
},
);
if (!response.ok) {
throw new Error(`Failed to save profile: ${response.statusText}`);
}
} catch (error) {
logger.error("Error saving profile:", error);
throw error;
}
}
/**
* Deletes a user profile from the server
* @param activeDid - The user's active DID
* @param partnerApiServer - The partner API server URL
* @returns Promise<void>
*/
static async deleteProfile(
activeDid: string,
partnerApiServer: string,
): Promise<void> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${partnerApiServer}/api/partner/userProfile`,
{
method: "DELETE",
headers,
},
);
if (!response.ok) {
throw new Error(`Failed to delete profile: ${response.statusText}`);
}
} catch (error) {
logger.error("Error deleting profile:", error);
throw error;
}
}
/**
* Loads a user profile from the server
* @param activeDid - The user's active DID
* @param partnerApiServer - The partner API server URL
* @returns Promise<UserProfile | null>
*/
static async loadProfile(
activeDid: string,
partnerApiServer: string,
): Promise<UserProfile | null> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${partnerApiServer}/api/partner/userProfileForIssuer/${activeDid}`,
{ headers },
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`Failed to load profile: ${response.statusText}`);
}
return await response.json();
} catch (error) {
logger.error("Error loading profile:", error);
throw error;
}
}
}

110
src/services/RateLimitsService.ts

@ -0,0 +1,110 @@
/**
* @file RateLimitsService.ts
* @description Service class for handling rate limit operations
* @author Matthew Raymer
* @version 1.0.0
*/
import { logger } from "../utils/logger";
import { getHeaders } from "../libs/endorserServer";
import type { EndorserRateLimits, ImageRateLimits } from "../interfaces/limits";
import axios from "axios";
export class RateLimitsService {
/**
* Fetches rate limits for a given DID
* @param apiServer - The API server URL
* @param did - The user's DID
* @returns Promise<EndorserRateLimits>
*/
static async fetchRateLimits(
apiServer: string,
did: string,
): Promise<EndorserRateLimits> {
logger.log("Fetching rate limits for DID:", did);
logger.log("Using API server:", apiServer);
try {
const headers = await getHeaders(did);
const response = await axios.get(
`${apiServer}/api/v2/rate-limits/${did}`,
{ headers },
);
logger.log("Rate limits response:", response.data);
return response.data;
} catch (error) {
if (
axios.isAxiosError(error) &&
(error.response?.status === 400 || error.response?.status === 404)
) {
const errorData = error.response.data as {
error?: { message?: string; code?: string };
};
if (
errorData.error?.code === "UNREGISTERED_USER" ||
error.response?.status === 404
) {
logger.log("User is not registered, returning default limits");
return {
doneClaimsThisWeek: "0",
maxClaimsPerWeek: "0",
nextWeekBeginDateTime: new Date().toISOString(),
doneRegistrationsThisMonth: "0",
maxRegistrationsPerMonth: "0",
nextMonthBeginDateTime: new Date().toISOString(),
};
}
}
logger.error("Error fetching rate limits:", error);
throw error;
}
}
/**
* Fetches image rate limits for a given DID
* @param apiServer - The API server URL
* @param activeDid - The user's active DID
* @returns Promise<ImageRateLimits>
*/
static async fetchImageRateLimits(
apiServer: string,
activeDid: string,
): Promise<ImageRateLimits> {
try {
const headers = await getHeaders(activeDid);
const response = await fetch(
`${apiServer}/api/endorser/imageRateLimits/${activeDid}`,
{ headers },
);
if (!response.ok) {
throw new Error(
`Failed to fetch image rate limits: ${response.statusText}`,
);
}
return await response.json();
} catch (error) {
logger.error("Error fetching image rate limits:", error);
throw error;
}
}
/**
* Formats rate limit error messages
* @param error - The error object
* @returns string
*/
static formatRateLimitError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "object" && error !== null) {
const err = error as {
response?: { data?: { error?: { message?: string } } };
};
return err.response?.data?.error?.message || "An unknown error occurred";
}
return "An unknown error occurred";
}
}

35
src/services/platforms/capacitor/DatabaseBackupService.ts

@ -0,0 +1,35 @@
/**
* @file DatabaseBackupService.ts
* @description Capacitor-specific implementation of the DatabaseBackupService
* @author Matthew Raymer
* @version 1.0.0
*/
import { DatabaseBackupService } from "../../DatabaseBackupService";
import { Filesystem } from "@capacitor/filesystem";
import { Share } from "@capacitor/share";
export default class CapacitorDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(
base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
// Capacitor platform handling
const fileName = `database-backup-${new Date().toISOString()}.json`;
const path = `backups/${fileName}`;
await Filesystem.writeFile({
path,
data: base64Data,
directory: "CACHE",
recursive: true,
});
await Share.share({
title: "Database Backup",
text: "Here's your database backup",
url: path,
});
}
}

18
src/services/platforms/electron/DatabaseBackupService.ts

@ -0,0 +1,18 @@
import { DatabaseBackupService } from "../../DatabaseBackupService";
import { dialog } from "electron";
import * as fs from "fs";
import * as path from "path";
export default class ElectronDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(base64Data: string): Promise<void> {
const { filePath } = await dialog.showSaveDialog({
title: "Save Database Backup",
defaultPath: path.join(process.env.HOME || "", "database-backup.json"),
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (filePath) {
fs.writeFileSync(filePath, base64Data, "base64");
}
}
}

20
src/services/platforms/empty.ts

@ -0,0 +1,20 @@
/**
* @file empty.ts
* @description Stub implementation for excluding platform-specific code
* @author Matthew Raymer
* @version 1.0.0
*/
import { DatabaseBackupService } from "../DatabaseBackupService";
export default class StubDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(
_base64Data: string,
_arrayBuffer: ArrayBuffer,
_blob: Blob,
): Promise<void> {
throw new Error("This platform does not support database backups");
}
}
export { StubDatabaseBackupService as DatabaseBackupService };

33
src/services/platforms/web/DatabaseBackupService.ts

@ -0,0 +1,33 @@
/**
* @file DatabaseBackupService.ts
* @description Web-specific implementation of the DatabaseBackupService
* @author Matthew Raymer
* @version 1.0.0
*/
import { DatabaseBackupService } from "../../DatabaseBackupService";
import { log, error } from "../../../utils/logger";
export default class WebDatabaseBackupService extends DatabaseBackupService {
protected async handleBackup(
_base64Data: string,
_arrayBuffer: ArrayBuffer,
blob: Blob,
): Promise<void> {
try {
log("Starting web platform backup");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `database-backup-${new Date().toISOString()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log("Web platform backup completed");
} catch (err) {
error("Error during web platform backup:", err);
throw err;
}
}
}

64
src/types/capacitor.d.ts

@ -0,0 +1,64 @@
/**
* Type declarations for Capacitor modules used in the application.
* @author Matthew Raymer
*/
declare module "@capacitor/filesystem" {
export interface FileWriteOptions {
path: string;
data: string;
directory?: string;
encoding?: string;
recursive?: boolean;
}
export interface FileReadResult {
data: string;
}
export interface FileDeleteOptions {
path: string;
directory?: string;
}
export interface FilesystemDirectory {
Cache: "CACHE";
Documents: "DOCUMENTS";
Data: "DATA";
External: "EXTERNAL";
ExternalStorage: "EXTERNAL_STORAGE";
}
export interface Filesystem {
writeFile(options: FileWriteOptions): Promise<void>;
readFile(options: {
path: string;
directory?: string;
}): Promise<FileReadResult>;
deleteFile(options: FileDeleteOptions): Promise<void>;
}
export const Filesystem: Filesystem;
export const Directory: FilesystemDirectory;
export const Encoding: {
UTF8: "utf8";
ASCII: "ascii";
UTF16: "utf16";
};
}
declare module "@capacitor/share" {
export interface ShareOptions {
title?: string;
text?: string;
url?: string;
dialogTitle?: string;
files?: string[];
}
export interface Share {
share(options: ShareOptions): Promise<void>;
}
export const Share: Share;
}

7
src/types/index.ts

@ -1,3 +1,10 @@
/**
* Index file for all type declarations.
* @author Matthew Raymer
*/
export * from "./interfaces";
import { GiveSummaryRecord, GiveVerifiableCredential } from "interfaces";
export interface GiveRecordWithContactInfo extends GiveSummaryRecord {

430
src/types/interfaces.ts

@ -0,0 +1,430 @@
/**
* @file interfaces.ts
* @description Core type declarations for the TimeSafari application
*
* This module defines the core interfaces and types used throughout the application.
* It serves as the central location for type definitions that are shared across
* multiple components and services.
*
* Architecture:
* 1. DID (Decentralized Identifier) Types:
* - IIdentifier: Core DID structure
* - IKey: Cryptographic key information
* - IService: Service endpoints and capabilities
*
* 2. Verifiable Credential Types:
* - GenericCredWrapper: Base wrapper for all credentials
* - GiveVerifiableCredential: Gift-related credentials
* - OfferVerifiableCredential: Offer-related credentials
* - RegisterVerifiableCredential: Registration credentials
*
* 3. Service Types:
* - EndorserService: Claims and endorsements
* - PushNotificationService: Web push notifications
* - ProfileService: User profiles
* - BackupService: Data backup
*
* @see src/interfaces/identifier.ts
* @see src/interfaces/claims.ts
* @see src/interfaces/limits.ts
*/
import { GiveVerifiableCredential } from "../interfaces";
/**
* Interface for a Decentralized Identifier (DID)
*
* This interface defines the structure of a DID, which is a unique identifier
* that can be used to look up a DID document containing information associated
* with the DID, such as public keys and service endpoints.
*
* @example
* ```typescript
* const identifier: IIdentifier = {
* did: 'did:ethr:0x123...',
* provider: 'ethr',
* keys: [{
* kid: 'keys-1',
* kms: 'local',
* type: 'Secp256k1',
* publicKeyHex: '0x...',
* meta: { derivationPath: "m/44'/60'/0'/0/0" }
* }],
* services: [{
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch'
* }]
* };
* ```
*/
export interface IIdentifier {
/**
* The DID string in the format 'did:method:identifier'
* @example 'did:ethr:0x123...'
*/
did: string;
/**
* The DID method provider
* @example 'ethr'
*/
provider: string;
/**
* Array of cryptographic keys associated with the DID
*/
keys: Array<{
/**
* Key identifier
* @example 'keys-1'
*/
kid: string;
/**
* Key management system
* @example 'local'
*/
kms: string;
/**
* Key type
* @example 'Secp256k1'
*/
type: string;
/**
* Public key in hexadecimal format
* @example '0x...'
*/
publicKeyHex: string;
/**
* Optional metadata about the key
*/
meta?: {
/**
* HD wallet derivation path
* @example "m/44'/60'/0'/0/0"
*/
derivationPath?: string;
/**
* Key usage or purpose
* @example "signing", "encryption"
*/
usage?: string;
/**
* Key creation timestamp
*/
createdAt?: number;
/**
* Additional key metadata
*/
[key: string]: unknown;
};
}>;
/**
* Array of service endpoints associated with the DID
*/
services: Array<{
/**
* Service identifier
* @example 'endorser-service'
*/
id: string;
/**
* Service type
* @example 'EndorserService'
*/
type: string;
/**
* Service endpoint URL
* @example 'https://api.endorser.ch'
*/
serviceEndpoint: string;
/**
* Optional service description
*/
description?: string;
}>;
/**
* Optional metadata about the identifier
*/
meta?: {
/**
* DID method-specific metadata
* @example { network: "mainnet", chainId: 1 } for ethr
*/
method?: Record<string, unknown>;
/**
* Identifier creation timestamp
*/
createdAt?: number;
/**
* Last update timestamp
*/
updatedAt?: number;
/**
* Additional identifier metadata
*/
[key: string]: unknown;
};
}
/**
* Interface for a cryptographic key
*
* This interface defines the structure of a cryptographic key used in the
* DID system. It includes both public and private key information, along
* with metadata about the key's purpose and derivation.
*
* @example
* ```typescript
* const key: IKey = {
* id: 'did:ethr:0x123...#keys-1',
* type: 'Secp256k1VerificationKey2018',
* controller: 'did:ethr:0x123...',
* ethereumAddress: '0x123...',
* publicKeyHex: '0x...',
* privateKeyHex: '0x...',
* meta: {
* derivationPath: "m/44'/60'/0'/0/0"
* }
* };
* ```
*/
export interface IKey {
/**
* Unique identifier for the key
* @example 'did:ethr:0x123...#keys-1'
*/
id: string;
/**
* Key type specification
* @example 'Secp256k1VerificationKey2018'
*/
type: string;
/**
* DID that controls this key
* @example 'did:ethr:0x123...'
*/
controller: string;
/**
* Associated Ethereum address
* @example '0x123...'
*/
ethereumAddress: string;
/**
* Public key in hexadecimal format
* @example '0x...'
*/
publicKeyHex: string;
/**
* Private key in hexadecimal format
* @example '0x...'
*/
privateKeyHex: string;
/**
* Optional metadata about the key
*/
meta?: {
/**
* HD wallet derivation path
* @example "m/44'/60'/0'/0/0"
*/
derivationPath?: string;
/**
* Key usage or purpose
* @example "signing", "encryption"
*/
usage?: string;
/**
* Key creation timestamp
*/
createdAt?: number;
/**
* Additional key metadata
*/
[key: string]: unknown;
};
}
/**
* Interface for a service endpoint
*
* This interface defines the structure of a service endpoint that can be
* associated with a DID. Services provide additional functionality and
* endpoints for DID operations.
*
* @example
* ```typescript
* const service: IService = {
* id: 'endorser-service',
* type: 'EndorserService',
* serviceEndpoint: 'https://api.endorser.ch',
* description: 'Service for handling claims and endorsements',
* metadata: {
* version: '1.0.0',
* capabilities: ['claims', 'endorsements'],
* config: {
* apiServer: 'https://api.endorser.ch'
* }
* }
* };
* ```
*/
export interface IService {
/**
* Unique identifier for the service
* @example 'endorser-service'
*/
id: string;
/**
* Type of service
* @example 'EndorserService'
*/
type: string;
/**
* Service endpoint URL
* @example 'https://api.endorser.ch'
*/
serviceEndpoint: string;
/**
* Optional human-readable description
*/
description?: string;
/**
* Optional service metadata
*/
metadata?: {
/**
* Service version
* @example '1.0.0'
*/
version?: string;
/**
* Array of service capabilities
* @example ['claims', 'endorsements']
*/
capabilities?: string[];
/**
* Service-specific configuration
*/
config?: Record<string, unknown>;
};
}
export interface ExportProgress {
status: "preparing" | "exporting" | "complete" | "error";
message?: string;
error?: Error;
}
export interface UserProfile {
/** User's profile description */
description: string;
/** User's location information */
location?: {
/** Latitude coordinate */
lat: number;
/** Longitude coordinate */
lng: number;
};
/** User's given name */
givenName?: string;
/** User's family name */
familyName?: string;
}
export interface ErrorResponse {
error: string;
message: string;
statusCode: number;
}
export interface LeafletMouseEvent {
latlng: {
lat: number;
lng: number;
};
}
export interface GiveRecordWithContactInfo {
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
fulfillsType?: string;
handleId: string;
issuedAt: string;
issuerDid: string;
jwtId: string;
providerPlanHandleId?: string;
recipientDid: string;
unit: string;
giver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
issuer: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
receiver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
providerPlanName?: string;
recipientProjectName?: string;
image?: string;
}
export interface TimeSafariError extends Error {
/**
* User-friendly error message
*/
userMessage?: string;
/**
* Error code for programmatic handling
*/
code?: string;
/**
* Additional error context
*/
context?: Record<string, unknown>;
}

82
src/utils/logger.ts

@ -4,6 +4,32 @@ function safeStringify(obj: unknown) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
// Skip Vue component instance properties
if (
value &&
typeof value === "object" &&
("$el" in value || "$options" in value || "$parent" in value)
) {
return "[Vue Component]";
}
// Handle Vue router objects
if (
value &&
typeof value === "object" &&
("fullPath" in value || "path" in value || "name" in value)
) {
return {
fullPath: value.fullPath,
path: value.path,
name: value.name,
params: value.params,
query: value.query,
hash: value.hash,
};
}
// Handle circular references
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return "[Circular]";
@ -11,6 +37,7 @@ function safeStringify(obj: unknown) {
seen.add(value);
}
// Handle functions
if (typeof value === "function") {
return `[Function: ${value.name || "anonymous"}]`;
}
@ -19,28 +46,63 @@ function safeStringify(obj: unknown) {
});
}
function formatMessage(message: string, ...args: unknown[]): string {
const prefix = "[TimeSafari]";
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
return `${prefix} ${message}${argsString}`;
}
export const logger = {
log: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.log(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
console.log(formattedMessage);
logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
}
},
warn: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
console.warn(formattedMessage);
logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
}
},
error: (message: string, ...args: unknown[]) => {
// Errors will always be logged
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.error(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
console.error(formattedMessage);
logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
},
};
export function log(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.log(formattedMessage);
}
export function error(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.error(formattedMessage);
}
export function warn(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.warn(formattedMessage);
}
export function info(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.info(formattedMessage);
}
export function debug(message: string, ...args: unknown[]): void {
const formattedMessage = formatMessage(message, ...args);
// eslint-disable-next-line no-console
console.debug(formattedMessage);
}

2261
src/views/AccountViewView.vue

File diff suppressed because it is too large

47
src/views/ClaimCertificateView.vue

@ -5,6 +5,17 @@
<router-link :to="'/claim/' + claimId">
<canvas ref="claimCanvas" class="w-full block mx-auto"></canvas>
</router-link>
<div class="qr-code-container">
<QRCodeVue
ref="qrCodeRef"
:value="qrCodeData"
:size="200"
level="H"
render-as="svg"
:margin="0"
:color="{ dark: '#000000', light: '#ffffff' }"
/>
</div>
</div>
</div>
</section>
@ -13,13 +24,17 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "../constants/app";
import QRCodeVue from "qrcode.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as serverUtil from "../libs/endorserServer";
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
import { logger } from "../utils/logger";
@Component
@Component({
components: {
QRCodeVue,
},
})
export default class ClaimCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@ -31,6 +46,8 @@ export default class ClaimCertificateView extends Vue {
serverUtil = serverUtil;
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
@ -252,19 +269,23 @@ export default class ClaimCertificateView extends Vue {
);
// Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas");
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
await this.generateQRCode();
};
}
}
}
private async generateQRCode() {
if (!this.qrCodeRef) return;
const canvas = await this.qrCodeRef.toCanvas();
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Draw the QR code on the claim canvas
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
ctx.drawImage(canvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
}
}
</script>

114
src/views/ClaimReportCertificateView.vue

@ -2,6 +2,17 @@
<section id="Content">
<div v-if="claimData">
<canvas ref="claimCanvas"></canvas>
<div class="qr-code-container">
<QRCodeVue
ref="qrCodeRef"
:value="qrCodeData"
:size="200"
level="H"
render-as="svg"
:margin="0"
:color="{ dark: '#000000', light: '#ffffff' }"
/>
</div>
</div>
</section>
</template>
@ -9,13 +20,19 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue";
import QRCode from "qrcode";
import QRCodeVue from "qrcode.vue";
import { APP_SERVER, NotificationIface } from "../constants/app";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as endorserServer from "../libs/endorserServer";
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
import { logger } from "../utils/logger";
@Component
@Component({
components: {
QRCodeVue,
},
})
export default class ClaimReportCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@ -23,10 +40,14 @@ export default class ClaimReportCertificateView extends Vue {
allMyDids: Array<string> = [];
apiServer = "";
claimId = "";
claimData = null;
claimData: GenericCredWrapper<GenericVerifiableCredential> | null = null;
endorserServer = endorserServer;
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
private readonly CANVAS_WIDTH = 1100;
private readonly CANVAS_HEIGHT = 850;
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
@ -63,20 +84,12 @@ export default class ClaimReportCertificateView extends Vue {
}
}
async drawCanvas(
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>,
) {
async drawCanvas(claimData: GenericCredWrapper<GenericVerifiableCredential>) {
await db.open();
const allContacts = await db.contacts.toArray();
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) {
const CANVAS_WIDTH = 1100;
const CANVAS_HEIGHT = 850;
// size to approximate portrait of 8.5"x11"
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext("2d");
if (ctx) {
// Load the background image
@ -84,7 +97,13 @@ export default class ClaimReportCertificateView extends Vue {
backgroundImage.src = "/img/background/cert-frame-2.jpg";
backgroundImage.onload = async () => {
// Draw the background image
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.drawImage(
backgroundImage,
0,
0,
this.CANVAS_WIDTH,
this.CANVAS_HEIGHT,
);
// Set font and styles
ctx.fillStyle = "black";
@ -98,8 +117,8 @@ export default class ClaimReportCertificateView extends Vue {
const claimTypeWidth = ctx.measureText(claimTypeText).width;
ctx.fillText(
claimTypeText,
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.33,
(this.CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
this.CANVAS_HEIGHT * 0.33,
);
if (claimData.claim.agent) {
@ -108,8 +127,8 @@ export default class ClaimReportCertificateView extends Vue {
const presentedWidth = ctx.measureText(presentedText).width;
ctx.fillText(
presentedText,
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.37,
(this.CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
this.CANVAS_HEIGHT * 0.37,
);
const agentText = endorserServer.didInfoForCertificate(
claimData.claim.agent,
@ -119,8 +138,8 @@ export default class ClaimReportCertificateView extends Vue {
const agentWidth = ctx.measureText(agentText).width;
ctx.fillText(
agentText,
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
CANVAS_HEIGHT * 0.4,
(this.CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
this.CANVAS_HEIGHT * 0.4,
);
}
@ -135,8 +154,8 @@ export default class ClaimReportCertificateView extends Vue {
const descriptionWidth = ctx.measureText(descriptionLine).width;
ctx.fillText(
descriptionLine,
(CANVAS_WIDTH - descriptionWidth) / 2,
CANVAS_HEIGHT * 0.45,
(this.CANVAS_WIDTH - descriptionWidth) / 2,
this.CANVAS_HEIGHT * 0.45,
);
}
@ -149,33 +168,43 @@ export default class ClaimReportCertificateView extends Vue {
claimData.issuer,
allContacts,
);
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6);
ctx.fillText(
issuerText,
this.CANVAS_WIDTH * 0.3,
this.CANVAS_HEIGHT * 0.6,
);
}
// Draw claim ID
ctx.font = "14px Arial";
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7);
ctx.fillText(
this.claimId,
this.CANVAS_WIDTH * 0.3,
this.CANVAS_HEIGHT * 0.7,
);
ctx.fillText(
"via EndorserSearch.com",
CANVAS_WIDTH * 0.3,
CANVAS_HEIGHT * 0.73,
this.CANVAS_WIDTH * 0.3,
this.CANVAS_HEIGHT * 0.73,
);
// Generate and draw QR code
const qrCodeCanvas = document.createElement("canvas");
await QRCode.toCanvas(
qrCodeCanvas,
APP_SERVER + "/claim/" + this.claimId,
{
width: 150,
color: { light: "#0000" /* Transparent background */ },
},
);
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
await this.generateQRCode();
};
}
}
}
private async generateQRCode() {
if (!this.qrCodeRef) return;
const canvas = await this.qrCodeRef.toCanvas();
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Draw the QR code on the report canvas
ctx.drawImage(canvas, this.CANVAS_WIDTH * 0.6, this.CANVAS_HEIGHT * 0.55);
}
}
</script>
@ -186,5 +215,18 @@ canvas {
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.qr-code-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
}
</style>

2
src/views/ContactGiftingView.vue

@ -21,7 +21,7 @@
<h2 class="text-base flex gap-4 items-center">
<span class="grow">
<img
src="../assets/blank-square.svg"
src="@/assets/blank-square.svg"
width="32"
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
/>

4
src/views/HelpView.vue

@ -484,13 +484,13 @@
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
<span class="text-blue-500 mr-1">CC0 1.0</span>
<img
src="../assets/help/creative-commons-circle.svg"
src="@/assets/help/creative-commons-circle.svg"
alt="CC circle"
width="20"
class="display: inline"
/>
<img
src="../assets/help/creative-commons-zero.svg"
src="@/assets/help/creative-commons-zero.svg"
alt="CC zero"
width="20"
style="display: inline"

749
src/views/HomeView.vue

File diff suppressed because it is too large

2
src/views/ProjectViewView.vue

@ -220,7 +220,7 @@
</li>
<li @click="openGiftDialogToProject()">
<img
src="../assets/blank-square.svg"
src="@/assets/blank-square.svg"
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
/>
<h3

6
src/views/ProjectsView.vue

@ -279,11 +279,7 @@ import ProjectIcon from "../components/ProjectIcon.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import { Contact } from "../db/tables/contacts";
import {
didInfo,
getHeaders,
getPlanFromCache,
} from "../libs/endorserServer";
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
import * as libsUtil from "../libs/util";
import { OnboardPage } from "../libs/util";

51
test-playwright/00-noid-tests.spec.ts

@ -74,13 +74,30 @@ import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUt
test('Check activity feed - check that server is running', async ({ page }) => {
// Load app homepage
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
// Check that initial 10 activities have been loaded
// Wait for and dismiss onboarding dialog, with retry logic
const closeOnboarding = async () => {
const closeButton = page.getByTestId('closeOnboardingAndFinish');
if (await closeButton.isVisible()) {
await closeButton.click();
await expect(closeButton).toBeHidden();
}
};
// Initial dismissal
await closeOnboarding();
// Wait for network to be idle
await page.waitForLoadState('networkidle');
// Check and dismiss onboarding again if it reappeared
await closeOnboarding();
// Wait for initial feed items to load
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
// Scroll down a bit to trigger loading additional activities
await page.locator('ul#listLatestActivity li:nth-child(50)').scrollIntoViewIfNeeded();
await page.locator('ul#listLatestActivity li:nth-child(20)').scrollIntoViewIfNeeded();
});
test('Check discover results', async ({ page }) => {
@ -104,8 +121,11 @@ test('Check no-ID messaging in account', async ({ page }) => {
// Check 'a friend needs to register you' notice
await expect(page.locator('#noticeBeforeAnnounce')).toBeVisible();
// Check that there is no ID
await expect(page.locator('#sectionIdentityDetails code.truncate')).toBeEmpty();
// Check that there is no ID by finding the wrapper first
const didWrapper = page.locator('[data-testId="didWrapper"]');
await expect(didWrapper).toBeVisible();
const codeElement = didWrapper.locator('code[role="code"]');
await expect(codeElement).toBeEmpty();
});
test('Check ability to share contact', async ({ page }) => {
@ -169,7 +189,14 @@ test('Check setting name & sharing info', async ({ page }) => {
test('Confirm test API setting (may fail if you are running your own Time Safari)', async ({ page }, testInfo) => {
// Load account view
await page.goto('./account');
await page.getByRole('heading', { name: 'Advanced' }).click();
// Wait for and click the Advanced heading
const advancedHeading = page.getByRole('heading', { name: 'Advanced' });
await advancedHeading.waitFor({ state: 'visible' });
await advancedHeading.click();
// Wait for the Advanced section to be fully loaded
await page.waitForLoadState('networkidle');
// look into the config file: if it starts Time Safari, it might say which server it should set by default
const webServer = testInfo.config.webServer;
@ -178,8 +205,12 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
const endorserTerm = endorserWords?.find(word => word.startsWith(ENDORSER_ENV_NAME + '='));
const endorserTermInConfig = endorserTerm?.substring(ENDORSER_ENV_NAME.length + 1);
const endorserServer = endorserTermInConfig || 'https://test-api.endorser.ch';
await expect(page.getByRole('textbox').nth(1)).toHaveValue(endorserServer);
// Find the Claim Server input field using the label's for attribute
const serverInput = page.locator('input[type="text"]').first();
await serverInput.waitFor({ state: 'visible' });
const endorserServer = endorserTermInConfig || 'https://api.endorser.ch';
await expect(serverInput).toHaveValue(endorserServer);
});
test('Check User 0 can register a random person', async ({ page }) => {

3
tsconfig.json

@ -18,7 +18,8 @@
"@/db/*": ["db/*"],
"@/libs/*": ["libs/*"],
"@/constants/*": ["constants/*"],
"@/store/*": ["store/*"]
"@/store/*": ["store/*"],
"@/types/*": ["types/*"]
},
"lib": ["ES2020", "dom", "dom.iterable"], // Include typings for ES2020 and DOM APIs
},

46
vite.config.base.ts

@ -0,0 +1,46 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'),
stream: 'stream-browserify',
util: 'util',
crypto: 'crypto-browserify'
},
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
},
optimizeDeps: {
include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core'],
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
build: {
sourcemap: true,
target: 'esnext',
chunkSizeWarningLimit: 1000,
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true
},
rollupOptions: {
external: ['stream', 'util', 'crypto'],
output: {
globals: {
stream: 'stream',
util: 'util',
crypto: 'crypto'
}
}
}
}
});

12
vite.config.common.mts

@ -36,7 +36,15 @@ export async function createBuildConfig(mode: string) {
assetsDir: 'assets',
chunkSizeWarningLimit: 1000,
rollupOptions: {
external: isCapacitor ? ['@capacitor/app'] : []
external: isCapacitor ? ['@capacitor/app'] : [],
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.svg')) {
return 'assets/[name][extname]';
}
return 'assets/[name]-[hash][extname]';
}
}
}
},
define: {
@ -44,6 +52,8 @@ export async function createBuildConfig(mode: string) {
'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)),
__dirname: isElectron ? JSON.stringify(process.cwd()) : '""',
'process.env.VITE_ASSET_URL': JSON.stringify(isCapacitor ? './assets/' : '/assets/'),
'process.env.VITE_BASE_URL': JSON.stringify(isCapacitor ? './' : '/')
},
resolve: {
alias: {

156
vite.config.dev.mts.timestamp-1743747195916-245e61245d5ec.mjs

File diff suppressed because one or more lines are too long

28
vite.config.mobile.ts

@ -0,0 +1,28 @@
import { defineConfig, loadEnv } from "vite";
import baseConfig from "./vite.config.base";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
...baseConfig,
define: {
'import.meta.env.VITE_PLATFORM': JSON.stringify('mobile'),
},
build: {
...baseConfig.build,
outDir: 'dist/mobile',
rollupOptions: {
...baseConfig.build.rollupOptions,
output: {
...baseConfig.build.rollupOptions.output,
manualChunks: {
// Mobile-specific chunk splitting
vendor: ['vue', 'vue-router', 'pinia'],
capacitor: ['@capacitor/core', '@capacitor/filesystem', '@capacitor/share'],
}
}
}
}
};
});

60
vite.config.ts

@ -1,46 +1,18 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
import { defineConfig, loadEnv } from "vite";
import baseConfig from "./vite.config.base";
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'),
stream: 'stream-browserify',
util: 'util',
crypto: 'crypto-browserify'
},
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
},
optimizeDeps: {
include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core'],
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
build: {
sourcemap: true,
target: 'esnext',
chunkSizeWarningLimit: 1000,
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true
},
rollupOptions: {
external: ['stream', 'util', 'crypto'],
output: {
globals: {
stream: 'stream',
util: 'util',
crypto: 'crypto'
}
}
}
}
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
// Set the third parameter to '' to load all env regardless of the `VITE_` prefix.
const env = loadEnv(mode, process.cwd(), '');
const platform = env.PLATFORM || 'web';
// Load platform-specific config
const platformConfig = require(`./vite.config.${platform}`).default;
return {
...baseConfig,
...platformConfig,
};
});

113
vite.config.web.mts

@ -1,27 +1,92 @@
import { defineConfig, mergeConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { createBuildConfig } from "./vite.config.common.mts";
import { loadAppConfig } from "./vite.config.utils.mts";
/**
* @file vite.config.web.mts
* @description Vite configuration for web platform builds
*
* This configuration file defines how the application is built for web platforms.
* It extends the base configuration with web-specific settings and optimizations.
*
* Build Process Integration:
* 1. Configuration Loading:
* - Loads environment variables based on build mode
* - Merges base configuration from vite.config.common.mts
* - Loads application-specific configuration
*
* 2. Platform Definition:
* - Sets VITE_PLATFORM environment variable to 'web'
* - Used by PlatformServiceFactory to load web-specific implementations
*
* 3. Build Output:
* - Outputs to 'dist/web' directory
* - Creates vendor chunk for Vue-related dependencies
* - Enables PWA features with auto-update capability
*
* 4. Development vs Production:
* - Development: Enables source maps and development features
* - Production: Optimizes chunks and enables PWA features
*
* Usage:
* - Development: npm run dev
* - Production: npm run build:web
*
* @see vite.config.common.mts
* @see vite.config.utils.mts
* @see PlatformServiceFactory.ts
*/
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('web');
const appConfig = await loadAppConfig();
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import baseConfig from "./vite.config.base";
return mergeConfig(baseConfig, {
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: appConfig.pwaConfig?.manifest,
devOptions: {
enabled: false
},
workbox: {
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
sourcemap: true
}
})
]
});
// Define Node.js built-in modules that need browser compatibility
const nodeBuiltins = {
stream: 'stream-browserify',
util: 'util',
crypto: 'crypto-browserify',
http: 'stream-http',
https: 'https-browserify',
zlib: 'browserify-zlib',
url: 'url',
assert: 'assert',
path: 'path-browserify',
fs: 'browserify-fs',
tty: 'tty-browserify'
};
export default defineConfig({
...baseConfig,
plugins: [vue()],
optimizeDeps: {
...baseConfig.optimizeDeps,
include: [...(baseConfig.optimizeDeps?.include || []), 'qrcode.vue'],
exclude: Object.keys(nodeBuiltins),
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
resolve: {
...baseConfig.resolve,
alias: {
...baseConfig.resolve?.alias,
...nodeBuiltins
}
},
build: {
...baseConfig.build,
commonjsOptions: {
...baseConfig.build?.commonjsOptions,
include: [/node_modules/],
exclude: [/src\/services\/platforms\/electron/],
transformMixedEsModules: true
},
rollupOptions: {
...baseConfig.build?.rollupOptions,
external: Object.keys(nodeBuiltins),
output: {
...baseConfig.build?.rollupOptions?.output,
globals: nodeBuiltins
}
}
}
});

Loading…
Cancel
Save