Browse Source

Merge branch 'qrcode-capacitor' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-capacitor

qrcode-capacitor
Matthew Raymer 4 months ago
parent
commit
0c3a91e56d
  1. 32
      .eslintrc.js
  2. 57
      .eslintrc.json
  3. 55
      .eslintrc.mjs
  4. 672
      package-lock.json
  5. 22
      package.json
  6. 0
      postcss.config.cjs
  7. 266
      src/App.vue
  8. 76
      src/components/ActivityListItem.vue
  9. 90
      src/components/ChoiceButtonDialog.vue
  10. 40
      src/components/ContactNameDialog.vue
  11. 82
      src/components/DataExportSection.vue
  12. 38
      src/components/EntityIcon.vue
  13. 97
      src/components/FeedFilters.vue
  14. 265
      src/components/GiftedDialog.vue
  15. 154
      src/components/GiftedPrompts.vue
  16. 78
      src/components/HiddenDidDialog.vue
  17. 97
      src/components/ImageMethodDialog.vue
  18. 34
      src/components/ImageViewer.vue
  19. 47
      src/components/InfiniteScroll.vue
  20. 42
      src/components/InviteDialog.vue
  21. 336
      src/components/MembersList.vue
  22. 205
      src/components/OfferDialog.vue
  23. 64
      src/components/OnboardingDialog.vue
  24. 250
      src/components/PhotoDialog.vue
  25. 28
      src/components/ProjectIcon.vue
  26. 456
      src/components/PushNotificationPermission.vue
  27. 124
      src/components/QRScanner/CapacitorScanner.ts
  28. 206
      src/components/QRScanner/QRScannerDialog.vue
  29. 74
      src/components/QRScanner/WebDialogQRScanner.ts
  30. 38
      src/components/QRScanner/factory.ts
  31. 14
      src/components/QRScanner/types.ts
  32. 14
      src/components/QuickNav.vue
  33. 34
      src/components/TopMessage.vue
  34. 42
      src/components/UserNameDialog.vue
  35. 108
      src/components/World/World.js
  36. 14
      src/components/World/components/camera.js
  37. 14
      src/components/World/components/lights.js
  38. 194
      src/components/World/components/objects/landmarks.js
  39. 24
      src/components/World/components/objects/terrain.js
  40. 10
      src/components/World/components/scene.js
  41. 26
      src/components/World/systems/Loop.js
  42. 26
      src/components/World/systems/Resizer.js
  43. 24
      src/components/World/systems/controls.js
  44. 10
      src/components/World/systems/renderer.js
  45. 63
      src/constants/app.ts
  46. 174
      src/db/index.ts
  47. 22
      src/db/tables/accounts.ts
  48. 28
      src/db/tables/contacts.ts
  49. 8
      src/db/tables/logs.ts
  50. 10
      src/db/tables/secret.ts
  51. 86
      src/db/tables/settings.ts
  52. 10
      src/db/tables/temp.ts
  53. 166
      src/electron/main.js
  54. 58
      src/electron/preload.js
  55. 54
      src/interfaces/claims-result.ts
  56. 86
      src/interfaces/claims.ts
  57. 34
      src/interfaces/common.ts
  58. 4
      src/interfaces/deepLinks.ts
  59. 14
      src/interfaces/index.ts
  60. 18
      src/interfaces/limits.ts
  61. 106
      src/interfaces/records.ts
  62. 12
      src/interfaces/user.ts
  63. 36
      src/lib/capacitor/app.ts
  64. 12
      src/lib/fontawesome.ts
  65. 258
      src/libs/crypto/index.ts
  66. 32
      src/libs/crypto/vc/did-eth-local-resolver.ts
  67. 78
      src/libs/crypto/vc/didPeer.ts
  68. 166
      src/libs/crypto/vc/index.ts
  69. 415
      src/libs/crypto/vc/passkeyDidPeer.ts
  70. 64
      src/libs/crypto/vc/passkeyHelpers.ts
  71. 12
      src/libs/crypto/vc/util.ts
  72. 863
      src/libs/endorserServer.ts
  73. 14
      src/libs/partnerServer.ts
  74. 510
      src/libs/util.ts
  75. 4
      src/libs/veramo/setup.ts
  76. 52
      src/main.capacitor.ts
  77. 76
      src/main.common.ts
  78. 6
      src/main.electron.ts
  79. 6
      src/main.pywebview.ts
  80. 68
      src/main.ts
  81. 8
      src/main.web.ts
  82. 30
      src/registerServiceWorker.ts
  83. 364
      src/router/index.ts
  84. 38
      src/services/PlatformService.ts
  85. 42
      src/services/PlatformServiceFactory.ts
  86. 118
      src/services/QRScanner/CapacitorQRScanner.ts
  87. 72
      src/services/QRScanner/NativeQRScanner.ts
  88. 37
      src/services/QRScanner/QRScannerFactory.ts
  89. 90
      src/services/QRScanner/WebDialogQRScanner.ts
  90. 142
      src/services/QRScanner/WebQRScanner.ts
  91. 36
      src/services/QRScanner/types.ts
  92. 20
      src/services/api.ts
  93. 136
      src/services/deepLinks.ts
  94. 68
      src/services/plan.ts
  95. 348
      src/services/platforms/CapacitorPlatformService.ts
  96. 30
      src/services/platforms/ElectronPlatformService.ts
  97. 30
      src/services/platforms/PyWebViewPlatformService.ts
  98. 116
      src/services/platforms/WebPlatformService.ts
  99. 64
      src/test/index.ts
  100. 88
      src/types/deepLinks.ts

32
.eslintrc.js

@ -1,32 +0,0 @@
module.exports = {
root: true,
env: {
node: true,
es2022: true,
},
extends: [
"plugin:vue/vue3-recommended",
"eslint:recommended",
"@vue/typescript/recommended",
"plugin:prettier/recommended"
],
// parserOptions: {
// ecmaVersion: 2020,
// },
rules: {
"max-len": ["warn", {
code: 100,
ignoreComments: true,
ignorePattern: '^\\s*class="[^"]*"$',
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreUrls: true,
}],
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
"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-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
},
};

57
.eslintrc.json

@ -0,0 +1,57 @@
{
"root": true,
"env": {
"node": true,
"browser": true,
"es2022": true
},
"extends": [
"plugin:vue/vue3-recommended",
"eslint:recommended",
"@vue/typescript/recommended",
"plugin:prettier/recommended"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2022,
"sourceType": "module",
"extraFileExtensions": [".vue"],
"ecmaFeatures": {
"jsx": true
}
},
"plugins": [
"@typescript-eslint",
"vue",
"prettier"
],
"rules": {
"no-console": "warn",
"no-debugger": "warn",
"@typescript-eslint/no-explicit-any": "off",
"vue/multi-word-component-names": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-unnecessary-type-constraint": "off",
"vue/no-parsing-error": ["error", {
"x-invalid-end-tag": false,
"invalid-first-character-of-tag-name": false
}],
"vue/no-v-html": "warn",
"prettier/prettier": ["error", {
"singleQuote": true,
"semi": false,
"trailingComma": "none"
}]
},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.mts"],
"parser": "@typescript-eslint/parser"
},
{
"files": ["*.js", "*.jsx", "*.mjs"],
"parser": "@typescript-eslint/parser"
}
]
}

55
.eslintrc.mjs

@ -0,0 +1,55 @@
export default {
root: true,
env: {
node: true,
browser: true,
es2022: true
},
extends: [
'plugin:vue/vue3-recommended',
'eslint:recommended',
'@vue/typescript/recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: {
'ts': '@typescript-eslint/parser',
'js': '@typescript-eslint/parser',
'<template>': 'espree'
},
ecmaVersion: 2022,
sourceType: 'module',
extraFileExtensions: ['.vue'],
ecmaFeatures: {
jsx: true
}
},
plugins: [
'@typescript-eslint',
'vue'
],
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'@typescript-eslint/no-explicit-any': 'off',
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }]
},
overrides: [
{
files: ['*.ts', '*.tsx', '*.mts'],
parser: '@typescript-eslint/parser'
},
{
files: ['*.js', '*.jsx', '*.mjs'],
parser: '@typescript-eslint/parser'
}
],
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue', '.mjs', '.mts']
}
}
}
};

672
package-lock.json

@ -45,8 +45,10 @@
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2", "asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"buffer": "^6.0.3",
"cbor-x": "^1.5.9", "cbor-x": "^1.5.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"crypto-browserify": "^3.12.1",
"dexie": "^3.2.7", "dexie": "^3.2.7",
"dexie-export-import": "^4.1.4", "dexie-export-import": "^4.1.4",
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
@ -63,7 +65,7 @@
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.12.0",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",
@ -105,21 +107,23 @@
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^12.0.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^8.57.0", "eslint": "^8.54.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-import-resolver-node": "^0.3.9",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-vue": "^9.33.0",
"espree": "^10.3.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"markdownlint": "^0.37.4", "markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0", "markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13", "npm-check-updates": "^17.1.13",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.5.3",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
@ -4625,6 +4629,24 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": { "node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -10745,15 +10767,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vue/eslint-config-typescript": { "node_modules/@vue/eslint-config-typescript": {
"version": "11.0.3", "version": "12.0.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-11.0.3.tgz", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-12.0.0.tgz",
"integrity": "sha512-dkt6W0PX6H/4Xuxg/BlFj5xHvksjpSlVjtkQCpaYJBIEuKj2hOVU7r+TIe+ysCwRYFz/lGqvklntRkCAibsbPw==", "integrity": "sha512-StxLFet2Qe97T8+7L8pGlhYBBr8Eg05LPuTDVopQV6il+SK6qqom59BA/rcFipUef2jD8P2X44Vd8tMFytfvlg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/eslint-plugin": "^6.7.0",
"@typescript-eslint/parser": "^5.59.1", "@typescript-eslint/parser": "^6.7.0",
"vue-eslint-parser": "^9.1.1" "vue-eslint-parser": "^9.3.1"
}, },
"engines": { "engines": {
"node": "^14.17.0 || >=16.0.0" "node": "^14.17.0 || >=16.0.0"
@ -10769,226 +10791,6 @@
} }
} }
}, },
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz",
"integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.4.0",
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/type-utils": "5.62.0",
"@typescript-eslint/utils": "5.62.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.0",
"natural-compare-lite": "^1.4.0",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^5.0.0",
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/parser": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz",
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/typescript-estree": "5.62.0",
"debug": "^4.3.4"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/scope-manager": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz",
"integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/type-utils": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz",
"integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "5.62.0",
"@typescript-eslint/utils": "5.62.0",
"debug": "^4.3.4",
"tsutils": "^3.21.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/types": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz",
"integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/typescript-estree": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz",
"integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/visitor-keys": "5.62.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"semver": "^7.3.7",
"tsutils": "^3.21.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/utils": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz",
"integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@types/json-schema": "^7.0.9",
"@types/semver": "^7.3.12",
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
"@typescript-eslint/typescript-estree": "5.62.0",
"eslint-scope": "^5.1.1",
"semver": "^7.3.7"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/@typescript-eslint/visitor-keys": {
"version": "5.62.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz",
"integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "5.62.0",
"eslint-visitor-keys": "^3.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/eslint-scope": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@vue/eslint-config-typescript/node_modules/estraverse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.13", "version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz",
@ -12558,6 +12360,119 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/browserify-cipher": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
"integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
"license": "MIT",
"dependencies": {
"browserify-aes": "^1.0.4",
"browserify-des": "^1.0.0",
"evp_bytestokey": "^1.0.0"
}
},
"node_modules/browserify-des": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
"integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
"license": "MIT",
"dependencies": {
"cipher-base": "^1.0.1",
"des.js": "^1.0.0",
"inherits": "^2.0.1",
"safe-buffer": "^5.1.2"
}
},
"node_modules/browserify-rsa": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.1.tgz",
"integrity": "sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==",
"license": "MIT",
"dependencies": {
"bn.js": "^5.2.1",
"randombytes": "^2.1.0",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/browserify-sign": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.3.tgz",
"integrity": "sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw==",
"license": "ISC",
"dependencies": {
"bn.js": "^5.2.1",
"browserify-rsa": "^4.1.0",
"create-hash": "^1.2.0",
"create-hmac": "^1.1.7",
"elliptic": "^6.5.5",
"hash-base": "~3.0",
"inherits": "^2.0.4",
"parse-asn1": "^5.1.7",
"readable-stream": "^2.3.8",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/browserify-sign/node_modules/hash-base": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
"integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/browserify-sign/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/browserify-sign/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/browserify-sign/node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/browserify-sign/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/browserify-sign/node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.24.4", "version": "4.24.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
@ -14091,7 +14006,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
@ -14253,6 +14167,22 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/create-ecdh": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
"integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.1.0",
"elliptic": "^6.5.3"
}
},
"node_modules/create-ecdh/node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
"license": "MIT"
},
"node_modules/create-hash": { "node_modules/create-hash": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
@ -14379,6 +14309,45 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/crypto-browserify": {
"version": "3.12.1",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz",
"integrity": "sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==",
"license": "MIT",
"dependencies": {
"browserify-cipher": "^1.0.1",
"browserify-sign": "^4.2.3",
"create-ecdh": "^4.0.4",
"create-hash": "^1.2.0",
"create-hmac": "^1.1.7",
"diffie-hellman": "^5.0.3",
"hash-base": "~3.0.4",
"inherits": "^2.0.4",
"pbkdf2": "^3.1.2",
"public-encrypt": "^4.0.3",
"randombytes": "^2.1.0",
"randomfill": "^1.0.4"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/crypto-browserify/node_modules/hash-base": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
"integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/crypto-js": { "node_modules/crypto-js": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
@ -14841,6 +14810,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/des.js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz",
"integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/destroy": { "node_modules/destroy": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@ -14981,6 +14960,23 @@
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/diffie-hellman": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.1.0",
"miller-rabin": "^4.0.0",
"randombytes": "^2.0.0"
}
},
"node_modules/diffie-hellman/node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
"license": "MIT"
},
"node_modules/dijkstrajs": { "node_modules/dijkstrajs": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@ -15832,6 +15828,28 @@
"eslint": ">=7.0.0" "eslint": ">=7.0.0"
} }
}, },
"node_modules/eslint-import-resolver-node": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
"integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^3.2.7",
"is-core-module": "^2.13.0",
"resolve": "^1.22.4"
}
},
"node_modules/eslint-import-resolver-node/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-prettier": { "node_modules/eslint-plugin-prettier": {
"version": "5.2.6", "version": "5.2.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz",
@ -15927,6 +15945,24 @@
"concat-map": "0.0.1" "concat-map": "0.0.1"
} }
}, },
"node_modules/eslint/node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/minimatch": { "node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -15951,18 +15987,31 @@
} }
}, },
"node_modules/espree": { "node_modules/espree": {
"version": "9.6.1", "version": "10.3.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"acorn": "^8.9.0", "acorn": "^8.14.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1" "eslint-visitor-keys": "^4.2.0"
}, },
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/espree/node_modules/eslint-visitor-keys": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
@ -22576,6 +22625,25 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
"integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"brorand": "^1.0.1"
},
"bin": {
"miller-rabin": "bin/miller-rabin"
}
},
"node_modules/miller-rabin/node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
"license": "MIT"
},
"node_modules/mime": { "node_modules/mime": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
@ -23019,13 +23087,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/natural-compare-lite": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz",
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -23924,6 +23985,53 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse-asn1": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.7.tgz",
"integrity": "sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg==",
"license": "ISC",
"dependencies": {
"asn1.js": "^4.10.1",
"browserify-aes": "^1.2.0",
"evp_bytestokey": "^1.0.3",
"hash-base": "~3.0",
"pbkdf2": "^3.1.2",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/parse-asn1/node_modules/asn1.js": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
"integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/parse-asn1/node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
"license": "MIT"
},
"node_modules/parse-asn1/node_modules/hash-base": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.5.tgz",
"integrity": "sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.4",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/parse-entities": { "node_modules/parse-entities": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
@ -24619,7 +24727,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/progress": { "node_modules/progress": {
@ -24718,6 +24825,26 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/public-encrypt": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
"integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.1.0",
"browserify-rsa": "^4.0.0",
"create-hash": "^1.1.0",
"parse-asn1": "^5.0.0",
"randombytes": "^2.0.1",
"safe-buffer": "^5.1.2"
}
},
"node_modules/public-encrypt/node_modules/bn.js": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.1.tgz",
"integrity": "sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==",
"license": "MIT"
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@ -25073,6 +25200,16 @@
"safe-buffer": "^5.1.0" "safe-buffer": "^5.1.0"
} }
}, },
"node_modules/randomfill": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
"integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
"license": "MIT",
"dependencies": {
"randombytes": "^2.0.5",
"safe-buffer": "^5.1.0"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -29109,29 +29246,6 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsutils": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
"integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^1.8.1"
},
"engines": {
"node": ">= 6"
},
"peerDependencies": {
"typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
}
},
"node_modules/tsutils/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@ -30096,6 +30210,24 @@
"eslint": ">=6.0.0" "eslint": ">=6.0.0"
} }
}, },
"node_modules/vue-eslint-parser/node_modules/espree": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
"integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.9.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-facing-decorator": { "node_modules/vue-facing-decorator": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/vue-facing-decorator/-/vue-facing-decorator-3.0.4.tgz", "resolved": "https://registry.npmjs.org/vue-facing-decorator/-/vue-facing-decorator-3.0.4.tgz",

22
package.json

@ -2,18 +2,20 @@
"name": "timesafari", "name": "timesafari",
"version": "0.4.4", "version": "0.4.4",
"description": "Time Safari Application", "description": "Time Safari Application",
"type": "module",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:web": "vite --config vite.config.web.mts",
"serve": "vite preview", "serve": "vite preview",
"build": "vite build", "build": "vite build",
"build:mobile": "VITE_PLATFORM=capacitor vite build", "build:mobile": "VITE_PLATFORM=capacitor vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", "lint": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' eslint --ext .vue,.js,.jsx,.mjs,.ts,.tsx,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/", "format": "prettier --write src/",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src", "lint-fix": "NODE_OPTIONS='--experimental-vm-modules --experimental-specifier-resolution=node' eslint \"src/**/*.{vue,js,jsx,ts,tsx,mjs,mts}\" --fix",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js", "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile", "test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:prerequisites": "node scripts/check-prerequisites.js", "test:prerequisites": "node scripts/check-prerequisites.js",
@ -84,8 +86,10 @@
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"asn1-ber": "^1.2.2", "asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"buffer": "^6.0.3",
"cbor-x": "^1.5.9", "cbor-x": "^1.5.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"crypto-browserify": "^3.12.1",
"dexie": "^3.2.7", "dexie": "^3.2.7",
"dexie-export-import": "^4.1.4", "dexie-export-import": "^4.1.4",
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
@ -102,7 +106,7 @@
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.12.0",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",
@ -144,21 +148,23 @@
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^12.0.0",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^8.57.0", "eslint": "^8.54.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1", "eslint-import-resolver-node": "^0.3.9",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-vue": "^9.33.0",
"espree": "^10.3.0",
"fs-extra": "^11.3.0", "fs-extra": "^11.3.0",
"markdownlint": "^0.37.4", "markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0", "markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13", "npm-check-updates": "^17.1.13",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.5.3",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",

0
postcss.config.js → postcss.config.cjs

266
src/App.vue

@ -29,7 +29,9 @@
> >
<div class="w-full px-4 py-3"> <div class="w-full px-4 py-3">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ notification.text }}</p> <p class="text-sm">
{{ notification.text }}
</p>
</div> </div>
</div> </div>
@ -40,21 +42,20 @@
<div <div
class="flex items-center justify-center w-12 bg-slate-600 text-slate-100" class="flex items-center justify-center w-12 bg-slate-600 text-slate-100"
> >
<font-awesome <font-awesome icon="circle-info" class="fa-fw fa-xl" />
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-slate-900"> <div class="relative w-full pl-4 pr-8 py-2 text-slate-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p> <p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-slate-200 text-slate-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw"></font-awesome> <font-awesome icon="xmark" class="fa-fw" />
</button> </button>
</div> </div>
</div> </div>
@ -66,21 +67,20 @@
<div <div
class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100" class="flex items-center justify-center w-12 bg-emerald-600 text-emerald-100"
> >
<font-awesome <font-awesome icon="circle-info" class="fa-fw fa-xl" />
icon="circle-info"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-emerald-900"> <div class="relative w-full pl-4 pr-8 py-2 text-emerald-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p> <p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-emerald-200 text-emerald-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw"></font-awesome> <font-awesome icon="xmark" class="fa-fw" />
</button> </button>
</div> </div>
</div> </div>
@ -92,21 +92,20 @@
<div <div
class="flex items-center justify-center w-12 bg-amber-600 text-amber-100" class="flex items-center justify-center w-12 bg-amber-600 text-amber-100"
> >
<font-awesome <font-awesome icon="triangle-exclamation" class="fa-fw fa-xl" />
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-amber-900"> <div class="relative w-full pl-4 pr-8 py-2 text-amber-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p> <p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-amber-200 text-amber-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw"></font-awesome> <font-awesome icon="xmark" class="fa-fw" />
</button> </button>
</div> </div>
</div> </div>
@ -118,21 +117,20 @@
<div <div
class="flex items-center justify-center w-12 bg-rose-600 text-rose-100" class="flex items-center justify-center w-12 bg-rose-600 text-rose-100"
> >
<font-awesome <font-awesome icon="triangle-exclamation" class="fa-fw fa-xl" />
icon="triangle-exclamation"
class="fa-fw fa-xl"
></font-awesome>
</div> </div>
<div class="relative w-full pl-4 pr-8 py-2 text-rose-900"> <div class="relative w-full pl-4 pr-8 py-2 text-rose-900">
<span class="font-semibold">{{ notification.title }}</span> <span class="font-semibold">{{ notification.title }}</span>
<p class="text-sm">{{ truncateLongWords(notification.text) }}</p> <p class="text-sm">
{{ truncateLongWords(notification.text) }}
</p>
<button <button
class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600" class="absolute top-2 right-2 px-0.5 py-0 rounded-full bg-rose-200 text-rose-600"
@click="close(notification.id)" @click="close(notification.id)"
> >
<font-awesome icon="xmark" class="fa-fw"></font-awesome> <font-awesome icon="xmark" class="fa-fw" />
</button> </button>
</div> </div>
</div> </div>
@ -183,31 +181,33 @@
<span class="font-semibold text-lg"> <span class="font-semibold text-lg">
{{ notification.title }} {{ notification.title }}
</span> </span>
<p class="text-sm mb-2">{{ notification.text }}</p> <p class="text-sm mb-2">
{{ notification.text }}
</p>
<button <button
v-if="notification.onYes" v-if="notification.onYes"
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2" class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white px-2 py-2 rounded-md mb-2"
@click=" @click="{
notification.onYes(); notification.onYes();
close(notification.id); close(notification.id);
" }"
> >
Yes{{ Yes{{
notification.yesText ? ", " + notification.yesText : "" notification.yesText ? ', ' + notification.yesText : ''
}} }}
</button> </button>
<button <button
v-if="notification.onNo" v-if="notification.onNo"
class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2" class="block w-full text-center text-md font-bold uppercase bg-yellow-600 text-white px-2 py-2 rounded-md mb-2"
@click=" @click="{
notification.onNo(stopAsking); notification.onNo(stopAsking);
close(notification.id); close(notification.id);
stopAsking = false; // reset value stopAsking = false; // reset value
" }"
> >
No{{ notification.noText ? ", " + notification.noText : "" }} No{{ notification.noText ? ', ' + notification.noText : '' }}
</button> </button>
<label <label
@ -228,25 +228,25 @@
class="sr-only" class="sr-only"
/> />
<!-- line --> <!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <div class="block bg-slate-500 w-14 h-8 rounded-full" />
<!-- dot --> <!-- dot -->
<div <div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div> />
</div> </div>
</label> </label>
<button <button
class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md" class="block w-full text-center text-md font-bold uppercase bg-slate-600 text-white px-2 py-2 rounded-md"
@click=" @click="{
notification.onCancel notification.onCancel
? notification.onCancel(stopAsking) ? notification.onCancel(stopAsking)
: null; : null;
close(notification.id); close(notification.id);
stopAsking = false; // reset value for next time they open this modal stopAsking = false; // reset value for next time they open this modal
" }"
> >
{{ notification.onYes ? "Cancel" : "Close" }} {{ notification.onYes ? 'Cancel' : 'Close' }}
</button> </button>
</div> </div>
</div> </div>
@ -306,10 +306,10 @@
<button <button
class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2" class="block w-full text-center text-md font-bold uppercase bg-rose-600 text-white px-2 py-2 rounded-md mb-2"
@click=" @click="{
close(notification.id); close(notification.id);
turnOffNotifications(notification); turnOffNotifications(notification);
" }"
> >
Turn Off Notification Turn Off Notification
</button> </button>
@ -329,21 +329,21 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from 'vue-facing-decorator'
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index"; import { logConsoleAndDb, retrieveSettingsForActiveAccount } from './db/index'
import { NotificationIface } from "./constants/app"; import { NotificationIface } from './constants/app'
import { logger } from "./utils/logger"; import { logger } from './utils/logger'
interface Settings { interface Settings {
notifyingNewActivityTime?: string; notifyingNewActivityTime?: string
notifyingReminderTime?: string; notifyingReminderTime?: string
} }
@Component @Component
export default class App extends Vue { export default class App extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
stopAsking = false; stopAsking = false
// created() { // created() {
// logger.log( // logger.log(
@ -382,158 +382,158 @@ export default class App extends Vue {
truncateLongWords(sentence: string) { truncateLongWords(sentence: string) {
return sentence return sentence
.split(" ") .split(' ')
.map((word) => (word.length > 30 ? word.slice(0, 30) + "..." : word)) .map((word) => (word.length > 30 ? word.slice(0, 30) + '...' : word))
.join(" "); .join(' ')
} }
async turnOffNotifications( async turnOffNotifications(
notification: NotificationIface, notification: NotificationIface
): Promise<boolean> { ): Promise<boolean> {
logger.log("Starting turnOffNotifications..."); logger.log('Starting turnOffNotifications...')
let subscription: PushSubscriptionJSON | null = null; let subscription: PushSubscriptionJSON | null = null
let allGoingOff = false; let allGoingOff = false
try { try {
logger.log("Retrieving settings for the active account..."); logger.log('Retrieving settings for the active account...')
const settings: Settings = await retrieveSettingsForActiveAccount(); const settings: Settings = await retrieveSettingsForActiveAccount()
logger.log("Retrieved settings:", settings); logger.log('Retrieved settings:', settings)
const notifyingNewActivity = !!settings?.notifyingNewActivityTime; const notifyingNewActivity = !!settings?.notifyingNewActivityTime
const notifyingReminder = !!settings?.notifyingReminderTime; const notifyingReminder = !!settings?.notifyingReminderTime
if (!notifyingNewActivity || !notifyingReminder) { if (!notifyingNewActivity || !notifyingReminder) {
allGoingOff = true; allGoingOff = true
logger.log("Both notifications are being turned off."); logger.log('Both notifications are being turned off.')
} }
logger.log("Checking service worker readiness..."); logger.log('Checking service worker readiness...')
await navigator.serviceWorker?.ready await navigator.serviceWorker?.ready
.then((registration) => { .then((registration) => {
logger.log("Service worker is ready. Fetching subscription..."); logger.log('Service worker is ready. Fetching subscription...')
return registration.pushManager.getSubscription(); return registration.pushManager.getSubscription()
}) })
.then(async (subscript: PushSubscription | null) => { .then(async (subscript: PushSubscription | null) => {
if (subscript) { if (subscript) {
subscription = subscript.toJSON(); subscription = subscript.toJSON()
logger.log("PushSubscription retrieved:", subscription); logger.log('PushSubscription retrieved:', subscription)
if (allGoingOff) { if (allGoingOff) {
logger.log("Unsubscribing from push notifications..."); logger.log('Unsubscribing from push notifications...')
await subscript.unsubscribe(); await subscript.unsubscribe()
logger.log("Successfully unsubscribed."); logger.log('Successfully unsubscribed.')
} }
} else { } else {
logConsoleAndDb("Subscription object is not available."); logConsoleAndDb('Subscription object is not available.')
logger.log("No subscription found."); logger.log('No subscription found.')
} }
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
"Push provider server communication failed: " + 'Push provider server communication failed: ' +
JSON.stringify(error), JSON.stringify(error),
true, true
); )
logger.error("Error during subscription fetch:", error); logger.error('Error during subscription fetch:', error)
}); })
if (!subscription) { if (!subscription) {
logger.log("No subscription available. Notifying user..."); logger.log('No subscription available. Notifying user...')
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Finished", title: 'Finished',
text: "Notifications are off.", text: 'Notifications are off.'
}, },
5000, 5000
); )
logger.log("Exiting as there is no subscription to process."); logger.log('Exiting as there is no subscription to process.')
return true; return true
} }
const serverSubscription = { const serverSubscription = {
...subscription, ...subscription
}; }
if (!allGoingOff) { if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title; serverSubscription['notifyType'] = notification.title
logger.log( logger.log(
`Server subscription updated with notifyType: ${notification.title}`, `Server subscription updated with notifyType: ${notification.title}`
); )
} }
logger.log("Sending unsubscribe request to the server..."); logger.log('Sending unsubscribe request to the server...')
const pushServerSuccess = await fetch("/web-push/unsubscribe", { const pushServerSuccess = await fetch('/web-push/unsubscribe', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, },
body: JSON.stringify(serverSubscription), body: JSON.stringify(serverSubscription)
}) })
.then(async (response) => { .then(async (response) => {
if (!response.ok) { if (!response.ok) {
const errorBody = await response.text(); const errorBody = await response.text()
logConsoleAndDb( logConsoleAndDb(
`Push server failed: ${response.status} ${errorBody}`, `Push server failed: ${response.status} ${errorBody}`,
true, true
); )
logger.error("Push server error response:", errorBody); logger.error('Push server error response:', errorBody)
} }
logger.log(`Server response status: ${response.status}`); logger.log(`Server response status: ${response.status}`)
return response.ok; return response.ok
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
"Push server communication failed: " + JSON.stringify(error), 'Push server communication failed: ' + JSON.stringify(error),
true, true
); )
logger.error("Error during server communication:", error); logger.error('Error during server communication:', error)
return false; return false
}); })
const message = pushServerSuccess const message = pushServerSuccess
? "Notification is off." ? 'Notification is off.'
: "Notification is still on. Try to turn it off again."; : 'Notification is still on. Try to turn it off again.'
logger.log("Server response processed. Message:", message); logger.log('Server response processed. Message:', message)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Finished", title: 'Finished',
text: message, text: message
}, },
5000, 5000
); )
if (notification.callback) { if (notification.callback) {
logger.log("Executing notification callback..."); logger.log('Executing notification callback...')
notification.callback(pushServerSuccess); notification.callback(pushServerSuccess)
} }
logger.log( logger.log(
"Completed turnOffNotifications with success:", 'Completed turnOffNotifications with success:',
pushServerSuccess, pushServerSuccess
); )
return pushServerSuccess; return pushServerSuccess
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
"Error turning off notifications: " + JSON.stringify(error), 'Error turning off notifications: ' + JSON.stringify(error),
true, true
); )
logger.error("Critical error in turnOffNotifications:", error); logger.error('Critical error in turnOffNotifications:', error)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "error", type: 'error',
title: "Error", title: 'Error',
text: "Failed to turn off notifications. Please try again.", text: 'Failed to turn off notifications. Please try again.'
}, },
5000, 5000
); )
return false; return false
} }
} }
} }

76
src/components/ActivityListItem.vue

@ -29,7 +29,7 @@
<div> <div>
<h3 class="font-semibold"> <h3 class="font-semibold">
{{ record.issuer.known ? record.issuer.displayName : "" }} {{ record.issuer.known ? record.issuer.displayName : '' }}
</h3> </h3>
<p class="ms-auto text-xs text-slate-500 italic"> <p class="ms-auto text-xs text-slate-500 italic">
{{ friendlyDate }} {{ friendlyDate }}
@ -123,7 +123,7 @@
<div <div
class="shrink-0 w-0 h-0 border border-slate-300 border-t-[20px] sm:border-t-[25px] border-t-transparent border-b-[20px] sm:border-b-[25px] border-b-transparent border-s-[27px] sm:border-s-[34px] border-e-0" class="shrink-0 w-0 h-0 border border-slate-300 border-t-[20px] sm:border-t-[25px] border-t-transparent border-b-[20px] sm:border-b-[25px] border-b-transparent border-s-[27px] sm:border-s-[34px] border-e-0"
></div> />
</div> </div>
</div> </div>
@ -182,59 +182,59 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from 'vue-facing-decorator'
import { GiveRecordWithContactInfo } from "../types"; import { GiveRecordWithContactInfo } from '../types'
import EntityIcon from "./EntityIcon.vue"; import EntityIcon from './EntityIcon.vue'
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util"; import { isGiveClaimType, notifyWhyCannotConfirm } from '../libs/util'
import { containsHiddenDid } from "../libs/endorserServer"; import { containsHiddenDid } from '../libs/endorserServer'
import ProjectIcon from "./ProjectIcon.vue"; import ProjectIcon from './ProjectIcon.vue'
@Component({ @Component({
components: { components: {
EntityIcon, EntityIcon,
ProjectIcon, ProjectIcon
}, }
}) })
export default class ActivityListItem extends Vue { export default class ActivityListItem extends Vue {
@Prop() record!: GiveRecordWithContactInfo; @Prop() record!: GiveRecordWithContactInfo
@Prop() lastViewedClaimId?: string; @Prop() lastViewedClaimId?: string
@Prop() isRegistered!: boolean; @Prop() isRegistered!: boolean
@Prop() activeDid!: string; @Prop() activeDid!: string
@Prop() confirmerIdList?: string[]; @Prop() confirmerIdList?: string[]
get fetchAmount(): string { get fetchAmount(): string {
const claim = const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim; (this.record.fullClaim as unknown).claim || this.record.fullClaim
const amount = claim.object?.amountOfThisGood const amount = claim.object?.amountOfThisGood
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood) ? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
: ""; : ''
return amount; return amount
} }
get description(): string { get description(): string {
const claim = const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim; (this.record.fullClaim as unknown).claim || this.record.fullClaim
return `${claim.description}`; return `${claim.description}`
} }
private displayAmount(code: string, amt: number) { private displayAmount(code: string, amt: number) {
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`; return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`
} }
private currencyShortWordForCode(unitCode: string, single: boolean) { private currencyShortWordForCode(unitCode: string, single: boolean) {
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode; return unitCode === 'HUR' ? (single ? 'hour' : 'hours') : unitCode
} }
get canConfirm(): boolean { get canConfirm(): boolean {
if (!this.isRegistered) return false; if (!this.isRegistered) return false
if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false; if (!isGiveClaimType(this.record.fullClaim?.['@type'])) return false
if (this.confirmerIdList?.includes(this.activeDid)) return false; if (this.confirmerIdList?.includes(this.activeDid)) return false
if (this.record.issuerDid === this.activeDid) return false; if (this.record.issuerDid === this.activeDid) return false
if (containsHiddenDid(this.record.fullClaim)) return false; if (containsHiddenDid(this.record.fullClaim)) return false
return true; return true
} }
handleConfirmClick() { handleConfirmClick() {
@ -242,24 +242,24 @@ export default class ActivityListItem extends Vue {
notifyWhyCannotConfirm( notifyWhyCannotConfirm(
this.$notify, this.$notify,
this.isRegistered, this.isRegistered,
this.record.fullClaim?.["@type"], this.record.fullClaim?.['@type'],
this.record, this.record,
this.activeDid, this.activeDid,
this.confirmerIdList, this.confirmerIdList
); )
return; return
} }
this.$emit("confirmClaim", this.record); this.$emit('confirmClaim', this.record)
} }
get friendlyDate(): string { get friendlyDate(): string {
const date = new Date(this.record.issuedAt); const date = new Date(this.record.issuedAt)
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {
year: "numeric", year: 'numeric',
month: "short", month: 'short',
day: "numeric", day: 'numeric'
}); })
} }
} }
</script> </script>

90
src/components/ChoiceButtonDialog.vue

@ -26,7 +26,9 @@
> >
<div class="w-full px-6 py-6 text-slate-900 text-center"> <div class="w-full px-6 py-6 text-slate-900 text-center">
<span class="font-semibold text-lg">{{ title }}</span> <span class="font-semibold text-lg">{{ title }}</span>
<p class="text-sm mb-2">{{ text }}</p> <p class="text-sm mb-2">
{{ text }}
</p>
<button <button
class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2" class="block w-full text-center text-md font-bold capitalize bg-blue-800 text-white px-2 py-2 rounded-md mb-2"
@ -65,48 +67,48 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from 'vue-facing-decorator'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
@Component @Component
export default class PromptDialog extends Vue { export default class PromptDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
title = ""; title = ''
text = ""; text = ''
option1Text = ""; option1Text = ''
option2Text = ""; option2Text = ''
option3Text = ""; option3Text = ''
onOption1?: () => void; onOption1?: () => void
onOption2?: () => void; onOption2?: () => void
onOption3?: () => void; onOption3?: () => void
onCancel?: () => Promise<void>; onCancel?: () => Promise<void>
open(options: { open(options: {
title: string; title: string
text: string; text: string
option1Text?: string; option1Text?: string
option2Text?: string; option2Text?: string
option3Text?: string; option3Text?: string
onOption1?: () => void; onOption1?: () => void
onOption2?: () => void; onOption2?: () => void
onOption3?: () => void; onOption3?: () => void
onCancel?: () => Promise<void>; onCancel?: () => Promise<void>
}) { }) {
this.title = options.title; this.title = options.title
this.text = options.text; this.text = options.text
this.option1Text = options.option1Text || ""; this.option1Text = options.option1Text || ''
this.option2Text = options.option2Text || ""; this.option2Text = options.option2Text || ''
this.option3Text = options.option3Text || ""; this.option3Text = options.option3Text || ''
this.onOption1 = options.onOption1; this.onOption1 = options.onOption1
this.onOption2 = options.onOption2; this.onOption2 = options.onOption2
this.onOption3 = options.onOption3; this.onOption3 = options.onOption3
this.onCancel = options.onCancel; this.onCancel = options.onCancel
this.$notify( this.$notify(
{ {
group: "customModal", group: 'customModal',
type: "confirm", type: 'confirm',
title: this.title, title: this.title,
text: this.text, text: this.text,
option1Text: this.option1Text, option1Text: this.option1Text,
@ -115,38 +117,38 @@ export default class PromptDialog extends Vue {
onOption1: this.onOption1, onOption1: this.onOption1,
onOption2: this.onOption2, onOption2: this.onOption2,
onOption3: this.onOption3, onOption3: this.onOption3,
onCancel: this.onCancel, onCancel: this.onCancel
} as NotificationIface, } as NotificationIface,
-1, -1
); )
} }
handleOption1(close: (id: string) => void) { handleOption1(close: (id: string) => void) {
if (this.onOption1) { if (this.onOption1) {
this.onOption1(); this.onOption1()
} }
close("string that does not matter"); close('string that does not matter')
} }
handleOption2(close: (id: string) => void) { handleOption2(close: (id: string) => void) {
if (this.onOption2) { if (this.onOption2) {
this.onOption2(); this.onOption2()
} }
close("string that does not matter"); close('string that does not matter')
} }
handleOption3(close: (id: string) => void) { handleOption3(close: (id: string) => void) {
if (this.onOption3) { if (this.onOption3) {
this.onOption3(); this.onOption3()
} }
close("string that does not matter"); close('string that does not matter')
} }
handleCancel(close: (id: string) => void) { handleCancel(close: (id: string) => void) {
if (this.onCancel) { if (this.onCancel) {
this.onCancel(); this.onCancel()
} }
close("string that does not matter"); close('string that does not matter')
} }
} }
</script> </script>

40
src/components/ContactNameDialog.vue

@ -2,7 +2,9 @@
<template> <template>
<div v-if="visible" class="dialog-overlay"> <div v-if="visible" class="dialog-overlay">
<div class="dialog"> <div class="dialog">
<h1 class="text-xl font-bold text-center mb-4">{{ title }}</h1> <h1 class="text-xl font-bold text-center mb-4">
{{ title }}
</h1>
{{ message }} {{ message }}
Note that their name is only stored on this device. Note that their name is only stored on this device.
<input <input
@ -35,43 +37,43 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from 'vue-facing-decorator'
@Component @Component
export default class ContactNameDialog extends Vue { export default class ContactNameDialog extends Vue {
cancelCallback: () => void = () => {}; cancelCallback: () => void = () => {}
saveCallback: (name?: string) => void = () => {}; saveCallback: (name?: string) => void = () => {}
message = ""; message = ''
newText = ""; newText = ''
title = "Contact Name"; title = 'Contact Name'
visible = false; visible = false
async open( async open(
title?: string, title?: string,
message?: string, message?: string,
saveCallback?: (name?: string) => void, saveCallback?: (name?: string) => void,
cancelCallback?: () => void, cancelCallback?: () => void,
defaultName?: string, defaultName?: string
) { ) {
this.cancelCallback = cancelCallback || this.cancelCallback; this.cancelCallback = cancelCallback || this.cancelCallback
this.saveCallback = saveCallback || this.saveCallback; this.saveCallback = saveCallback || this.saveCallback
this.message = message ?? this.message; this.message = message ?? this.message
this.newText = defaultName ?? ""; this.newText = defaultName ?? ''
this.title = title ?? this.title; this.title = title ?? this.title
this.visible = true; this.visible = true
} }
async onClickSaveChanges() { async onClickSaveChanges() {
this.visible = false; this.visible = false
if (this.saveCallback) { if (this.saveCallback) {
this.saveCallback(this.newText); this.saveCallback(this.newText)
} }
} }
onClickCancel() { onClickCancel() {
this.visible = false; this.visible = false
if (this.cancelCallback) { if (this.cancelCallback) {
this.cancelCallback(); this.cancelCallback()
} }
} }
} }

82
src/components/DataExportSection.vue

@ -61,15 +61,15 @@ backup and database export, with platform-specific download instructions. * *
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from 'vue-facing-decorator'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
import { db } from "../db/index"; import { db } from '../db/index'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from '../services/PlatformServiceFactory'
import { import {
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities
} from "../services/PlatformService"; } from '../services/PlatformService'
/** /**
* @vue-component * @vue-component
@ -82,33 +82,33 @@ export default class DataExportSection extends Vue {
* Notification function injected by Vue * Notification function injected by Vue
* Used to show success/error messages to the user * Used to show success/error messages to the user
*/ */
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
/** /**
* Active DID (Decentralized Identifier) of the user * Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option * Controls visibility of seed backup option
* @required * @required
*/ */
@Prop({ required: true }) readonly activeDid!: string; @Prop({ required: true }) readonly activeDid!: string
/** /**
* URL for the database export download * URL for the database export download
* Created and revoked dynamically during export process * Created and revoked dynamically during export process
* Only used in web platform * Only used in web platform
*/ */
downloadUrl = ""; downloadUrl = ''
/** /**
* Platform service instance for platform-specific operations * Platform service instance for platform-specific operations
*/ */
private platformService: PlatformService = private platformService: PlatformService =
PlatformServiceFactory.getInstance(); PlatformServiceFactory.getInstance()
/** /**
* Platform capabilities for the current platform * Platform capabilities for the current platform
*/ */
private get platformCapabilities(): PlatformCapabilities { private get platformCapabilities(): PlatformCapabilities {
return this.platformService.getCapabilities(); return this.platformService.getCapabilities()
} }
/** /**
@ -117,7 +117,7 @@ export default class DataExportSection extends Vue {
*/ */
beforeUnmount() { beforeUnmount() {
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) { if (this.downloadUrl && this.platformCapabilities.hasFileDownload) {
URL.revokeObjectURL(this.downloadUrl); URL.revokeObjectURL(this.downloadUrl)
} }
} }
@ -131,45 +131,45 @@ export default class DataExportSection extends Vue {
*/ */
public async exportDatabase() { public async exportDatabase() {
try { try {
const blob = await db.export({ prettyJson: true }); const blob = await db.export({ prettyJson: true })
const fileName = `${db.name}-backup.json`; const fileName = `${db.name}-backup.json`
if (this.platformCapabilities.hasFileDownload) { if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link // Web platform: Use download link
this.downloadUrl = URL.createObjectURL(blob); this.downloadUrl = URL.createObjectURL(blob)
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement; const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement
downloadAnchor.href = this.downloadUrl; downloadAnchor.href = this.downloadUrl
downloadAnchor.download = fileName; downloadAnchor.download = fileName
downloadAnchor.click(); downloadAnchor.click()
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000)
} else if (this.platformCapabilities.hasFileSystem) { } else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory // Native platform: Write to app directory
const content = await blob.text(); const content = await blob.text()
await this.platformService.writeAndShareFile(fileName, content); await this.platformService.writeAndShareFile(fileName, content)
} }
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "success", type: 'success',
title: "Export Successful", title: 'Export Successful',
text: this.platformCapabilities.hasFileDownload text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup. It is in the Dexie format." ? 'See your downloads directory for the backup. It is in the Dexie format.'
: "Please choose a location to save your backup file.", : 'Please choose a location to save your backup file.'
}, },
-1, -1
); )
} catch (error) { } catch (error) {
logger.error("Export Error:", error); logger.error('Export Error:', error)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Export Error", title: 'Export Error',
text: "There was an error exporting the data.", text: 'There was an error exporting the data.'
}, },
3000, 3000
); )
} }
} }
@ -179,8 +179,8 @@ export default class DataExportSection extends Vue {
*/ */
public computedStartDownloadLinkClassNames() { public computedStartDownloadLinkClassNames() {
return { return {
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload, hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload
}; }
} }
/** /**
@ -189,8 +189,8 @@ export default class DataExportSection extends Vue {
*/ */
public computedDownloadLinkClassNames() { public computedDownloadLinkClassNames() {
return { return {
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload, hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload
}; }
} }
} }
</script> </script>

38
src/components/EntityIcon.vue

@ -1,40 +1,40 @@
<template> <template>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div class="w-fit" v-html="generateIcon()"></div> <div class="w-fit" v-html="generateIcon()" />
</template> </template>
<script lang="ts"> <script lang="ts">
import { createAvatar, StyleOptions } from "@dicebear/core"; import { createAvatar, StyleOptions } from '@dicebear/core'
import { avataaars } from "@dicebear/collection"; import { avataaars } from '@dicebear/collection'
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from 'vue-facing-decorator'
import { Contact } from "../db/tables/contacts"; import { Contact } from '../db/tables/contacts'
@Component @Component
export default class EntityIcon extends Vue { export default class EntityIcon extends Vue {
@Prop contact: Contact; @Prop contact: Contact
@Prop entityId = ""; // overridden by contact.did or profileImageUrl @Prop entityId = '' // overridden by contact.did or profileImageUrl
@Prop iconSize = 0; @Prop iconSize = 0
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl @Prop profileImageUrl = '' // overridden by contact.profileImageUrl
generateIcon() { generateIcon() {
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl; const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl
if (imageUrl) { if (imageUrl) {
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`; return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`
} else { } else {
const identifier = this.contact?.did || this.entityId; const identifier = this.contact?.did || this.entityId
if (!identifier) { if (!identifier) {
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`; 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= // https://api.dicebear.com/8.x/avataaars/svg?seed=
// ... does not render things with the same seed as this library. // ... 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 // "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. // ... which looks similar to '' at the dicebear site but which is different.
const options: StyleOptions<object> = { const options: StyleOptions<object> = {
seed: (identifier as string) || "", seed: (identifier as string) || '',
size: this.iconSize, size: this.iconSize
}; }
const avatar = createAvatar(avataaars, options); const avatar = createAvatar(avataaars, options)
const svgString = avatar.toString(); const svgString = avatar.toString()
return svgString; return svgString
} }
} }
} }

97
src/components/FeedFilters.vue

@ -22,11 +22,11 @@
class="sr-only" class="sr-only"
/> />
<!-- line --> <!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <div class="block bg-slate-500 w-14 h-8 rounded-full" />
<!-- dot --> <!-- dot -->
<div <div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div> />
</div> </div>
</div> </div>
@ -52,11 +52,11 @@
class="sr-only" class="sr-only"
/> />
<!-- line --> <!-- line -->
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div> <div class="block bg-slate-500 w-14 h-8 rounded-full" />
<!-- dot --> <!-- dot -->
<div <div
class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition" class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"
></div> />
</div> </div>
<div v-else class="relative ml-2"> <div v-else class="relative ml-2">
<button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500"> <button class="ml-2 px-4 py-2 rounded-md bg-blue-200 text-blue-500">
@ -91,101 +91,96 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from 'vue-facing-decorator'
import { import { LMap, LMarker, LRectangle, LTileLayer } from '@vue-leaflet/vue-leaflet'
LMap, import { Router } from 'vue-router'
LMarker, import { MASTER_SETTINGS_KEY } from '../db/tables/settings'
LRectangle, import { db, retrieveSettingsForActiveAccount } from '../db/index'
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
@Component({ @Component({
components: { components: {
LRectangle, LRectangle,
LMap, LMap,
LMarker, LMarker,
LTileLayer, LTileLayer
}, }
}) })
export default class FeedFilters extends Vue { export default class FeedFilters extends Vue {
$router!: Router; $router!: Router
onCloseIfChanged = () => {}; onCloseIfChanged = () => {}
hasSearchBox = false; hasSearchBox = false
hasVisibleDid = false; hasVisibleDid = false
isNearby = false; isNearby = false
settingChanged = false; settingChanged = false
visible = false; visible = false
async open(onCloseIfChanged: () => void) { async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged; this.onCloseIfChanged = onCloseIfChanged
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
this.hasVisibleDid = !!settings.filterFeedByVisible; this.hasVisibleDid = !!settings.filterFeedByVisible
this.isNearby = !!settings.filterFeedByNearby; this.isNearby = !!settings.filterFeedByNearby
if (settings.searchBoxes && settings.searchBoxes.length > 0) { if (settings.searchBoxes && settings.searchBoxes.length > 0) {
this.hasSearchBox = true; this.hasSearchBox = true
} }
this.settingChanged = false; this.settingChanged = false
this.visible = true; this.visible = true
} }
async toggleHasVisibleDid() { async toggleHasVisibleDid() {
this.settingChanged = true; this.settingChanged = true
this.hasVisibleDid = !this.hasVisibleDid; this.hasVisibleDid = !this.hasVisibleDid
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid, filterFeedByVisible: this.hasVisibleDid
}); })
} }
async toggleNearby() { async toggleNearby() {
this.settingChanged = true; this.settingChanged = true
this.isNearby = !this.isNearby; this.isNearby = !this.isNearby
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby, filterFeedByNearby: this.isNearby
}); })
} }
async clearAll() { async clearAll() {
if (this.hasVisibleDid || this.isNearby) { if (this.hasVisibleDid || this.isNearby) {
this.settingChanged = true; this.settingChanged = true
} }
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false, filterFeedByNearby: false,
filterFeedByVisible: false, filterFeedByVisible: false
}); })
this.hasVisibleDid = false; this.hasVisibleDid = false
this.isNearby = false; this.isNearby = false
} }
async setAll() { async setAll() {
if (!this.hasVisibleDid || !this.isNearby) { if (!this.hasVisibleDid || !this.isNearby) {
this.settingChanged = true; this.settingChanged = true
} }
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true, filterFeedByNearby: true,
filterFeedByVisible: true, filterFeedByVisible: true
}); })
this.hasVisibleDid = true; this.hasVisibleDid = true
this.isNearby = true; this.isNearby = true
} }
close() { close() {
if (this.settingChanged) { if (this.settingChanged) {
this.onCloseIfChanged(); this.onCloseIfChanged()
} }
this.visible = false; this.visible = false
} }
done() { done() {
this.close(); this.close()
} }
} }
</script> </script>

265
src/components/GiftedDialog.vue

@ -51,8 +51,8 @@
providerProjectId: fromProjectId, providerProjectId: fromProjectId,
recipientDid: receiver?.did, recipientDid: receiver?.did,
recipientName: receiver?.name, recipientName: receiver?.name,
unitCode, unitCode
}, }
}" }"
class="text-blue-500" class="text-blue-500"
> >
@ -87,44 +87,44 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from 'vue-facing-decorator'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
import { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
serverMessageForUser, serverMessageForUser
} from "../libs/endorserServer"; } from '../libs/endorserServer'
import * as libsUtil from "../libs/util"; import * as libsUtil from '../libs/util'
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from '../db/index'
import { Contact } from "../db/tables/contacts"; import { Contact } from '../db/tables/contacts'
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from '../libs/util'
@Component @Component
export default class GiftedDialog extends Vue { export default class GiftedDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
@Prop() fromProjectId = ""; @Prop() fromProjectId = ''
@Prop() toProjectId = ""; @Prop() toProjectId = ''
activeDid = ""; activeDid = ''
allContacts: Array<Contact> = []; allContacts: Array<Contact> = []
allMyDids: Array<string> = []; allMyDids: Array<string> = []
apiServer = ""; apiServer = ''
amountInput = "0"; amountInput = '0'
callbackOnSuccess?: (amount: number) => void = () => {}; callbackOnSuccess?: (amount: number) => void = () => {}
customTitle?: string; customTitle?: string
description = ""; description = ''
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo // undefined means no identified giver agent
isTrade = false; isTrade = false
offerId = ""; offerId = ''
prompt = ""; prompt = ''
receiver?: libsUtil.GiverReceiverInputInfo; receiver?: libsUtil.GiverReceiverInputInfo
unitCode = "HUR"; unitCode = 'HUR'
visible = false; visible = false
libsUtil = libsUtil; libsUtil = libsUtil
async open( async open(
giver?: libsUtil.GiverReceiverInputInfo, giver?: libsUtil.GiverReceiverInputInfo,
@ -132,146 +132,143 @@ export default class GiftedDialog extends Vue {
offerId?: string, offerId?: string,
customTitle?: string, customTitle?: string,
prompt?: string, prompt?: string,
callbackOnSuccess: (amount: number) => void = () => {}, callbackOnSuccess: (amount: number) => void = () => {}
) { ) {
this.customTitle = customTitle; this.customTitle = customTitle
this.giver = giver; this.giver = giver
this.prompt = prompt || ""; this.prompt = prompt || ''
this.receiver = receiver; this.receiver = receiver
// if we show "given to user" selection, default checkbox to true // if we show "given to user" selection, default checkbox to true
this.amountInput = "0"; this.amountInput = '0'
this.callbackOnSuccess = callbackOnSuccess; this.callbackOnSuccess = callbackOnSuccess
this.offerId = offerId || ""; this.offerId = offerId || ''
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || ''
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || ''
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray()
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids()
if (this.giver && !this.giver.name) { if (this.giver && !this.giver.name) {
this.giver.name = didInfo( this.giver.name = didInfo(
this.giver.did, this.giver.did,
this.activeDid, this.activeDid,
this.allMyDids, this.allMyDids,
this.allContacts, this.allContacts
); )
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logger.error("Error retrieving settings from database:", err); logger.error('Error retrieving settings from database:', err)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: err.message || "There was an error retrieving your settings.", text: err.message || 'There was an error retrieving your settings.'
}, },
-1, -1
); )
} }
this.visible = true; this.visible = true
} }
close() { close() {
// close the dialog but don't change values (since it might be submitting info) // close the dialog but don't change values (since it might be submitting info)
this.visible = false; this.visible = false
} }
changeUnitCode() { changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT); const units = Object.keys(this.libsUtil.UNIT_SHORT)
const index = units.indexOf(this.unitCode); const index = units.indexOf(this.unitCode)
this.unitCode = units[(index + 1) % units.length]; this.unitCode = units[(index + 1) % units.length]
} }
increment() { increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`; this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`
} }
decrement() { decrement() {
this.amountInput = `${Math.max( this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}`
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
} }
cancel() { cancel() {
this.close(); this.close()
this.eraseValues(); this.eraseValues()
} }
eraseValues() { eraseValues() {
this.description = ""; this.description = ''
this.giver = undefined; this.giver = undefined
this.amountInput = "0"; this.amountInput = '0'
this.prompt = ""; this.prompt = ''
this.unitCode = "HUR"; this.unitCode = 'HUR'
} }
async confirm() { async confirm() {
if (!this.activeDid) { if (!this.activeDid) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: "You must select an identifier before you can record a give.", text: 'You must select an identifier before you can record a give.'
}, },
3000, 3000
); )
return; return
} }
if (parseFloat(this.amountInput) < 0) { if (parseFloat(this.amountInput) < 0) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
text: "You may not send a negative number.", text: 'You may not send a negative number.',
title: "", title: ''
}, },
2000, 2000
); )
return; return
} }
if (!this.description && !parseFloat(this.amountInput)) { if (!this.description && !parseFloat(this.amountInput)) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: `You must enter a description or some number of ${ text: `You must enter a description or some number of ${
this.libsUtil.UNIT_LONG[this.unitCode] this.libsUtil.UNIT_LONG[this.unitCode]
}.`, }.`
}, },
2000, 2000
); )
return; return
} }
this.close(); this.close()
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "toast", type: 'toast',
text: "Recording the give...", text: 'Recording the give...',
title: "", title: ''
}, },
1000, 1000
); )
// this is asynchronous, but we don't need to wait for it to complete // this is asynchronous, but we don't need to wait for it to complete
await this.recordGive( await this.recordGive(
(this.giver?.did as string) || null, (this.giver?.did as string) || null,
(this.receiver?.did as string) || null, (this.receiver?.did as string) || null,
this.description, this.description,
parseFloat(this.amountInput), parseFloat(this.amountInput),
this.unitCode, this.unitCode
).then(() => { ).then(() => {
this.eraseValues(); this.eraseValues()
}); })
} }
/** /**
@ -287,7 +284,7 @@ export default class GiftedDialog extends Vue {
recipientDid: string | null, recipientDid: string | null,
description: string, description: string,
amount: number, amount: number,
unitCode: string = "HUR", unitCode: string = 'HUR'
) { ) {
try { try {
const result = await createAndSubmitGive( const result = await createAndSubmitGive(
@ -303,54 +300,54 @@ export default class GiftedDialog extends Vue {
this.offerId, this.offerId,
this.isTrade, this.isTrade,
undefined, undefined,
this.fromProjectId, this.fromProjectId
); )
if ( if (
result.type === "error" || result.type === 'error' ||
this.isGiveCreationError(result.response) this.isGiveCreationError(result.response)
) { ) {
const errorMessage = this.getGiveCreationErrorMessage(result); const errorMessage = this.getGiveCreationErrorMessage(result)
logger.error("Error with give creation result:", result); logger.error('Error with give creation result:', result)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: errorMessage || "There was an error creating the give.", text: errorMessage || 'There was an error creating the give.'
}, },
-1, -1
); )
} else { } else {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "success", type: 'success',
title: "Success", title: 'Success',
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`, text: `That ${this.isTrade ? 'trade' : 'gift'} was recorded.`
}, },
7000, 7000
); )
if (this.callbackOnSuccess) { if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount); this.callbackOnSuccess(amount)
} }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
logger.error("Error with give recordation caught:", error); logger.error('Error with give recordation caught:', error)
const errorMessage = const errorMessage =
error.userMessage || error.userMessage ||
serverMessageForUser(error) || serverMessageForUser(error) ||
"There was an error recording the give."; 'There was an error recording the give.'
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: errorMessage, text: errorMessage
}, },
-1, -1
); )
} }
} }
@ -362,7 +359,7 @@ export default class GiftedDialog extends Vue {
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) { isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error; return result.status !== 201 || result.data?.error
} }
/** /**
@ -375,19 +372,19 @@ export default class GiftedDialog extends Vue {
result.error?.userMessage || result.error?.userMessage ||
result.error?.error || result.error?.error ||
result.response?.data?.error?.message result.response?.data?.error?.message
); )
} }
explainData() { explainData() {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "success", type: 'success',
title: "Data Sharing", title: 'Data Sharing',
text: libsUtil.PRIVACY_MESSAGE, text: libsUtil.PRIVACY_MESSAGE
}, },
-1, -1
); )
} }
} }
</script> </script>

154
src/components/GiftedPrompts.vue

@ -7,7 +7,7 @@
class="text-lg text-center p-2 leading-none absolute right-0 -top-1" class="text-lg text-center p-2 leading-none absolute right-0 -top-1"
@click="cancel" @click="cancel"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]" />
</div> </div>
</h1> </h1>
<span class="mt-2 flex justify-between"> <span class="mt-2 flex justify-between">
@ -71,92 +71,92 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from 'vue-facing-decorator'
import { Router } from "vue-router"; import { Router } from 'vue-router'
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from '../constants/app'
import { db } from "../db/index"; import { db } from '../db/index'
import { Contact } from "../db/tables/contacts"; import { Contact } from '../db/tables/contacts'
import { GiverReceiverInputInfo } from "../libs/util"; import { GiverReceiverInputInfo } from '../libs/util'
@Component @Component
export default class GivenPrompts extends Vue { export default class GivenPrompts extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
$router!: Router; $router!: Router
CATEGORY_CONTACTS = 1; CATEGORY_CONTACTS = 1
CATEGORY_IDEAS = 0; CATEGORY_IDEAS = 0
IDEAS = [ IDEAS = [
"What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)", 'What food did someone make? (How did it free up your time for something? Was something doable because it eased your stress?)',
"What did a family member do? (How did you take better action because it made you feel loved?)", 'What did a family member do? (How did you take better action because it made you feel loved?)',
"What compliment did someone give you? (What task could you tackle because it boosted your confidence?)", 'What compliment did someone give you? (What task could you tackle because it boosted your confidence?)',
"Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)", 'Who is someone you can always rely on, and how did they demonstrate that? (What project tasks were enabled because you could depend on them?)',
"What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)", 'What did you see someone give to someone else? (What is the effect of the positivity you gained from seeing that?)',
"What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)", 'What is a way that someone helped you even though you have never met? (What different action did you take due to that newfound perspective or inspiration?)',
"How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)", 'How did a musician or author or artist inspire you? (What were you motivated to do more creatively because of that?)',
"What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)", 'What inspiration did you get from someone who handled tragedy well? (What could you accomplish with better grace or resilience after seeing their example?)',
"What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)", 'What is something worth respect that an organization gave you? (How did their contribution improve the situation or enable new activities?)',
"Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)", 'Who last gave you a good laugh? (What kind of bond or revitalization did that bring to a situation?)',
"What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)", 'What do you recall someone giving you while you were young? (How did it bring excitement or teach a skill or ignite a passion that resulted in improvements in your life?)',
"Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)", 'Who forgave you or overlooked a mistake? (How did that free you or build trust that enabled better relationships?)',
"What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)", 'What is a way an ancestor contributed to your life? (What in your life is now possible because of their efforts? What challenges are you undertaking knowing of their lives?)',
"What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)", 'What kind of help did someone at work give you? (How did that help with team progress? How did that lift your professional growth?)',
"How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)", 'How did a teacher or mentor or great example help you? (How did their guidance enhance your attitude or actions?)',
"What is a surprise gift you received? (What extra possibilities did it give you?)", 'What is a surprise gift you received? (What extra possibilities did it give you?)'
]; ]
callbackOnFullGiftInfo?: ( callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo, contactInfo?: GiverReceiverInputInfo,
description?: string, description?: string
) => void; ) => void
currentCategory = this.CATEGORY_IDEAS; // 0 = IDEAS, 1 = CONTACTS currentCategory = this.CATEGORY_IDEAS // 0 = IDEAS, 1 = CONTACTS
currentContact: Contact | undefined = undefined; currentContact: Contact | undefined = undefined
currentIdeaIndex = 0; currentIdeaIndex = 0
numContacts = 0; numContacts = 0
shownContactDbIndices: Array<boolean> = []; shownContactDbIndices: Array<boolean> = []
visible = false; visible = false
AppString = AppString; AppString = AppString
async open( async open(
callbackOnFullGiftInfo?: ( callbackOnFullGiftInfo?: (
contactInfo?: GiverReceiverInputInfo, contactInfo?: GiverReceiverInputInfo,
description?: string, description?: string
) => void, ) => void
) { ) {
this.visible = true; this.visible = true
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo; this.callbackOnFullGiftInfo = callbackOnFullGiftInfo
await db.open(); await db.open()
this.numContacts = await db.contacts.count(); this.numContacts = await db.contacts.count()
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start this.shownContactDbIndices = new Array<boolean>(this.numContacts) // all undefined to start
} }
cancel() { cancel() {
this.currentCategory = this.CATEGORY_IDEAS; this.currentCategory = this.CATEGORY_IDEAS
this.currentContact = undefined; this.currentContact = undefined
this.currentIdeaIndex = 0; this.currentIdeaIndex = 0
this.numContacts = 0; this.numContacts = 0
this.shownContactDbIndices = []; this.shownContactDbIndices = []
this.visible = false; this.visible = false
} }
proceed() { proceed() {
// proceed with logic but don't change values (just in case some actions are added later) // proceed with logic but don't change values (just in case some actions are added later)
this.visible = false; this.visible = false
if (this.currentCategory === this.CATEGORY_IDEAS) { if (this.currentCategory === this.CATEGORY_IDEAS) {
this.$router.push({ this.$router.push({
name: "contact-gift", name: 'contact-gift',
query: { query: {
prompt: this.IDEAS[this.currentIdeaIndex], prompt: this.IDEAS[this.currentIdeaIndex]
}, }
}); })
} else { } else {
// must be this.CATEGORY_CONTACTS // must be this.CATEGORY_CONTACTS
this.callbackOnFullGiftInfo?.( this.callbackOnFullGiftInfo?.(
this.currentContact as GiverReceiverInputInfo, this.currentContact as GiverReceiverInputInfo
); )
} }
} }
@ -167,14 +167,14 @@ export default class GivenPrompts extends Vue {
async nextIdea() { async nextIdea() {
// check if the next one is an idea or a contact // check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) { if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex++; this.currentIdeaIndex++
if (this.currentIdeaIndex === this.IDEAS.length) { if (this.currentIdeaIndex === this.IDEAS.length) {
// must have just finished ideas so move to contacts // must have just finished ideas so move to contacts
this.findNextUnshownContact(); this.findNextUnshownContact()
} }
} else { } else {
// must be this.CATEGORY_CONTACTS // must be this.CATEGORY_CONTACTS
this.findNextUnshownContact(); this.findNextUnshownContact()
// when that's finished, it'll reset to ideas // when that's finished, it'll reset to ideas
} }
} }
@ -186,54 +186,52 @@ export default class GivenPrompts extends Vue {
async prevIdea() { async prevIdea() {
// check if the next one is an idea or a contact // check if the next one is an idea or a contact
if (this.currentCategory === this.CATEGORY_IDEAS) { if (this.currentCategory === this.CATEGORY_IDEAS) {
this.currentIdeaIndex--; this.currentIdeaIndex--
if (this.currentIdeaIndex < 0) { if (this.currentIdeaIndex < 0) {
// must have just finished ideas so move to contacts // must have just finished ideas so move to contacts
this.findNextUnshownContact(); this.findNextUnshownContact()
} }
} else { } else {
// must be this.CATEGORY_CONTACTS // must be this.CATEGORY_CONTACTS
this.findNextUnshownContact(); this.findNextUnshownContact()
// when that's finished, it'll reset to ideas // when that's finished, it'll reset to ideas
} }
} }
nextIdeaPastContacts() { nextIdeaPastContacts() {
this.currentContact = undefined; this.currentContact = undefined
this.shownContactDbIndices = new Array<boolean>(this.numContacts); this.shownContactDbIndices = new Array<boolean>(this.numContacts)
this.currentCategory = this.CATEGORY_IDEAS; this.currentCategory = this.CATEGORY_IDEAS
// look at the previous idea and switch to the other side of the list // look at the previous idea and switch to the other side of the list
this.currentIdeaIndex = this.currentIdeaIndex =
this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1; this.currentIdeaIndex >= this.IDEAS.length ? 0 : this.IDEAS.length - 1
} }
async findNextUnshownContact() { async findNextUnshownContact() {
if (this.currentCategory === this.CATEGORY_IDEAS) { if (this.currentCategory === this.CATEGORY_IDEAS) {
// we're not in the contact prompts, so reset index array // we're not in the contact prompts, so reset index array
this.shownContactDbIndices = new Array<boolean>(this.numContacts); this.shownContactDbIndices = new Array<boolean>(this.numContacts)
} }
this.currentCategory = this.CATEGORY_CONTACTS; this.currentCategory = this.CATEGORY_CONTACTS
let someContactDbIndex = Math.floor(Math.random() * this.numContacts); let someContactDbIndex = Math.floor(Math.random() * this.numContacts)
let count = 0; let count = 0
// as long as the index has an entry, loop // as long as the index has an entry, loop
while ( while (
this.shownContactDbIndices[someContactDbIndex] != null && this.shownContactDbIndices[someContactDbIndex] != null &&
count++ < this.numContacts count++ < this.numContacts
) { ) {
someContactDbIndex = (someContactDbIndex + 1) % this.numContacts; someContactDbIndex = (someContactDbIndex + 1) % this.numContacts
} }
if (count >= this.numContacts) { if (count >= this.numContacts) {
// all contacts have been shown // all contacts have been shown
this.nextIdeaPastContacts(); this.nextIdeaPastContacts()
} else { } else {
// get the contact at that offset // get the contact at that offset
await db.open(); await db.open()
this.currentContact = await db.contacts this.currentContact = await db.contacts.offset(someContactDbIndex).first()
.offset(someContactDbIndex) this.shownContactDbIndices[someContactDbIndex] = true
.first();
this.shownContactDbIndices[someContactDbIndex] = true;
} }
} }
} }

78
src/components/HiddenDidDialog.vue

@ -100,33 +100,33 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from 'vue-facing-decorator'
import * as R from "ramda"; import * as R from 'ramda'
import { useClipboard } from "@vueuse/core"; import { useClipboard } from '@vueuse/core'
import { Contact } from "../db/tables/contacts"; import { Contact } from '../db/tables/contacts'
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from '../libs/endorserServer'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
@Component @Component
export default class HiddenDidDialog extends Vue { export default class HiddenDidDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
isOpen = false; isOpen = false
roleName = ""; roleName = ''
visibleToDids: string[] = []; visibleToDids: string[] = []
allContacts: Array<Contact> = []; allContacts: Array<Contact> = []
activeDid = ""; activeDid = ''
allMyDids: Array<string> = []; allMyDids: Array<string> = []
canShare = false; canShare = false
windowLocation = window.location.href; windowLocation = window.location.href
R = R; R = R
serverUtil = serverUtil; serverUtil = serverUtil
created() { created() {
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare // When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare
// then use this truer check: navigator.canShare && navigator.canShare() // then use this truer check: navigator.canShare && navigator.canShare()
this.canShare = !!navigator.share; this.canShare = !!navigator.share
} }
open( open(
@ -134,18 +134,18 @@ export default class HiddenDidDialog extends Vue {
visibleToDids: string[], visibleToDids: string[],
allContacts: Array<Contact>, allContacts: Array<Contact>,
activeDid: string, activeDid: string,
allMyDids: Array<string>, allMyDids: Array<string>
) { ) {
this.roleName = roleName; this.roleName = roleName
this.visibleToDids = visibleToDids; this.visibleToDids = visibleToDids
this.allContacts = allContacts; this.allContacts = allContacts
this.activeDid = activeDid; this.activeDid = activeDid
this.allMyDids = allMyDids; this.allMyDids = allMyDids
this.isOpen = true; this.isOpen = true
} }
close() { close() {
this.isOpen = false; this.isOpen = false
} }
didInfo(did: string) { didInfo(did: string) {
@ -153,8 +153,8 @@ export default class HiddenDidDialog extends Vue {
did, did,
this.activeDid, this.activeDid,
this.allMyDids, this.allMyDids,
this.allContacts, this.allContacts
); )
} }
copyToClipboard(name: string, text: string) { copyToClipboard(name: string, text: string) {
@ -163,23 +163,23 @@ export default class HiddenDidDialog extends Vue {
.then(() => { .then(() => {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "toast", type: 'toast',
title: "Copied", title: 'Copied',
text: (name || "That") + " was copied to the clipboard.", text: (name || 'That') + ' was copied to the clipboard.'
}, },
2000, 2000
); )
}); })
} }
onClickShareClaim() { onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation); this.copyToClipboard('A link to this page', this.windowLocation)
window.navigator.share({ window.navigator.share({
title: "Help Connect Me", title: 'Help Connect Me',
text: "I'm trying to find the people who recorded this. Can you help me?", text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation, url: this.windowLocation
}); })
} }
} }
</script> </script>

97
src/components/ImageMethodDialog.vue

@ -12,7 +12,7 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]" />
</div> </div>
</div> </div>
@ -56,103 +56,102 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import axios from "axios"; import axios from 'axios'
import { ref } from "vue"; import { ref } from 'vue'
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from 'vue-facing-decorator'
import PhotoDialog from "../components/PhotoDialog.vue"; import PhotoDialog from '../components/PhotoDialog.vue'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
const inputImageFileNameRef = ref<Blob>(); const inputImageFileNameRef = ref<Blob>()
@Component({ @Component({
components: { PhotoDialog }, components: { PhotoDialog }
}) })
export default class ImageMethodDialog extends Vue { export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
claimType: string; claimType: string
crop: boolean = false; crop: boolean = false
imageCallback: (imageUrl?: string) => void = () => {}; imageCallback: (imageUrl?: string) => void = () => {}
imageUrl?: string; imageUrl?: string
visible = false; visible = false
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) { open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType; this.claimType = claimType
this.crop = !!crop; this.crop = !!crop
this.imageCallback = setImageFn; this.imageCallback = setImageFn
this.visible = true; this.visible = true
} }
openPhotoDialog(blob?: Blob, fileName?: string) { openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false; this.visible = false
;(this.$refs.photoDialog as PhotoDialog).open(
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback, this.imageCallback,
this.claimType, this.claimType,
this.crop, this.crop,
blob, blob,
fileName, fileName
); )
} }
async uploadImageFile(event: Event) { async uploadImageFile(event: Event) {
this.visible = false; this.visible = false
inputImageFileNameRef.value = event.target.files[0]; inputImageFileNameRef.value = event.target.files[0]
// https://developer.mozilla.org/en-US/docs/Web/API/File // https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing // ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value; const file = inputImageFileNameRef.value
if (file != null) { if (file != null) {
const reader = new FileReader(); const reader = new FileReader()
reader.onload = async (e) => { reader.onload = async (e) => {
const data = e.target?.result as ArrayBuffer; const data = e.target?.result as ArrayBuffer
if (data) { if (data) {
const blob = new Blob([new Uint8Array(data)], { const blob = new Blob([new Uint8Array(data)], {
type: file.type, type: file.type
}); })
this.openPhotoDialog(blob, file.name as string); this.openPhotoDialog(blob, file.name as string)
}
} }
}; reader.readAsArrayBuffer(file as Blob)
reader.readAsArrayBuffer(file as Blob);
} }
} }
async acceptUrl() { async acceptUrl() {
this.visible = false; this.visible = false
if (this.crop) { if (this.crop) {
try { try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, { const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob responseType: 'blob' // This ensures the data is returned as a Blob
}); })
const fullUrl = new URL(this.imageUrl as string); const fullUrl = new URL(this.imageUrl as string)
const fileName = fullUrl.pathname.split("/").pop() as string; const fileName = fullUrl.pathname.split('/').pop() as string
(this.$refs.photoDialog as PhotoDialog).open( ;(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback, this.imageCallback,
this.claimType, this.claimType,
this.crop, this.crop,
urlBlobResponse.data as Blob, urlBlobResponse.data as Blob,
fileName, fileName
); )
} catch (error) { } catch (error) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: "There was an error retrieving that image.", text: 'There was an error retrieving that image.'
}, },
5000, 5000
); )
} }
} else { } else {
this.imageCallback(this.imageUrl); this.imageCallback(this.imageUrl)
} }
} }
close() { close() {
this.visible = false; this.visible = false
} }
} }
</script> </script>

34
src/components/ImageViewer.vue

@ -38,44 +38,44 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from 'vue-facing-decorator'
import { UAParser } from "ua-parser-js"; import { UAParser } from 'ua-parser-js'
@Component({ emits: ["update:isOpen"] }) @Component({ emits: ['update:isOpen'] })
export default class ImageViewer extends Vue { export default class ImageViewer extends Vue {
@Prop() imageUrl!: string; @Prop() imageUrl!: string
@Prop() imageData!: Blob | null; @Prop() imageData!: Blob | null
@Prop() isOpen!: boolean; @Prop() isOpen!: boolean
userAgent = new UAParser(); userAgent = new UAParser()
get isMobile() { get isMobile() {
const os = this.userAgent.getOS().name; const os = this.userAgent.getOS().name
return os === "iOS" || os === "Android"; return os === 'iOS' || os === 'Android'
} }
close() { close() {
this.$emit("update:isOpen", false); this.$emit('update:isOpen', false)
} }
async handleShare() { async handleShare() {
const os = this.userAgent.getOS().name; const os = this.userAgent.getOS().name
try { try {
if (os === "iOS" || os === "Android") { if (os === 'iOS' || os === 'Android') {
if (navigator.share) { if (navigator.share) {
// Always share the URL since it's more reliable across platforms // Always share the URL since it's more reliable across platforms
await navigator.share({ await navigator.share({
url: this.imageUrl, url: this.imageUrl
}); })
} else { } else {
// Fallback for browsers without share API // Fallback for browsers without share API
window.open(this.imageUrl, "_blank"); window.open(this.imageUrl, '_blank')
} }
} }
} catch (error) { } catch (error) {
logger.warn("Share failed, opening in new tab:", error); logger.warn('Share failed, opening in new tab:', error)
window.open(this.imageUrl, "_blank"); window.open(this.imageUrl, '_blank')
} }
} }
} }

47
src/components/InfiniteScroll.vue

@ -7,12 +7,12 @@ loading state management. * * @author Matthew Raymer * @version 1.0.0 */
<template> <template>
<div ref="scrollContainer"> <div ref="scrollContainer">
<slot /> <slot />
<div ref="sentinel" style="height: 1px"></div> <div ref="sentinel" style="height: 1px" />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Emit, Prop, Vue } from "vue-facing-decorator"; import { Component, Emit, Prop, Vue } from 'vue-facing-decorator'
/** /**
* InfiniteScroll Component * InfiniteScroll Component
@ -38,19 +38,19 @@ import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
export default class InfiniteScroll extends Vue { export default class InfiniteScroll extends Vue {
/** Distance in pixels from the bottom at which to trigger the reached-bottom event */ /** Distance in pixels from the bottom at which to trigger the reached-bottom event */
@Prop({ default: 200 }) @Prop({ default: 200 })
readonly distance!: number; readonly distance!: number
/** Intersection Observer instance for detecting scroll position */ /** Intersection Observer instance for detecting scroll position */
private observer!: IntersectionObserver; private observer!: IntersectionObserver
/** Flag to track initial render state */ /** Flag to track initial render state */
private isInitialRender = true; private isInitialRender = true
/** Flag to prevent multiple simultaneous loading states */ /** Flag to prevent multiple simultaneous loading states */
private isLoading = false; private isLoading = false
/** Timeout ID for debouncing scroll events */ /** Timeout ID for debouncing scroll events */
private debounceTimeout: number | null = null; private debounceTimeout: number | null = null
/** /**
* Vue lifecycle hook that runs after component updates. * Vue lifecycle hook that runs after component updates.
@ -64,13 +64,10 @@ export default class InfiniteScroll extends Vue {
const options = { const options = {
root: null, root: null,
rootMargin: `0px 0px ${this.distance}px 0px`, rootMargin: `0px 0px ${this.distance}px 0px`,
threshold: 1.0, threshold: 1.0
}; }
this.observer = new IntersectionObserver( this.observer = new IntersectionObserver(this.handleIntersection, options)
this.handleIntersection, this.observer.observe(this.$refs.sentinel as HTMLElement)
options,
);
this.observer.observe(this.$refs.sentinel as HTMLElement);
} }
} }
@ -83,10 +80,10 @@ export default class InfiniteScroll extends Vue {
*/ */
beforeUnmount() { beforeUnmount() {
if (this.observer) { if (this.observer) {
this.observer.disconnect(); this.observer.disconnect()
} }
if (this.debounceTimeout) { if (this.debounceTimeout) {
window.clearTimeout(this.debounceTimeout); window.clearTimeout(this.debounceTimeout)
} }
} }
@ -101,25 +98,25 @@ export default class InfiniteScroll extends Vue {
* Used internally by the Intersection Observer * Used internally by the Intersection Observer
* @emits reached-bottom - Emitted when the user scrolls near the bottom * @emits reached-bottom - Emitted when the user scrolls near the bottom
*/ */
@Emit("reached-bottom") @Emit('reached-bottom')
handleIntersection(entries: IntersectionObserverEntry[]) { handleIntersection(entries: IntersectionObserverEntry[]) {
const entry = entries[0]; const entry = entries[0]
if (entry.isIntersecting && !this.isLoading) { if (entry.isIntersecting && !this.isLoading) {
// Debounce the intersection event // Debounce the intersection event
if (this.debounceTimeout) { if (this.debounceTimeout) {
window.clearTimeout(this.debounceTimeout); window.clearTimeout(this.debounceTimeout)
} }
this.debounceTimeout = window.setTimeout(() => { this.debounceTimeout = window.setTimeout(() => {
this.isLoading = true; this.isLoading = true
this.$emit("reached-bottom", true); this.$emit('reached-bottom', true)
// Reset loading state after a short delay // Reset loading state after a short delay
setTimeout(() => { setTimeout(() => {
this.isLoading = false; this.isLoading = false
}, 1000); }, 1000)
}, 300); }, 300)
} }
return false; return false
} }
} }
</script> </script>

42
src/components/InviteDialog.vue

@ -46,50 +46,50 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from 'vue-facing-decorator'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
@Component @Component
export default class InviteDialog extends Vue { export default class InviteDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
callback: (text: string, expiresAt: string) => void = () => {}; callback: (text: string, expiresAt: string) => void = () => {}
inviteIdentifier = ""; inviteIdentifier = ''
text = ""; text = ''
visible = false; visible = false
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7) expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
.toISOString() .toISOString()
.substring(0, 10); .substring(0, 10)
async open( async open(
inviteIdentifier: string, inviteIdentifier: string,
aCallback: (text: string, expiresAt: string) => void, aCallback: (text: string, expiresAt: string) => void
) { ) {
this.callback = aCallback; this.callback = aCallback
this.inviteIdentifier = inviteIdentifier; this.inviteIdentifier = inviteIdentifier
this.visible = true; this.visible = true
} }
async onClickSaveChanges() { async onClickSaveChanges() {
if (!this.expiresAt) { if (!this.expiresAt) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "warning", type: 'warning',
title: "Needs Expiration", title: 'Needs Expiration',
text: "You must select an expiration date.", text: 'You must select an expiration date.'
}, },
5000, 5000
); )
} else { } else {
this.callback(this.text, this.expiresAt); this.callback(this.text, this.expiresAt)
this.visible = false; this.visible = false
} }
} }
onClickCancel() { onClickCancel() {
this.visible = false; this.visible = false
} }
} }
</script> </script>

336
src/components/MembersList.vue

@ -80,7 +80,9 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center"> <div class="flex items-center">
<h3 class="text-lg font-medium">{{ member.name }}</h3> <h3 class="text-lg font-medium">
{{ member.name }}
</h3>
<div <div
v-if="!getContactFor(member.did) && member.did !== activeDid" v-if="!getContactFor(member.did) && member.did !== activeDid"
class="flex justify-end" class="flex justify-end"
@ -99,7 +101,7 @@
title="Contact info" title="Contact info"
@click=" @click="
informAboutAddingContact( informAboutAddingContact(
getContactFor(member.did) !== undefined, getContactFor(member.did) !== undefined
) )
" "
> >
@ -157,138 +159,138 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from 'vue-facing-decorator'
import { import {
logConsoleAndDb, logConsoleAndDb,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
db, db
} from "../db/index"; } from '../db/index'
import { import {
errorStringForLog, errorStringForLog,
getHeaders, getHeaders,
register, register,
serverMessageForUser, serverMessageForUser
} from "../libs/endorserServer"; } from '../libs/endorserServer'
import { decryptMessage } from "../libs/crypto"; import { decryptMessage } from '../libs/crypto'
import { Contact } from "../db/tables/contacts"; import { Contact } from '../db/tables/contacts'
import * as libsUtil from "../libs/util"; import * as libsUtil from '../libs/util'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
interface Member { interface Member {
admitted: boolean; admitted: boolean
content: string; content: string
memberId: number; memberId: number
} }
interface DecryptedMember { interface DecryptedMember {
member: Member; member: Member
name: string; name: string
did: string; did: string
isRegistered: boolean; isRegistered: boolean
} }
@Component @Component
export default class MembersList extends Vue { export default class MembersList extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
libsUtil = libsUtil; libsUtil = libsUtil
@Prop({ required: true }) password!: string; @Prop({ required: true }) password!: string
@Prop({ default: false }) showOrganizerTools!: boolean; @Prop({ default: false }) showOrganizerTools!: boolean
decryptedMembers: DecryptedMember[] = []; decryptedMembers: DecryptedMember[] = []
firstName = ""; firstName = ''
isLoading = true; isLoading = true
isOrganizer = false; isOrganizer = false
members: Member[] = []; members: Member[] = []
missingPassword = false; missingPassword = false
missingMyself = false; missingMyself = false
activeDid = ""; activeDid = ''
apiServer = ""; apiServer = ''
contacts: Array<Contact> = []; contacts: Array<Contact> = []
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || ''
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || ''
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || ''
await this.fetchMembers(); await this.fetchMembers()
await this.loadContacts(); await this.loadContacts()
} }
async fetchMembers() { async fetchMembers() {
try { try {
this.isLoading = true; this.isLoading = true
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid)
const response = await this.axios.get( const response = await this.axios.get(
`${this.apiServer}/api/partner/groupOnboardMembers`, `${this.apiServer}/api/partner/groupOnboardMembers`,
{ headers }, { headers }
); )
if (response.data && response.data.data) { if (response.data && response.data.data) {
this.members = response.data.data; this.members = response.data.data
await this.decryptMemberContents(); await this.decryptMemberContents()
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
"Error fetching members: " + errorStringForLog(error), 'Error fetching members: ' + errorStringForLog(error),
true, true
); )
this.$emit( this.$emit(
"error", 'error',
serverMessageForUser(error) || "Failed to fetch members.", serverMessageForUser(error) || 'Failed to fetch members.'
); )
} finally { } finally {
this.isLoading = false; this.isLoading = false
} }
} }
async decryptMemberContents() { async decryptMemberContents() {
this.decryptedMembers = []; this.decryptedMembers = []
if (!this.password) { if (!this.password) {
this.missingPassword = true; this.missingPassword = true
return; return
} }
let isFirstEntry = true, let isFirstEntry = true,
foundMyself = false; foundMyself = false
for (const member of this.members) { for (const member of this.members) {
try { try {
const decryptedContent = await decryptMessage( const decryptedContent = await decryptMessage(
member.content, member.content,
this.password, this.password
); )
const content = JSON.parse(decryptedContent); const content = JSON.parse(decryptedContent)
this.decryptedMembers.push({ this.decryptedMembers.push({
member: member, member: member,
name: content.name, name: content.name,
did: content.did, did: content.did,
isRegistered: !!content.isRegistered, isRegistered: !!content.isRegistered
}); })
if (isFirstEntry && content.did === this.activeDid) { if (isFirstEntry && content.did === this.activeDid) {
this.isOrganizer = true; this.isOrganizer = true
} }
if (content.did === this.activeDid) { if (content.did === this.activeDid) {
foundMyself = true; foundMyself = true
} }
} catch (error) { } catch (error) {
// do nothing, relying on the count of members to determine if there was an error // do nothing, relying on the count of members to determine if there was an error
} }
isFirstEntry = false; isFirstEntry = false
} }
this.missingMyself = !foundMyself; this.missingMyself = !foundMyself
} }
decryptionErrorMessage(): string { decryptionErrorMessage(): string {
if (this.isOrganizer) { if (this.isOrganizer) {
if (this.decryptedMembers.length < this.members.length) { if (this.decryptedMembers.length < this.members.length) {
return "Some members have data that cannot be decrypted with that password."; return 'Some members have data that cannot be decrypted with that password.'
} else { } else {
// the lists must be equal // the lists must be equal
return ""; return ''
} }
} else { } else {
// non-organizers should only see problems if the first (organizer) member is not decrypted // non-organizers should only see problems if the first (organizer) member is not decrypted
@ -296,10 +298,10 @@ export default class MembersList extends Vue {
this.decryptedMembers.length === 0 || this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) { ) {
return "Your password is not the same as the organizer. Reload or have them check their password."; return 'Your password is not the same as the organizer. Reload or have them check their password.'
} else { } else {
// the first (organizer) member was decrypted OK // the first (organizer) member was decrypted OK
return ""; return ''
} }
} }
} }
@ -307,118 +309,118 @@ export default class MembersList extends Vue {
membersToShow(): DecryptedMember[] { membersToShow(): DecryptedMember[] {
if (this.isOrganizer) { if (this.isOrganizer) {
if (this.showOrganizerTools) { if (this.showOrganizerTools) {
return this.decryptedMembers; return this.decryptedMembers
} else { } else {
return this.decryptedMembers.filter( return this.decryptedMembers.filter(
(member: DecryptedMember) => member.member.admitted, (member: DecryptedMember) => member.member.admitted
); )
} }
} }
// non-organizers only get visible members from server // non-organizers only get visible members from server
return this.decryptedMembers; return this.decryptedMembers
} }
informAboutAdmission() { informAboutAdmission() {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Admission info", title: 'Admission info',
text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered.", text: "This is to register people in Time Safari and to admit them to the meeting. A '+' symbol means they are not yet admitted and you can register and admit them. A '-' means you can remove them, but they will stay registered."
}, },
10000, 10000
); )
} }
informAboutAddingContact(contactImportedAlready: boolean) { informAboutAddingContact(contactImportedAlready: boolean) {
if (contactImportedAlready) { if (contactImportedAlready) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Contact Exists", title: 'Contact Exists',
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.", text: 'They are in your contacts. If you want to remove them, you must do that from the contacts screen.'
}, },
10000, 10000
); )
} else { } else {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Contact Available", title: 'Contact Available',
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.", text: 'This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.'
}, },
10000, 10000
); )
} }
} }
async loadContacts() { async loadContacts() {
this.contacts = await db.contacts.toArray(); this.contacts = await db.contacts.toArray()
} }
getContactFor(did: string): Contact | undefined { getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did); return this.contacts.find((contact) => contact.did === did)
} }
checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) { checkWhetherContactBeforeAdmitting(decrMember: DecryptedMember) {
const contact = this.getContactFor(decrMember.did); const contact = this.getContactFor(decrMember.did)
if (!decrMember.member.admitted && !contact) { if (!decrMember.member.admitted && !contact) {
// If not a contact, show confirmation dialog // If not a contact, show confirmation dialog
this.$notify( this.$notify(
{ {
group: "modal", group: 'modal',
type: "confirm", type: 'confirm',
title: "Add as Contact First?", title: 'Add as Contact First?',
text: "This person is not in your contacts. Would you like to add them as a contact first?", text: 'This person is not in your contacts. Would you like to add them as a contact first?',
yesText: "Add as Contact", yesText: 'Add as Contact',
noText: "Skip Adding Contact", noText: 'Skip Adding Contact',
onYes: async () => { onYes: async () => {
await this.addAsContact(decrMember); await this.addAsContact(decrMember)
// After adding as contact, proceed with admission // After adding as contact, proceed with admission
await this.toggleAdmission(decrMember); await this.toggleAdmission(decrMember)
}, },
onNo: async () => { onNo: async () => {
// If they choose not to add as contact, show second confirmation // If they choose not to add as contact, show second confirmation
this.$notify( this.$notify(
{ {
group: "modal", group: 'modal',
type: "confirm", type: 'confirm',
title: "Continue Without Adding?", title: 'Continue Without Adding?',
text: "Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.", text: 'Are you sure you want to proceed with admission? If they are not a contact, you will not know their name after this meeting.',
yesText: "Continue", yesText: 'Continue',
onYes: async () => { onYes: async () => {
await this.toggleAdmission(decrMember); await this.toggleAdmission(decrMember)
}, },
onCancel: async () => { onCancel: async () => {
// Do nothing, effectively canceling the operation // Do nothing, effectively canceling the operation
}
}, },
-1
)
}
}, },
-1, -1
); )
},
},
-1,
);
} else { } else {
// If already a contact, proceed directly with admission // If already a contact, proceed directly with admission
this.toggleAdmission(decrMember); this.toggleAdmission(decrMember)
} }
} }
async toggleAdmission(decrMember: DecryptedMember) { async toggleAdmission(decrMember: DecryptedMember) {
try { try {
const headers = await getHeaders(this.activeDid); const headers = await getHeaders(this.activeDid)
await this.axios.put( await this.axios.put(
`${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`, `${this.apiServer}/api/partner/groupOnboardMember/${decrMember.member.memberId}`,
{ admitted: !decrMember.member.admitted }, { admitted: !decrMember.member.admitted },
{ headers }, { headers }
); )
// Update local state // Update local state
decrMember.member.admitted = !decrMember.member.admitted; decrMember.member.admitted = !decrMember.member.admitted
const oldContact = this.getContactFor(decrMember.did); const oldContact = this.getContactFor(decrMember.did)
// if admitted, now register that user if they are not registered // if admitted, now register that user if they are not registered
if ( if (
decrMember.member.admitted && decrMember.member.admitted &&
@ -427,61 +429,61 @@ export default class MembersList extends Vue {
) { ) {
const contactOldOrNew: Contact = oldContact || { const contactOldOrNew: Contact = oldContact || {
did: decrMember.did, did: decrMember.did,
name: decrMember.name, name: decrMember.name
}; }
try { try {
const result = await register( const result = await register(
this.activeDid, this.activeDid,
this.apiServer, this.apiServer,
this.axios, this.axios,
contactOldOrNew, contactOldOrNew
); )
if (result.success) { if (result.success) {
decrMember.isRegistered = true; decrMember.isRegistered = true
if (oldContact) { if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true }); await db.contacts.update(decrMember.did, { registered: true })
oldContact.registered = true; oldContact.registered = true
} }
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "success", type: 'success',
title: "Registered", title: 'Registered',
text: "Besides being admitted, they were also registered.", text: 'Besides being admitted, they were also registered.'
}, },
3000, 3000
); )
} else { } else {
throw result; throw result
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
// registration failure is likely explained by a message from the server // registration failure is likely explained by a message from the server
const additionalInfo = const additionalInfo =
serverMessageForUser(error) || error?.error || ""; serverMessageForUser(error) || error?.error || ''
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "warning", type: 'warning',
title: "Registration failed", title: 'Registration failed',
text: text:
"They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. " + 'They were admitted to the meeting. However, registration failed. You can register them from the contacts screen. ' +
additionalInfo, additionalInfo
}, },
12000, 12000
); )
} }
} }
} catch (error) { } catch (error) {
logConsoleAndDb( logConsoleAndDb(
"Error toggling admission: " + errorStringForLog(error), 'Error toggling admission: ' + errorStringForLog(error),
true, true
); )
this.$emit( this.$emit(
"error", 'error',
serverMessageForUser(error) || serverMessageForUser(error) ||
"Failed to update member admission status.", 'Failed to update member admission status.'
); )
} }
} }
@ -489,36 +491,36 @@ export default class MembersList extends Vue {
try { try {
const newContact = { const newContact = {
did: member.did, did: member.did,
name: member.name, name: member.name
}; }
await db.contacts.add(newContact); await db.contacts.add(newContact)
this.contacts.push(newContact); this.contacts.push(newContact)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "success", type: 'success',
title: "Contact Added", title: 'Contact Added',
text: "They were added to your contacts.", text: 'They were added to your contacts.'
}, },
3000, 3000
); )
} catch (err) { } catch (err) {
logConsoleAndDb("Error adding contact: " + errorStringForLog(err), true); logConsoleAndDb('Error adding contact: ' + errorStringForLog(err), true)
let message = "An error prevented adding this contact."; let message = 'An error prevented adding this contact.'
if (err instanceof Error && err.message?.indexOf("already exists") > -1) { if (err instanceof Error && err.message?.indexOf('already exists') > -1) {
message = "This person is already in your contact list."; message = 'This person is already in your contact list.'
} }
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Contact Not Added", title: 'Contact Not Added',
text: message, text: message
}, },
5000, 5000
); )
} }
} }
} }

205
src/components/OfferDialog.vue

@ -49,8 +49,8 @@
projectName, projectName,
recipientDid, recipientDid,
recipientName, recipientName,
unitCode: amountUnitCode, unitCode: amountUnitCode
}, }
}" }"
class="text-blue-500" class="text-blue-500"
> >
@ -80,117 +80,114 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from 'vue-facing-decorator'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
import { import {
createAndSubmitOffer, createAndSubmitOffer,
serverMessageForUser, serverMessageForUser
} from "../libs/endorserServer"; } from '../libs/endorserServer'
import * as libsUtil from "../libs/util"; import * as libsUtil from '../libs/util'
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from '../db/index'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
@Component @Component
export default class OfferDialog extends Vue { export default class OfferDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
@Prop projectId?: string; @Prop projectId?: string
@Prop projectName?: string; @Prop projectName?: string
activeDid = ""; activeDid = ''
apiServer = ""; apiServer = ''
amountInput = "0"; amountInput = '0'
amountUnitCode = "HUR"; amountUnitCode = 'HUR'
description = ""; description = ''
expirationDateInput = ""; expirationDateInput = ''
recipientDid? = ""; recipientDid? = ''
recipientName? = ""; recipientName? = ''
visible = false; visible = false
libsUtil = libsUtil; libsUtil = libsUtil
async open(recipientDid?: string, recipientName?: string) { async open(recipientDid?: string, recipientName?: string) {
try { try {
this.recipientDid = recipientDid; this.recipientDid = recipientDid
this.recipientName = recipientName; this.recipientName = recipientName
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || ''
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) { } catch (err: any) {
logger.error("Error retrieving settings from database:", err); logger.error('Error retrieving settings from database:', err)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: err.message || "There was an error retrieving your settings.", text: err.message || 'There was an error retrieving your settings.'
}, },
-1, -1
); )
} }
this.visible = true; this.visible = true
} }
close() { close() {
// close the dialog but don't change values (since it might be submitting info) // close the dialog but don't change values (since it might be submitting info)
this.visible = false; this.visible = false
} }
changeUnitCode() { changeUnitCode() {
const units = Object.keys(this.libsUtil.UNIT_SHORT); const units = Object.keys(this.libsUtil.UNIT_SHORT)
const index = units.indexOf(this.amountUnitCode); const index = units.indexOf(this.amountUnitCode)
this.amountUnitCode = units[(index + 1) % units.length]; this.amountUnitCode = units[(index + 1) % units.length]
} }
increment() { increment() {
this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`; this.amountInput = `${(parseFloat(this.amountInput) || 0) + 1}`
} }
decrement() { decrement() {
this.amountInput = `${Math.max( this.amountInput = `${Math.max(0, (parseFloat(this.amountInput) || 1) - 1)}`
0,
(parseFloat(this.amountInput) || 1) - 1,
)}`;
} }
cancel() { cancel() {
this.close(); this.close()
this.eraseValues(); this.eraseValues()
} }
eraseValues() { eraseValues() {
this.description = ""; this.description = ''
this.amountInput = "0"; this.amountInput = '0'
this.amountUnitCode = "HUR"; this.amountUnitCode = 'HUR'
} }
async confirm() { async confirm() {
this.close(); this.close()
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "toast", type: 'toast',
text: "Recording the offer...", text: 'Recording the offer...',
title: "", title: ''
}, },
1000, 1000
); )
// this is asynchronous, but we don't need to wait for it to complete // this is asynchronous, but we don't need to wait for it to complete
this.recordOffer( this.recordOffer(
this.description, this.description,
parseFloat(this.amountInput), parseFloat(this.amountInput),
this.amountUnitCode, this.amountUnitCode,
this.expirationDateInput, this.expirationDateInput
).then(() => { ).then(() => {
this.description = ""; this.description = ''
this.amountInput = "0"; this.amountInput = '0'
}); })
} }
/** /**
@ -202,33 +199,33 @@ export default class OfferDialog extends Vue {
public async recordOffer( public async recordOffer(
description: string, description: string,
amount: number, amount: number,
unitCode: string = "HUR", unitCode: string = 'HUR',
expirationDateInput?: string, expirationDateInput?: string
) { ) {
if (!this.activeDid) { if (!this.activeDid) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: "You must select an identity before you can record an offer.", text: 'You must select an identity before you can record an offer.'
}, },
7000, 7000
); )
return; return
} }
if (!description && !amount) { if (!description && !amount) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`, text: `You must enter a description or some number of ${this.libsUtil.UNIT_LONG[unitCode]}.`
}, },
-1, -1
); )
return; return
} }
try { try {
@ -239,54 +236,54 @@ export default class OfferDialog extends Vue {
description, description,
amount, amount,
unitCode, unitCode,
"", '',
expirationDateInput, expirationDateInput,
this.recipientDid, this.recipientDid,
this.projectId, this.projectId
); )
if ( if (
result.type === "error" || result.type === 'error' ||
this.isOfferCreationError(result.response) this.isOfferCreationError(result.response)
) { ) {
const errorMessage = this.getOfferCreationErrorMessage(result); const errorMessage = this.getOfferCreationErrorMessage(result)
logger.error("Error with offer creation result:", result); logger.error('Error with offer creation result:', result)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: errorMessage || "There was an error creating the offer.", text: errorMessage || 'There was an error creating the offer.'
}, },
-1, -1
); )
} else { } else {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "success", type: 'success',
title: "Success", title: 'Success',
text: "That offer was recorded.", text: 'That offer was recorded.'
}, },
5000, 5000
); )
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {
logger.error("Error with offer recordation caught:", error); logger.error('Error with offer recordation caught:', error)
const message = const message =
error.userMessage || error.userMessage ||
error.response?.data?.error?.message || error.response?.data?.error?.message ||
"There was an error recording the offer."; 'There was an error recording the offer.'
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: message, text: message
}, },
-1, -1
); )
} }
} }
@ -298,7 +295,7 @@ export default class OfferDialog extends Vue {
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
isOfferCreationError(result: any) { isOfferCreationError(result: any) {
return result.status !== 201 || result.data?.error; return result.status !== 201 || result.data?.error
} }
/** /**
@ -311,7 +308,7 @@ export default class OfferDialog extends Vue {
serverMessageForUser(result) || serverMessageForUser(result) ||
result.error?.userMessage || result.error?.userMessage ||
result.error?.error result.error?.error
); )
} }
} }
</script> </script>

64
src/components/OnboardingDialog.vue

@ -198,64 +198,64 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from 'vue-facing-decorator'
import { Router } from "vue-router"; import { Router } from 'vue-router'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
import { import {
db, db,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings, updateAccountSettings
} from "../db/index"; } from '../db/index'
import { OnboardPage } from "../libs/util"; import { OnboardPage } from '../libs/util'
@Component({ @Component({
computed: { computed: {
OnboardPage() { OnboardPage() {
return OnboardPage; return OnboardPage
}, }
}, },
components: { OnboardPage }, components: { OnboardPage }
}) })
export default class OnboardingDialog extends Vue { export default class OnboardingDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
$router!: Router; $router!: Router
activeDid = ""; activeDid = ''
firstContactName = null; firstContactName = null
givenName = ""; givenName = ''
isRegistered = false; isRegistered = false
numContacts = 0; numContacts = 0
page = OnboardPage.Home; page = OnboardPage.Home
visible = false; visible = false
async open(page: OnboardPage) { async open(page: OnboardPage) {
this.page = page; this.page = page
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || ''
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered
const contacts = await db.contacts.toArray(); const contacts = await db.contacts.toArray()
this.numContacts = contacts.length; this.numContacts = contacts.length
if (this.numContacts > 0) { if (this.numContacts > 0) {
this.firstContactName = contacts[0].name; this.firstContactName = contacts[0].name
} }
this.visible = true; this.visible = true
if (this.page === OnboardPage.Create) { if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages // we'll assume that they've been through all the other pages
await updateAccountSettings(this.activeDid, { await updateAccountSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true
}); })
} }
} }
async onClickClose(done?: boolean, goHome?: boolean) { async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false; this.visible = false
if (done) { if (done) {
await updateAccountSettings(this.activeDid, { await updateAccountSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true
}); })
if (goHome) { if (goHome) {
this.$router.push({ name: "home" }); this.$router.push({ name: 'home' })
} }
} }
} }

250
src/components/PhotoDialog.vue

@ -15,7 +15,7 @@
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white" class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]" />
</div> </div>
</div> </div>
@ -30,13 +30,13 @@
<VuePictureCropper <VuePictureCropper
:box-style="{ :box-style="{
backgroundColor: '#f8f8f8', backgroundColor: '#f8f8f8',
margin: 'auto', margin: 'auto'
}" }"
:img="createBlobURL(blob)" :img="createBlobURL(blob)"
:options="{ :options="{
viewMode: 1, viewMode: 1,
dragMode: 'crop', dragMode: 'crop',
aspectRatio: 9 / 9, aspectRatio: 9 / 9
}" }"
class="max-h-[90vh] max-w-[90vw] object-contain" class="max-h-[90vh] max-w-[90vw] object-contain"
/> />
@ -100,47 +100,47 @@
* @file PhotoDialog.vue * @file PhotoDialog.vue
*/ */
import axios from "axios"; import axios from 'axios'
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from 'vue-facing-decorator'
import VuePictureCropper, { cropper } from "vue-picture-cropper"; import VuePictureCropper, { cropper } from 'vue-picture-cropper'
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from '../constants/app'
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from '../db/index'
import { accessToken } from "../libs/crypto"; import { accessToken } from '../libs/crypto'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from '../services/PlatformServiceFactory'
@Component({ components: { VuePictureCropper } }) @Component({ components: { VuePictureCropper } })
export default class PhotoDialog extends Vue { export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
activeDid = ""; activeDid = ''
blob?: Blob; blob?: Blob
claimType = ""; claimType = ''
crop = false; crop = false
fileName?: string; fileName?: string
setImageCallback: (arg: string) => void = () => {}; setImageCallback: (arg: string) => void = () => {}
showRetry = true; showRetry = true
uploading = false; uploading = false
visible = false; visible = false
private platformService = PlatformServiceFactory.getInstance(); private platformService = PlatformServiceFactory.getInstance()
URL = window.URL || window.webkitURL; URL = window.URL || window.webkitURL
async mounted() { async mounted() {
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || ''
} catch (err: unknown) { } catch (err: unknown) {
logger.error("Error retrieving settings from database:", err); logger.error('Error retrieving settings from database:', err)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: err.message || "There was an error retrieving your settings.", text: err.message || 'There was an error retrieving your settings.'
}, },
-1, -1
); )
} }
} }
@ -149,140 +149,140 @@ export default class PhotoDialog extends Vue {
claimType: string, claimType: string,
crop?: boolean, crop?: boolean,
blob?: Blob, blob?: Blob,
inputFileName?: string, inputFileName?: string
) { ) {
this.visible = true; this.visible = true
this.claimType = claimType; this.claimType = claimType
this.crop = !!crop; this.crop = !!crop
const bottomNav = document.querySelector("#QuickNav") as HTMLElement; const bottomNav = document.querySelector('#QuickNav') as HTMLElement
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = "none"; bottomNav.style.display = 'none'
} }
this.setImageCallback = setImageFn; this.setImageCallback = setImageFn
if (blob) { if (blob) {
this.blob = blob; this.blob = blob
this.fileName = inputFileName; this.fileName = inputFileName
this.showRetry = false; this.showRetry = false
} else { } else {
this.blob = undefined; this.blob = undefined
this.fileName = undefined; this.fileName = undefined
this.showRetry = true; this.showRetry = true
} }
} }
close() { close() {
this.visible = false; this.visible = false
const bottomNav = document.querySelector("#QuickNav") as HTMLElement; const bottomNav = document.querySelector('#QuickNav') as HTMLElement
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = ""; bottomNav.style.display = ''
} }
this.blob = undefined; this.blob = undefined
} }
async takePhoto() { async takePhoto() {
try { try {
const result = await this.platformService.takePicture(); const result = await this.platformService.takePicture()
this.blob = result.blob; this.blob = result.blob
this.fileName = result.fileName; this.fileName = result.fileName
} catch (error: unknown) { } catch (error: unknown) {
logger.error("Error taking picture:", error); logger.error('Error taking picture:', error)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: "Failed to take picture. Please try again.", text: 'Failed to take picture. Please try again.'
}, },
5000, 5000
); )
} }
} }
async pickPhoto() { async pickPhoto() {
try { try {
const result = await this.platformService.pickImage(); const result = await this.platformService.pickImage()
this.blob = result.blob; this.blob = result.blob
this.fileName = result.fileName; this.fileName = result.fileName
} catch (error: unknown) { } catch (error: unknown) {
logger.error("Error picking image:", error); logger.error('Error picking image:', error)
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: "Failed to pick image. Please try again.", text: 'Failed to pick image. Please try again.'
}, },
5000, 5000
); )
} }
} }
private createBlobURL(blob: Blob): string { private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob); return URL.createObjectURL(blob)
} }
async retryImage() { async retryImage() {
this.blob = undefined; this.blob = undefined
} }
async uploadImage() { async uploadImage() {
this.uploading = true; this.uploading = true
if (this.crop) { if (this.crop) {
this.blob = (await cropper?.getBlob()) || undefined; this.blob = (await cropper?.getBlob()) || undefined
} }
const token = await accessToken(this.activeDid); const token = await accessToken(this.activeDid)
const headers = { const headers = {
Authorization: "Bearer " + token, Authorization: 'Bearer ' + token
}; }
const formData = new FormData(); const formData = new FormData()
if (!this.blob) { if (!this.blob) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: "There was an error finding the picture. Please try again.", text: 'There was an error finding the picture. Please try again.'
}, },
5000, 5000
); )
this.uploading = false; this.uploading = false
return; return
} }
formData.append("image", this.blob, this.fileName || "photo.jpg"); formData.append('image', this.blob, this.fileName || 'photo.jpg')
formData.append("claimType", this.claimType); formData.append('claimType', this.claimType)
try { try {
if ( if (
window.location.hostname === "localhost" && window.location.hostname === 'localhost' &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost") !DEFAULT_IMAGE_API_SERVER.includes('localhost')
) { ) {
logger.log( logger.log(
"Using shared image API server, so only users on that server can play with images.", 'Using shared image API server, so only users on that server can play with images.'
); )
} }
const response = await axios.post( const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image", DEFAULT_IMAGE_API_SERVER + '/image',
formData, formData,
{ headers }, { headers }
); )
this.uploading = false; this.uploading = false
this.close(); this.close()
this.setImageCallback(response.data.url as string); this.setImageCallback(response.data.url as string)
} catch (error: unknown) { } catch (error: unknown) {
// Log the raw error first // Log the raw error first
logger.error("Raw error object:", JSON.stringify(error, null, 2)); logger.error('Raw error object:', JSON.stringify(error, null, 2))
let errorMessage = "There was an error saving the picture."; let errorMessage = 'There was an error saving the picture.'
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const status = error.response?.status; const status = error.response?.status
const statusText = error.response?.statusText; const statusText = error.response?.statusText
const data = error.response?.data; const data = error.response?.data
// Log detailed error information // Log detailed error information
logger.error("Upload error details:", { logger.error('Upload error details:', {
status, status,
statusText, statusText,
data: JSON.stringify(data, null, 2), data: JSON.stringify(data, null, 2),
@ -290,49 +290,49 @@ export default class PhotoDialog extends Vue {
config: { config: {
url: error.config?.url, url: error.config?.url,
method: error.config?.method, method: error.config?.method,
headers: error.config?.headers, headers: error.config?.headers
}, }
}); })
if (status === 401) { if (status === 401) {
errorMessage = "Authentication failed. Please try logging in again."; errorMessage = 'Authentication failed. Please try logging in again.'
} else if (status === 413) { } else if (status === 413) {
errorMessage = "Image file is too large. Please try a smaller image."; errorMessage = 'Image file is too large. Please try a smaller image.'
} else if (status === 415) { } else if (status === 415) {
errorMessage = errorMessage =
"Unsupported image format. Please try a different image."; 'Unsupported image format. Please try a different image.'
} else if (status && status >= 500) { } else if (status && status >= 500) {
errorMessage = "Server error. Please try again later."; errorMessage = 'Server error. Please try again later.'
} else if (data?.message) { } else if (data?.message) {
errorMessage = data.message; errorMessage = data.message
} }
} else if (error instanceof Error) { } else if (error instanceof Error) {
// Log non-Axios error with full details // Log non-Axios error with full details
logger.error("Non-Axios error details:", { logger.error('Non-Axios error details:', {
name: error.name, name: error.name,
message: error.message, message: error.message,
stack: error.stack, stack: error.stack,
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2), error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2)
}); })
} else { } else {
// Log any other type of error // Log any other type of error
logger.error("Unknown error type:", { logger.error('Unknown error type:', {
error: JSON.stringify(error, null, 2), error: JSON.stringify(error, null, 2),
type: typeof error, type: typeof error
}); })
} }
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: errorMessage, text: errorMessage
}, },
5000, 5000
); )
this.uploading = false; this.uploading = false
this.blob = undefined; this.blob = undefined
} }
} }
} }

28
src/components/ProjectIcon.vue

@ -15,35 +15,35 @@
/> />
</template> </template>
<script lang="ts"> <script lang="ts">
import { toSvg } from "jdenticon"; import { toSvg } from 'jdenticon'
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from 'vue-facing-decorator'
const BLANK_CONFIG = { const BLANK_CONFIG = {
lightness: { lightness: {
color: [1.0, 1.0], color: [1.0, 1.0],
grayscale: [1.0, 1.0], grayscale: [1.0, 1.0]
}, },
saturation: { saturation: {
color: 0.0, color: 0.0,
grayscale: 0.0, grayscale: 0.0
}, },
backColor: "#0000", backColor: '#0000'
}; }
@Component @Component
export default class ProjectIcon extends Vue { export default class ProjectIcon extends Vue {
@Prop entityId = ""; @Prop entityId = ''
@Prop iconSize = 0; @Prop iconSize = 0
@Prop imageUrl = ""; @Prop imageUrl = ''
@Prop linkToFull = false; @Prop linkToFull = false
generateIdenticon() { generateIdenticon() {
if (this.imageUrl) { if (this.imageUrl) {
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`; return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`
} else { } else {
const config = this.entityId ? undefined : BLANK_CONFIG; const config = this.entityId ? undefined : BLANK_CONFIG
const svgString = toSvg(this.entityId, this.iconSize, config); const svgString = toSvg(this.entityId, this.iconSize, config)
return svgString; return svgString
} }
} }
} }

456
src/components/PushNotificationPermission.vue

@ -45,7 +45,7 @@
> >
<!-- eslint-enable --> <!-- eslint-enable -->
<span class="w-full flex justify-between text-xs text-slate-500"> <span class="w-full flex justify-between text-xs text-slate-500">
<span></span> <span />
<span>(100 characters max)</span> <span>(100 characters max)</span>
</span> </span>
</div> </div>
@ -79,8 +79,8 @@
<button <button
class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md" class="block w-full text-center text-md font-bold uppercase bg-blue-600 text-white mt-2 px-2 py-2 rounded-md"
@click=" @click="
close(); close()
turnOnNotifications(); turnOnNotifications()
" "
> >
Turn on Daily Message Turn on Daily Message
@ -100,46 +100,46 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from 'vue-facing-decorator'
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app"; import { DEFAULT_PUSH_SERVER, NotificationIface } from '../constants/app'
import { import {
logConsoleAndDb, logConsoleAndDb,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
secretDB, secretDB
} from "../db/index"; } from '../db/index'
import { MASTER_SECRET_KEY } from "../db/tables/secret"; import { MASTER_SECRET_KEY } from '../db/tables/secret'
import { urlBase64ToUint8Array } from "../libs/crypto/vc/util"; import { urlBase64ToUint8Array } from '../libs/crypto/vc/util'
import * as libsUtil from "../libs/util"; import * as libsUtil from '../libs/util'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
// Example interface for error // Example interface for error
interface ErrorResponse { interface ErrorResponse {
message: string; message: string
} }
// PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson // PushSubscriptionJSON is defined in the Push API https://www.w3.org/TR/push-api/#dom-pushsubscriptionjson
interface PushSubscriptionWithTime extends PushSubscriptionJSON { interface PushSubscriptionWithTime extends PushSubscriptionJSON {
message?: string; message?: string
notifyTime: { utcHour: number; minute: number }; notifyTime: { utcHour: number; minute: number }
notifyType: string; notifyType: string
} }
interface ServiceWorkerMessage { interface ServiceWorkerMessage {
type: string; type: string
data: string; data: string
} }
interface ServiceWorkerResponse { interface ServiceWorkerResponse {
// Define the properties and their types // Define the properties and their types
success: boolean; success: boolean
message?: string; message?: string
} }
interface VapidResponse { interface VapidResponse {
data: { data: {
vapidKey: string; vapidKey: string
}; }
} }
@Component @Component
@ -147,135 +147,135 @@ export default class PushNotificationPermission extends Vue {
// eslint-disable-next-line // eslint-disable-next-line
$notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>; $notify!: (notification: NotificationIface, timeout?: number) => Promise<() => void>;
DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE; DAILY_CHECK_TITLE = libsUtil.DAILY_CHECK_TITLE
DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE; DIRECT_PUSH_TITLE = libsUtil.DIRECT_PUSH_TITLE
callback: (success: boolean, time: string, message?: string) => void = callback: (success: boolean, time: string, message?: string) => void =
() => {}; () => {}
hourAm = true; hourAm = true
hourInput = "8"; hourInput = '8'
isVisible = false; isVisible = false
messageInput = ""; messageInput = ''
minuteInput = "00"; minuteInput = '00'
pushType = ""; pushType = ''
serviceWorkerReady = false; serviceWorkerReady = false
vapidKey = ""; vapidKey = ''
async open( async open(
pushType: string, pushType: string,
callback?: (success: boolean, time: string, message?: string) => void, callback?: (success: boolean, time: string, message?: string) => void
) { ) {
this.callback = callback || this.callback; this.callback = callback || this.callback
this.isVisible = true; this.isVisible = true
this.pushType = pushType; this.pushType = pushType
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
let pushUrl = DEFAULT_PUSH_SERVER; let pushUrl = DEFAULT_PUSH_SERVER
if (settings?.webPushServer) { if (settings?.webPushServer) {
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer
} }
if (pushUrl.startsWith("http://localhost")) { if (pushUrl.startsWith('http://localhost')) {
logConsoleAndDb("Not checking for VAPID in this local environment."); logConsoleAndDb('Not checking for VAPID in this local environment.')
} else { } else {
let responseData = ""; let responseData = ''
await this.axios await this.axios
.get(pushUrl + "/web-push/vapid") .get(pushUrl + '/web-push/vapid')
.then((response: VapidResponse) => { .then((response: VapidResponse) => {
this.vapidKey = response.data?.vapidKey || ""; this.vapidKey = response.data?.vapidKey || ''
logConsoleAndDb("Got vapid key: " + this.vapidKey); logConsoleAndDb('Got vapid key: ' + this.vapidKey)
responseData = JSON.stringify(response.data); responseData = JSON.stringify(response.data)
navigator.serviceWorker?.addEventListener( navigator.serviceWorker?.addEventListener(
"controllerchange", 'controllerchange',
() => { () => {
logConsoleAndDb( logConsoleAndDb(
"New service worker is now controlling the page", 'New service worker is now controlling the page'
); )
}, }
); )
}); })
if (!this.vapidKey) { if (!this.vapidKey) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error Setting Notifications", title: 'Error Setting Notifications',
text: "Could not set notifications.", text: 'Could not set notifications.'
}, },
5000, 5000
); )
logConsoleAndDb( logConsoleAndDb(
"Error Setting Notifications: web push server response didn't have vapidKey: " + "Error Setting Notifications: web push server response didn't have vapidKey: " +
responseData, responseData,
true, true
); )
} }
} }
} catch (error) { } catch (error) {
if (window.location.host.startsWith("localhost")) { if (window.location.host.startsWith('localhost')) {
logConsoleAndDb( logConsoleAndDb(
"Ignoring the error getting VAPID for local development.", 'Ignoring the error getting VAPID for local development.'
); )
} else { } else {
logConsoleAndDb( logConsoleAndDb(
"Got an error initializing notifications: " + JSON.stringify(error), 'Got an error initializing notifications: ' + JSON.stringify(error),
true, true
); )
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error Setting Notifications", title: 'Error Setting Notifications',
text: "Got an error setting notifications.", text: 'Got an error setting notifications.'
}, },
5000, 5000
); )
} }
} }
// there may be a long pause here on first initialization // there may be a long pause here on first initialization
navigator.serviceWorker?.ready.then(() => { navigator.serviceWorker?.ready.then(() => {
this.serviceWorkerReady = true; this.serviceWorkerReady = true
}); })
if (this.pushType === this.DIRECT_PUSH_TITLE) { if (this.pushType === this.DIRECT_PUSH_TITLE) {
this.messageInput = this.messageInput =
"Click to share some gratitude with the world -- even if they're unnamed."; "Click to share some gratitude with the world -- even if they're unnamed."
// focus on the message input // focus on the message input
setTimeout(function () { setTimeout(function () {
document.getElementById("push-message")?.focus(); document.getElementById('push-message')?.focus()
}, 100); }, 100)
} else { } else {
// not critical but doesn't make sense in a daily check // not critical but doesn't make sense in a daily check
this.messageInput = ""; this.messageInput = ''
} }
} }
private close() { private close() {
this.isVisible = false; this.isVisible = false
} }
private sendMessageToServiceWorker( private sendMessageToServiceWorker(
message: ServiceWorkerMessage, message: ServiceWorkerMessage
): Promise<unknown> { ): Promise<unknown> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (navigator.serviceWorker?.controller) { if (navigator.serviceWorker?.controller) {
const messageChannel = new MessageChannel(); const messageChannel = new MessageChannel()
messageChannel.port1.onmessage = (event: MessageEvent) => { messageChannel.port1.onmessage = (event: MessageEvent) => {
if (event.data.error) { if (event.data.error) {
reject(event.data.error as ErrorResponse); reject(event.data.error as ErrorResponse)
} else { } else {
resolve(event.data as ServiceWorkerResponse); resolve(event.data as ServiceWorkerResponse)
}
} }
};
navigator.serviceWorker?.controller.postMessage(message, [ navigator.serviceWorker?.controller.postMessage(message, [
messageChannel.port2, messageChannel.port2
]); ])
} else { } else {
reject("Service worker controller not available"); reject('Service worker controller not available')
} }
}); })
} }
private async askPermission(): Promise<NotificationPermission> { private async askPermission(): Promise<NotificationPermission> {
@ -283,136 +283,136 @@ export default class PushNotificationPermission extends Vue {
// "Requesting permission for notifications: " + JSON.stringify(navigator), // "Requesting permission for notifications: " + JSON.stringify(navigator),
// ); // );
if ( if (
!("serviceWorker" in navigator && navigator.serviceWorker?.controller) !('serviceWorker' in navigator && navigator.serviceWorker?.controller)
) { ) {
return Promise.reject("Service worker not available."); return Promise.reject('Service worker not available.')
} }
await secretDB.open(); await secretDB.open()
const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret; const secret = (await secretDB.secret.get(MASTER_SECRET_KEY))?.secret
if (!secret) { if (!secret) {
return Promise.reject("No secret found."); return Promise.reject('No secret found.')
} }
return this.sendSecretToServiceWorker(secret) return this.sendSecretToServiceWorker(secret)
.then(() => this.checkNotificationSupport()) .then(() => this.checkNotificationSupport())
.then(() => this.requestNotificationPermission()) .then(() => this.requestNotificationPermission())
.catch((error) => Promise.reject(error)); .catch((error) => Promise.reject(error))
} }
private sendSecretToServiceWorker(secret: string): Promise<void> { private sendSecretToServiceWorker(secret: string): Promise<void> {
const message: ServiceWorkerMessage = { const message: ServiceWorkerMessage = {
type: "SEND_LOCAL_DATA", type: 'SEND_LOCAL_DATA',
data: secret, data: secret
}; }
return this.sendMessageToServiceWorker(message).then((response) => { return this.sendMessageToServiceWorker(message).then((response) => {
logConsoleAndDb( logConsoleAndDb(
"Response from service worker: " + JSON.stringify(response), 'Response from service worker: ' + JSON.stringify(response)
); )
}); })
} }
private checkNotificationSupport(): Promise<void> { private checkNotificationSupport(): Promise<void> {
if (!("Notification" in window)) { if (!('Notification' in window)) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Browser Notifications Are Not Supported", title: 'Browser Notifications Are Not Supported',
text: "This browser does not support notifications.", text: 'This browser does not support notifications.'
}, },
3000, 3000
); )
return Promise.reject("This browser does not support notifications."); return Promise.reject('This browser does not support notifications.')
} }
if (window.Notification.permission === "granted") { if (window.Notification.permission === 'granted') {
return Promise.resolve(); return Promise.resolve()
} }
return Promise.resolve(); return Promise.resolve()
} }
private requestNotificationPermission(): Promise<NotificationPermission> { private requestNotificationPermission(): Promise<NotificationPermission> {
return window.Notification.requestPermission().then( return window.Notification.requestPermission().then(
(permission: string) => { (permission: string) => {
if (permission !== "granted") { if (permission !== 'granted') {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error Requesting Notification Permission", title: 'Error Requesting Notification Permission',
text: text:
"Allow this app permission to make notifications for personal reminders." + 'Allow this app permission to make notifications for personal reminders.' +
" You can adjust them at any time in your settings.", ' You can adjust them at any time in your settings.'
}, },
-1, -1
); )
throw new Error("Permission was not granted to this app."); throw new Error('Permission was not granted to this app.')
} }
return permission; return permission
}, }
); )
} }
private checkHourInput() { private checkHourInput() {
const hourNum = parseInt(this.hourInput); const hourNum = parseInt(this.hourInput)
if (isNaN(hourNum)) { if (isNaN(hourNum)) {
this.hourInput = "12"; this.hourInput = '12'
} else if (hourNum < 1) { } else if (hourNum < 1) {
this.hourInput = "12"; this.hourInput = '12'
this.hourAm = !this.hourAm; this.hourAm = !this.hourAm
} else if (hourNum > 12) { } else if (hourNum > 12) {
this.hourInput = "1"; this.hourInput = '1'
this.hourAm = !this.hourAm; this.hourAm = !this.hourAm
} else { } else {
this.hourInput = hourNum.toString(); this.hourInput = hourNum.toString()
} }
} }
private checkMinuteInput() { private checkMinuteInput() {
const minuteNum = parseInt(this.minuteInput); const minuteNum = parseInt(this.minuteInput)
if (isNaN(minuteNum)) { if (isNaN(minuteNum)) {
this.minuteInput = "00"; this.minuteInput = '00'
} else if (minuteNum < 0) { } else if (minuteNum < 0) {
this.minuteInput = "59"; this.minuteInput = '59'
} else if (minuteNum < 10) { } else if (minuteNum < 10) {
this.minuteInput = "0" + minuteNum; this.minuteInput = '0' + minuteNum
} else if (minuteNum > 59) { } else if (minuteNum > 59) {
this.minuteInput = "00"; this.minuteInput = '00'
} else { } else {
this.minuteInput = minuteNum.toString(); this.minuteInput = minuteNum.toString()
} }
} }
private async turnOnNotifications() { private async turnOnNotifications() {
let notifyCloser = () => {}; let notifyCloser = () => {}
return this.askPermission() return this.askPermission()
.then((permission) => { .then((permission) => {
logConsoleAndDb("Permission granted: " + JSON.stringify(permission)); logConsoleAndDb('Permission granted: ' + JSON.stringify(permission))
// Call the function and handle promises // Call the function and handle promises
return this.subscribeToPush(); return this.subscribeToPush()
}) })
.then(() => { .then(() => {
logConsoleAndDb("Subscribed successfully."); logConsoleAndDb('Subscribed successfully.')
return navigator.serviceWorker?.ready; return navigator.serviceWorker?.ready
}) })
.then((registration) => { .then((registration) => {
return registration.pushManager.getSubscription(); return registration.pushManager.getSubscription()
}) })
.then(async (subscription) => { .then(async (subscription) => {
if (subscription) { if (subscription) {
notifyCloser = await this.$notify( notifyCloser = await this.$notify(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Notification Setup Underway", title: 'Notification Setup Underway',
text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page.", text: "Setting up notifications for interesting activity, which takes about 10 seconds. If you don't see a final confirmation, check the 'Troubleshoot' page."
}, },
-1, -1
); )
// we already checked that this is a valid hour number // we already checked that this is a valid hour number
const rawHourNum = libsUtil.numberOrZero(this.hourInput); const rawHourNum = libsUtil.numberOrZero(this.hourInput)
const adjHourNum = this.hourAm const adjHourNum = this.hourAm
? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum ? // If it's AM, then we'll change it to 0 for 12 AM but otherwise use rawHourNum
rawHourNum === 12 rawHourNum === 12
@ -421,153 +421,153 @@ export default class PushNotificationPermission extends Vue {
: // Otherwise it's PM, so keep a 12 but otherwise add 12 : // Otherwise it's PM, so keep a 12 but otherwise add 12
rawHourNum === 12 rawHourNum === 12
? 12 ? 12
: rawHourNum + 12; : rawHourNum + 12
const hourNum = adjHourNum % 24; // probably unnecessary now const hourNum = adjHourNum % 24 // probably unnecessary now
const utcHour = const utcHour =
hourNum + Math.round(new Date().getTimezoneOffset() / 60); hourNum + Math.round(new Date().getTimezoneOffset() / 60)
const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24; const finalUtcHour = (utcHour + (utcHour < 0 ? 24 : 0)) % 24
const minuteNum = libsUtil.numberOrZero(this.minuteInput); const minuteNum = libsUtil.numberOrZero(this.minuteInput)
const utcMinute = const utcMinute =
minuteNum + Math.round(new Date().getTimezoneOffset() % 60); minuteNum + Math.round(new Date().getTimezoneOffset() % 60)
const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60; const finalUtcMinute = (utcMinute + (utcMinute < 0 ? 60 : 0)) % 60
const subscriptionWithTime: PushSubscriptionWithTime = { const subscriptionWithTime: PushSubscriptionWithTime = {
notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute }, notifyTime: { utcHour: finalUtcHour, minute: finalUtcMinute },
notifyType: this.pushType, notifyType: this.pushType,
message: this.messageInput, message: this.messageInput,
...subscription.toJSON(), ...subscription.toJSON()
}; }
await this.sendSubscriptionToServer(subscriptionWithTime); await this.sendSubscriptionToServer(subscriptionWithTime)
// To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1 // To help investigate potential issues with this: https://firebase.google.com/docs/cloud-messaging/migrate-v1
logConsoleAndDb( logConsoleAndDb(
"Subscription data sent to server with endpoint: " + 'Subscription data sent to server with endpoint: ' +
subscription.endpoint, subscription.endpoint
); )
return subscriptionWithTime; return subscriptionWithTime
} else { } else {
throw new Error("Subscription object is not available."); throw new Error('Subscription object is not available.')
} }
}) })
.then(async (subscription: PushSubscriptionWithTime) => { .then(async (subscription: PushSubscriptionWithTime) => {
logConsoleAndDb( logConsoleAndDb(
"Subscription data sent to server and all finished successfully.", 'Subscription data sent to server and all finished successfully.'
); )
await libsUtil.sendTestThroughPushServer(subscription, true); await libsUtil.sendTestThroughPushServer(subscription, true)
notifyCloser(); notifyCloser()
setTimeout(() => { setTimeout(() => {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "success", type: 'success',
title: "Notification Is On", title: 'Notification Is On',
text: "You should see at least one on your device; if not, check the 'Troubleshoot' link.", text: "You should see at least one on your device; if not, check the 'Troubleshoot' link."
}, },
7000, 7000
); )
}, 500); }, 500)
const timeText = const timeText =
// eslint-disable-next-line // eslint-disable-next-line
this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM"); this.hourInput + ":" + this.minuteInput + " " + (this.hourAm ? "AM" : "PM");
this.callback(true, timeText, this.messageInput); this.callback(true, timeText, this.messageInput)
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
"Got an error setting notification permissions: " + 'Got an error setting notification permissions: ' +
" string " + ' string ' +
error.toString() + error.toString() +
" JSON " + ' JSON ' +
JSON.stringify(error), JSON.stringify(error),
true, true
); )
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error Setting Notification Permissions", title: 'Error Setting Notification Permissions',
text: "Could not set notification permissions.", text: 'Could not set notification permissions.'
}, },
3000, 3000
); )
// if we want to also unsubscribe, be sure to do that only if no other notification is active // if we want to also unsubscribe, be sure to do that only if no other notification is active
}); })
} }
private subscribeToPush(): Promise<void> { private subscribeToPush(): Promise<void> {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
if (!("serviceWorker" in navigator && "PushManager" in window)) { if (!('serviceWorker' in navigator && 'PushManager' in window)) {
const errorMsg = "Push messaging is not supported"; const errorMsg = 'Push messaging is not supported'
logger.warn(errorMsg); logger.warn(errorMsg)
return reject(new Error(errorMsg)); return reject(new Error(errorMsg))
} }
if (window.Notification.permission !== "granted") { if (window.Notification.permission !== 'granted') {
const errorMsg = "Notification permission not granted"; const errorMsg = 'Notification permission not granted'
logger.warn(errorMsg); logger.warn(errorMsg)
return reject(new Error(errorMsg)); return reject(new Error(errorMsg))
} }
const applicationServerKey = urlBase64ToUint8Array(this.vapidKey); const applicationServerKey = urlBase64ToUint8Array(this.vapidKey)
const options: PushSubscriptionOptions = { const options: PushSubscriptionOptions = {
userVisibleOnly: true, userVisibleOnly: true,
applicationServerKey: applicationServerKey, applicationServerKey: applicationServerKey
}; }
navigator.serviceWorker?.ready navigator.serviceWorker?.ready
.then((registration) => { .then((registration) => {
return registration.pushManager.subscribe(options); return registration.pushManager.subscribe(options)
}) })
.then((subscription) => { .then((subscription) => {
logConsoleAndDb( logConsoleAndDb(
"Push subscription successful: " + JSON.stringify(subscription), 'Push subscription successful: ' + JSON.stringify(subscription)
); )
resolve(); resolve()
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb( logConsoleAndDb(
"Push subscription failed: " + 'Push subscription failed: ' +
JSON.stringify(error) + JSON.stringify(error) +
" - " + ' - ' +
JSON.stringify(options), JSON.stringify(options),
true, true
); )
// Inform the user about the issue // Inform the user about the issue
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error Setting Push Notifications", title: 'Error Setting Push Notifications',
text: text:
"We encountered an issue setting up push notifications. " + 'We encountered an issue setting up push notifications. ' +
"If you wish to revoke notification permissions, please do so in your browser settings.", 'If you wish to revoke notification permissions, please do so in your browser settings.'
}, },
-1, -1
); )
reject(error); reject(error)
}); })
}); })
} }
private sendSubscriptionToServer( private sendSubscriptionToServer(
subscription: PushSubscriptionWithTime, subscription: PushSubscriptionWithTime
): Promise<void> { ): Promise<void> {
logConsoleAndDb( logConsoleAndDb(
"About to send subscription... " + JSON.stringify(subscription), 'About to send subscription... ' + JSON.stringify(subscription)
); )
return fetch("/web-push/subscribe", { return fetch('/web-push/subscribe', {
method: "POST", method: 'POST',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, },
body: JSON.stringify(subscription), body: JSON.stringify(subscription)
}).then((response) => { }).then((response) => {
if (!response.ok) { if (!response.ok) {
logger.error("Bad response subscribing to web push: ", response); logger.error('Bad response subscribing to web push: ', response)
throw new Error("Failed to send push subscription to server"); throw new Error('Failed to send push subscription to server')
} }
logConsoleAndDb("Push subscription sent to server successfully."); logConsoleAndDb('Push subscription sent to server successfully.')
}); })
} }
} }
</script> </script>

124
src/components/QRScanner/CapacitorScanner.ts

@ -0,0 +1,124 @@
import {
BarcodeScanner,
BarcodeFormat,
LensFacing,
ScanResult
} from '@capacitor-mlkit/barcode-scanning'
import type { QRScannerService, ScanListener } from './types'
import { logger } from '../../utils/logger'
export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null
private isScanning = false
private listenerHandles: Array<() => Promise<void>> = []
async checkPermissions() {
try {
const { camera } = await BarcodeScanner.checkPermissions()
return camera === 'granted'
} catch (error) {
logger.error('Error checking camera permissions:', error)
return false
}
}
async requestPermissions() {
try {
const { camera } = await BarcodeScanner.requestPermissions()
return camera === 'granted'
} catch (error) {
logger.error('Error requesting camera permissions:', error)
return false
}
}
async isSupported() {
try {
const { supported } = await BarcodeScanner.isSupported()
return supported
} catch (error) {
logger.error('Error checking barcode scanner support:', error)
return false
}
}
async startScan() {
if (this.isScanning) {
logger.warn('Scanner is already active')
return
}
try {
// First register listeners before starting scan
await this.registerListeners()
this.isScanning = true
await BarcodeScanner.startScan({
formats: [BarcodeFormat.QrCode],
lensFacing: LensFacing.Back
})
} catch (error) {
// Ensure cleanup on error
this.isScanning = false
await this.removeListeners()
logger.error('Error starting barcode scan:', error)
throw error
}
}
private async registerListeners() {
try {
const handle = await BarcodeScanner.addListener(
'barcodesScanned',
async (result: ScanResult) => {
if (result.barcodes.length > 0 && this.scanListener) {
const barcode = result.barcodes[0]
this.scanListener.onScan(barcode.rawValue)
await this.stopScan()
}
}
)
this.listenerHandles.push(() => handle.remove())
} catch (error) {
logger.error('Error registering barcode listener:', error)
throw error
}
}
private async removeListeners() {
for (const remove of this.listenerHandles) {
try {
await remove()
} catch (error) {
logger.error('Error removing listener:', error)
}
}
this.listenerHandles = []
}
async stopScan() {
if (!this.isScanning) {
return
}
try {
// First stop the scan
await BarcodeScanner.stopScan()
} catch (error) {
logger.error('Error stopping barcode scan:', error)
} finally {
// Always cleanup state even if stop fails
this.isScanning = false
await this.removeListeners()
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener
}
async cleanup(): Promise<void> {
await this.stopScan()
this.scanListener = null
}
}

206
src/components/QRScannerDialog.vue → src/components/QRScanner/QRScannerDialog.vue

@ -38,7 +38,9 @@
</button> </button>
<div v-else class="text-center"> <div v-else class="text-center">
<font-awesome icon="spinner" class="fa-spin fa-3x" /> <font-awesome icon="spinner" class="fa-spin fa-3x" />
<p class="mt-2">{{ state.processingDetails }}</p> <p class="mt-2">
{{ state.processingDetails }}
</p>
</div> </div>
</div> </div>
@ -55,216 +57,216 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from 'vue-facing-decorator'
import { QrcodeStream } from "vue-qrcode-reader"; import { QrcodeStream } from 'vue-qrcode-reader'
import { reactive } from "vue"; import { reactive } from 'vue'
import { import {
BarcodeScanner, BarcodeScanner,
type ScanResult, type ScanResult
} from "@capacitor-mlkit/barcode-scanning"; } from '@capacitor-mlkit/barcode-scanning'
import type { PluginListenerHandle } from "@capacitor/core"; import type { PluginListenerHandle } from '@capacitor/core'
import { logger } from "../utils/logger"; import { logger } from '../../utils/logger'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../../constants/app'
// Declare global constants // Declare global constants
declare const __USE_QR_READER__: boolean; declare const __USE_QR_READER__: boolean
declare const __IS_MOBILE__: boolean; declare const __IS_MOBILE__: boolean
interface AppState { interface AppState {
isProcessing: boolean; isProcessing: boolean
processingStatus: string; processingStatus: string
processingDetails: string; processingDetails: string
error: string; error: string
} }
@Component({ @Component({
components: { components: {
QrcodeStream, QrcodeStream
}, }
}) })
export default class QRScannerDialog extends Vue { export default class QRScannerDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
visible = false; visible = false
private scanListener: PluginListenerHandle | null = null; private scanListener: PluginListenerHandle | null = null
private onScanCallback: ((result: string) => void) | null = null; private onScanCallback: ((result: string) => void) | null = null
state = reactive<AppState>({ state = reactive<AppState>({
isProcessing: false, isProcessing: false,
processingStatus: "", processingStatus: '',
processingDetails: "", processingDetails: '',
error: "", error: ''
}); })
async open(onScan: (result: string) => void) { async open(onScan: (result: string) => void) {
this.onScanCallback = onScan; this.onScanCallback = onScan
this.visible = true; this.visible = true
this.state.error = ""; this.state.error = ''
if (!this.useQRReader) { if (!this.useQRReader) {
// Check if barcode scanning is supported on mobile // Check if barcode scanning is supported on mobile
try { try {
const { supported } = await BarcodeScanner.isSupported(); const { supported } = await BarcodeScanner.isSupported()
if (!supported) { if (!supported) {
this.showError("Barcode scanning is not supported on this device"); this.showError('Barcode scanning is not supported on this device')
return; return
} }
} catch (error) { } catch (error) {
this.showError("Failed to check barcode scanner support"); this.showError('Failed to check barcode scanner support')
return; return
} }
} }
} }
close() { close() {
this.visible = false; this.visible = false
this.stopScanning().catch((error) => { this.stopScanning().catch((error) => {
logger.error("Error stopping scanner during close:", error); logger.error('Error stopping scanner during close:', error)
}); })
this.onScanCallback = null; this.onScanCallback = null
} }
async openMobileCamera() { async openMobileCamera() {
try { try {
this.state.isProcessing = true; this.state.isProcessing = true
this.state.processingStatus = "Starting camera..."; this.state.processingStatus = 'Starting camera...'
logger.log("Opening mobile camera - starting initialization"); logger.log('Opening mobile camera - starting initialization')
// Check current permission status // Check current permission status
const status = await BarcodeScanner.checkPermissions(); const status = await BarcodeScanner.checkPermissions()
logger.log("Camera permission status:", JSON.stringify(status, null, 2)); logger.log('Camera permission status:', JSON.stringify(status, null, 2))
if (status.camera !== "granted") { if (status.camera !== 'granted') {
// Request permission if not granted // Request permission if not granted
logger.log("Requesting camera permissions..."); logger.log('Requesting camera permissions...')
const permissionStatus = await BarcodeScanner.requestPermissions(); const permissionStatus = await BarcodeScanner.requestPermissions()
if (permissionStatus.camera !== "granted") { if (permissionStatus.camera !== 'granted') {
throw new Error("Camera permission not granted"); throw new Error('Camera permission not granted')
} }
logger.log( logger.log(
"Camera permission granted:", 'Camera permission granted:',
JSON.stringify(permissionStatus, null, 2), JSON.stringify(permissionStatus, null, 2)
); )
} }
// Remove any existing listener first // Remove any existing listener first
await this.cleanupScanListener(); await this.cleanupScanListener()
// Set up the listener before starting the scan // Set up the listener before starting the scan
logger.log("Setting up new barcode listener"); logger.log('Setting up new barcode listener')
this.scanListener = await BarcodeScanner.addListener( this.scanListener = await BarcodeScanner.addListener(
"barcodesScanned", 'barcodesScanned',
async (result: ScanResult) => { async (result: ScanResult) => {
logger.log( logger.log(
"Barcode scan result received:", 'Barcode scan result received:',
JSON.stringify(result, null, 2), JSON.stringify(result, null, 2)
); )
if (result.barcodes && result.barcodes.length > 0) { if (result.barcodes && result.barcodes.length > 0) {
this.state.processingDetails = `Processing QR code: ${result.barcodes[0].rawValue}`; this.state.processingDetails = `Processing QR code: ${result.barcodes[0].rawValue}`
await this.handleScanResult(result.barcodes[0].rawValue); await this.handleScanResult(result.barcodes[0].rawValue)
} }
}, }
); )
logger.log("Barcode listener setup complete"); logger.log('Barcode listener setup complete')
// Start the scanner // Start the scanner
logger.log("Starting barcode scanner"); logger.log('Starting barcode scanner')
await BarcodeScanner.startScan(); await BarcodeScanner.startScan()
logger.log("Barcode scanner started successfully"); logger.log('Barcode scanner started successfully')
this.state.isProcessing = false; this.state.isProcessing = false
this.state.processingStatus = ""; this.state.processingStatus = ''
} catch (error) { } catch (error) {
logger.error("Failed to open camera:", error); logger.error('Failed to open camera:', error)
this.state.isProcessing = false; this.state.isProcessing = false
this.state.processingStatus = ""; this.state.processingStatus = ''
this.showError( this.showError(
error instanceof Error ? error.message : "Failed to open camera", error instanceof Error ? error.message : 'Failed to open camera'
); )
// Cleanup on error // Cleanup on error
await this.cleanupScanListener(); await this.cleanupScanListener()
} }
} }
private async handleScanResult(rawValue: string) { private async handleScanResult(rawValue: string) {
try { try {
this.state.isProcessing = true; this.state.isProcessing = true
this.state.processingStatus = "Processing QR code..."; this.state.processingStatus = 'Processing QR code...'
this.state.processingDetails = `Scanned value: ${rawValue}`; this.state.processingDetails = `Scanned value: ${rawValue}`
// Stop scanning before processing // Stop scanning before processing
await this.stopScanning(); await this.stopScanning()
if (this.onScanCallback) { if (this.onScanCallback) {
await this.onScanCallback(rawValue); await this.onScanCallback(rawValue)
// Only close after the callback is complete // Only close after the callback is complete
this.close(); this.close()
} }
} catch (error) { } catch (error) {
logger.error("Error handling scan result:", error); logger.error('Error handling scan result:', error)
this.showError("Failed to process scan result"); this.showError('Failed to process scan result')
} finally { } finally {
this.state.isProcessing = false; this.state.isProcessing = false
this.state.processingStatus = ""; this.state.processingStatus = ''
this.state.processingDetails = ""; this.state.processingDetails = ''
} }
} }
private async cleanupScanListener() { private async cleanupScanListener() {
try { try {
if (this.scanListener) { if (this.scanListener) {
await this.scanListener.remove(); await this.scanListener.remove()
this.scanListener = null; this.scanListener = null
} }
} catch (error) { } catch (error) {
logger.error("Error removing scan listener:", error); logger.error('Error removing scan listener:', error)
} }
} }
async stopScanning() { async stopScanning() {
try { try {
await this.cleanupScanListener(); await this.cleanupScanListener()
if (!this.useQRReader) { if (!this.useQRReader) {
// Stop the native scanner // Stop the native scanner
await BarcodeScanner.stopScan(); await BarcodeScanner.stopScan()
} }
} catch (error) { } catch (error) {
logger.error("Error stopping scanner:", error); logger.error('Error stopping scanner:', error)
throw error; throw error
} }
} }
// Web QR reader handlers // Web QR reader handlers
async onScanDetect(result: { rawValue: string }) { async onScanDetect(result: { rawValue: string }) {
await this.handleScanResult(result.rawValue); await this.handleScanResult(result.rawValue)
} }
onScanError(error: Error) { onScanError(error: Error) {
logger.error("Scan error:", error); logger.error('Scan error:', error)
this.showError("Failed to scan QR code"); this.showError('Failed to scan QR code')
} }
private showError(message: string) { private showError(message: string) {
this.state.error = message; this.state.error = message
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error", title: 'Error',
text: message, text: message
}, },
5000, 5000
); )
} }
get useQRReader(): boolean { get useQRReader(): boolean {
return __USE_QR_READER__; return __USE_QR_READER__
} }
get isMobile(): boolean { get isMobile(): boolean {
return __IS_MOBILE__; return __IS_MOBILE__
} }
} }
</script> </script>

74
src/components/QRScanner/WebDialogQRScanner.ts

@ -0,0 +1,74 @@
import type { QRScannerService, ScanListener } from './types'
import QRScannerDialog from './QRScannerDialog.vue'
import { createApp, type App } from 'vue'
import { logger } from '../../utils/logger'
// Import platform-specific flags from Vite config
declare const __USE_QR_READER__: boolean
export class WebDialogQRScanner implements QRScannerService {
private dialogApp: App | null = null
private dialogElement: HTMLDivElement | null = null
private scanListener: ScanListener | null = null
async checkPermissions(): Promise<boolean> {
return navigator?.mediaDevices !== undefined
}
async requestPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
stream.getTracks().forEach((track) => track.stop())
return true
} catch (error) {
logger.error('Failed to get camera permissions:', error)
return false
}
}
async isSupported(): Promise<boolean> {
return Promise.resolve(
__USE_QR_READER__ && navigator?.mediaDevices !== undefined
)
}
async startScan(): Promise<void> {
if (!(await this.isSupported())) {
throw new Error('QR scanning is not supported in this environment')
}
this.dialogElement = document.createElement('div')
document.body.appendChild(this.dialogElement)
this.dialogApp = createApp(QRScannerDialog, {
onScan: (result: string) => {
if (this.scanListener) {
this.scanListener.onScan(result)
}
},
onClose: () => {
this.stopScan()
}
})
this.dialogApp.mount(this.dialogElement)
}
async stopScan(): Promise<void> {
if (this.dialogApp && this.dialogElement) {
this.dialogApp.unmount()
this.dialogElement.remove()
this.dialogApp = null
this.dialogElement = null
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener
}
async cleanup(): Promise<void> {
await this.stopScan()
this.scanListener = null
}
}

38
src/components/QRScanner/factory.ts

@ -0,0 +1,38 @@
import { Capacitor } from '@capacitor/core'
import type { QRScannerService } from './types'
import { logger } from '../../utils/logger'
import { WebDialogQRScanner } from './WebDialogScanner'
import { CapacitorQRScanner } from './CapacitorScanner'
// Import platform-specific flags from Vite config
declare const __USE_QR_READER__: boolean
declare const __IS_MOBILE__: boolean
export class QRScannerFactory {
private static instance: QRScannerService | null = null
static getInstance(): QRScannerService {
if (!this.instance) {
// Use platform-specific flags for more accurate detection
if (__IS_MOBILE__ || Capacitor.isNativePlatform()) {
logger.log('Creating native QR scanner instance')
this.instance = new CapacitorQRScanner()
} else if (__USE_QR_READER__) {
logger.log('Creating web QR scanner instance')
this.instance = new WebDialogQRScanner()
} else {
throw new Error(
'No QR scanner implementation available for this platform'
)
}
}
return this.instance
}
static async cleanup() {
if (this.instance) {
await this.instance.cleanup()
this.instance = null
}
}
}

14
src/components/QRScanner/types.ts

@ -0,0 +1,14 @@
export interface ScanListener {
onScan: (result: string) => void
onError?: (error: Error) => void
}
export interface QRScannerService {
checkPermissions(): Promise<boolean>
requestPermissions(): Promise<boolean>
isSupported(): Promise<boolean>
startScan(): Promise<void>
stopScan(): Promise<void>
addListener(listener: ScanListener): void
cleanup(): Promise<void>
}

14
src/components/QuickNav.vue

@ -8,7 +8,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Home', 'bg-slate-400 text-white': selected === 'Home',
'text-slate-500': selected !== 'Home', 'text-slate-500': selected !== 'Home'
}" }"
> >
<router-link :to="{ name: 'home' }" class="block text-center py-2 px-1"> <router-link :to="{ name: 'home' }" class="block text-center py-2 px-1">
@ -24,7 +24,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Discover', 'bg-slate-400 text-white': selected === 'Discover',
'text-slate-500': selected !== 'Discover', 'text-slate-500': selected !== 'Discover'
}" }"
> >
<router-link <router-link
@ -43,7 +43,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Projects', 'bg-slate-400 text-white': selected === 'Projects',
'text-slate-500': selected !== 'Projects', 'text-slate-500': selected !== 'Projects'
}" }"
> >
<router-link <router-link
@ -62,7 +62,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Contacts', 'bg-slate-400 text-white': selected === 'Contacts',
'text-slate-500': selected !== 'Contacts', 'text-slate-500': selected !== 'Contacts'
}" }"
> >
<router-link <router-link
@ -81,7 +81,7 @@
'basis-1/5': true, 'basis-1/5': true,
'rounded-md': true, 'rounded-md': true,
'bg-slate-400 text-white': selected === 'Profile', 'bg-slate-400 text-white': selected === 'Profile',
'text-slate-500': selected !== 'Profile', 'text-slate-500': selected !== 'Profile'
}" }"
> >
<router-link <router-link
@ -106,10 +106,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from 'vue-facing-decorator'
@Component @Component
export default class QuickNav extends Vue { export default class QuickNav extends Vue {
@Prop selected = ""; @Prop selected = ''
} }
</script> </script>

34
src/components/TopMessage.vue

@ -13,46 +13,46 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from 'vue-facing-decorator'
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from '../constants/app'
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from '../db/index'
@Component @Component
export default class TopMessage extends Vue { export default class TopMessage extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
@Prop selected = ""; @Prop selected = ''
message = ""; message = ''
async mounted() { async mounted() {
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
if ( if (
settings.warnIfTestServer && settings.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
) { ) {
const didPrefix = settings.activeDid?.slice(11, 15); const didPrefix = settings.activeDid?.slice(11, 15)
this.message = "You're linked to a non-prod server, user " + didPrefix; this.message = "You're linked to a non-prod server, user " + didPrefix
} else if ( } else if (
settings.warnIfProdServer && settings.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) { ) {
const didPrefix = settings.activeDid?.slice(11, 15); const didPrefix = settings.activeDid?.slice(11, 15)
this.message = this.message =
"You're linked to the production server, user " + didPrefix; "You're linked to the production server, user " + didPrefix
} }
} catch (err: unknown) { } catch (err: unknown) {
this.$notify( this.$notify(
{ {
group: "alert", group: 'alert',
type: "danger", type: 'danger',
title: "Error Detecting Server", title: 'Error Detecting Server',
text: JSON.stringify(err), text: JSON.stringify(err)
}, },
-1, -1
); )
} }
} }
} }

42
src/components/UserNameDialog.vue

@ -35,49 +35,49 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from 'vue-facing-decorator'
import { NotificationIface } from "../constants/app"; import { NotificationIface } from '../constants/app'
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from '../db/index'
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from '../db/tables/settings'
@Component @Component
export default class UserNameDialog extends Vue { export default class UserNameDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void
@Prop({ @Prop({
default: default:
"This is not sent to servers. It is only shared with people when you send it to them.", 'This is not sent to servers. It is only shared with people when you send it to them.'
}) })
sharingExplanation!: string; sharingExplanation!: string
@Prop({ default: false }) callbackOnCancel!: boolean; @Prop({ default: false }) callbackOnCancel!: boolean
callback: (name?: string) => void = () => {}; callback: (name?: string) => void = () => {}
givenName = ""; givenName = ''
visible = false; visible = false
/** /**
* @param aCallback - callback function for name, which may be "" * @param aCallback - callback function for name, which may be ""
*/ */
async open(aCallback?: (name?: string) => void) { async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback; this.callback = aCallback || this.callback
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
this.givenName = settings.firstName || ""; this.givenName = settings.firstName || ''
this.visible = true; this.visible = true
} }
async onClickSaveChanges() { async onClickSaveChanges() {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName, firstName: this.givenName
}); })
this.visible = false; this.visible = false
this.callback(this.givenName); this.callback(this.givenName)
} }
onClickCancel() { onClickCancel() {
this.visible = false; this.visible = false
if (this.callbackOnCancel) { if (this.callbackOnCancel) {
this.callback(); this.callback()
} }
} }
} }

108
src/components/World/World.js

@ -1,53 +1,53 @@
// from https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80 // from https://medium.com/nicasource/building-an-interactive-web-portfolio-with-vue-three-js-part-three-implementing-three-js-452cb375ef80
import * as TWEEN from "@tweenjs/tween.js"; import * as TWEEN from '@tweenjs/tween.js'
import * as THREE from "three"; import * as THREE from 'three'
import { createCamera } from "./components/camera.js"; import { createCamera } from './components/camera.js'
import { createLights } from "./components/lights.js"; import { createLights } from './components/lights.js'
import { createScene } from "./components/scene.js"; import { createScene } from './components/scene.js'
import { loadLandmarks } from "./components/objects/landmarks.js"; import { loadLandmarks } from './components/objects/landmarks.js'
import { createTerrain } from "./components/objects/terrain.js"; import { createTerrain } from './components/objects/terrain.js'
import { Loop } from "./systems/Loop.js"; import { Loop } from './systems/Loop.js'
import { Resizer } from "./systems/Resizer.js"; import { Resizer } from './systems/Resizer.js'
import { createControls } from "./systems/controls.js"; import { createControls } from './systems/controls.js'
import { createRenderer } from "./systems/renderer.js"; import { createRenderer } from './systems/renderer.js'
const COLOR1 = "#dddddd"; const COLOR1 = '#dddddd'
const COLOR2 = "#0055aa"; const COLOR2 = '#0055aa'
class World { class World {
constructor(container, vue) { constructor(container, vue) {
this.PLATFORM_BORDER = 5; this.PLATFORM_BORDER = 5
this.PLATFORM_EDGE_FOR_UNKNOWNS = 10; this.PLATFORM_EDGE_FOR_UNKNOWNS = 10
this.PLATFORM_SIZE = 100; // note that the loadLandmarks calculations may still assume 100 this.PLATFORM_SIZE = 100 // note that the loadLandmarks calculations may still assume 100
this.update = this.update.bind(this); this.update = this.update.bind(this)
this.vue = vue; this.vue = vue
// Instances of camera, scene, and renderer // Instances of camera, scene, and renderer
this.camera = createCamera(); this.camera = createCamera()
this.scene = createScene(COLOR2); this.scene = createScene(COLOR2)
this.renderer = createRenderer(); this.renderer = createRenderer()
// necessary for models, says https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader // necessary for models, says https://threejs.org/docs/index.html#examples/en/loaders/GLTFLoader
this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.light = null; this.light = null
this.lights = []; this.lights = []
this.bushes = []; this.bushes = []
// Initialize Loop // Initialize Loop
this.loop = new Loop(this.camera, this.scene, this.renderer); this.loop = new Loop(this.camera, this.scene, this.renderer)
container.append(this.renderer.domElement); container.append(this.renderer.domElement)
// Orbit Controls // Orbit Controls
const controls = createControls(this.camera, this.renderer.domElement); const controls = createControls(this.camera, this.renderer.domElement)
// Light Instance, with optional light helper // Light Instance, with optional light helper
const { light } = createLights(COLOR1); const { light } = createLights(COLOR1)
// Terrain Instance // Terrain Instance
const terrain = createTerrain({ const terrain = createTerrain({
@ -56,55 +56,55 @@ class World {
width: width:
this.PLATFORM_SIZE + this.PLATFORM_SIZE +
this.PLATFORM_BORDER * 2 + this.PLATFORM_BORDER * 2 +
this.PLATFORM_EDGE_FOR_UNKNOWNS * 2, this.PLATFORM_EDGE_FOR_UNKNOWNS * 2
}); })
this.loop.updatables.push(controls); this.loop.updatables.push(controls)
this.loop.updatables.push(light); this.loop.updatables.push(light)
this.loop.updatables.push(terrain); this.loop.updatables.push(terrain)
this.scene.add(light, terrain); this.scene.add(light, terrain)
loadLandmarks(vue, this, this.scene, this.loop); loadLandmarks(vue, this, this.scene, this.loop)
requestAnimationFrame(this.update); requestAnimationFrame(this.update)
// Responsive handler // Responsive handler
const resizer = new Resizer(container, this.camera, this.renderer); const resizer = new Resizer(container, this.camera, this.renderer)
resizer.onResize = () => { resizer.onResize = () => {
this.render(); this.render()
}; }
} }
update(time) { update(time) {
TWEEN.update(time); TWEEN.update(time)
this.lights.forEach((light) => { this.lights.forEach((light) => {
light.updateMatrixWorld(); light.updateMatrixWorld()
light.target.updateMatrixWorld(); light.target.updateMatrixWorld()
}); })
this.lights.forEach((bush) => { this.lights.forEach((bush) => {
bush.updateMatrixWorld(); bush.updateMatrixWorld()
}); })
requestAnimationFrame(this.update); requestAnimationFrame(this.update)
} }
render() { render() {
// draw a single frame // draw a single frame
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera)
} }
// Animation handlers // Animation handlers
start() { start() {
this.loop.start(); this.loop.start()
} }
stop() { stop() {
this.loop.stop(); this.loop.stop()
} }
setExposedWorldProperties(key, value) { setExposedWorldProperties(key, value) {
this.vue.setWorldProperty(key, value); this.vue.setWorldProperty(key, value)
} }
} }
export { World }; export { World }

14
src/components/World/components/camera.js

@ -1,19 +1,19 @@
import { PerspectiveCamera } from "three"; import { PerspectiveCamera } from 'three'
function createCamera() { function createCamera() {
const camera = new PerspectiveCamera( const camera = new PerspectiveCamera(
35, // fov = Field Of View 35, // fov = Field Of View
1, // aspect ratio (dummy value) 1, // aspect ratio (dummy value)
0.1, // near clipping plane 0.1, // near clipping plane
350, // far clipping plane 350 // far clipping plane
); )
// move the camera back so we can view the scene // move the camera back so we can view the scene
camera.position.set(0, 100, 200); camera.position.set(0, 100, 200)
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
camera.tick = () => {}; camera.tick = () => {}
return camera; return camera
} }
export { createCamera }; export { createCamera }

14
src/components/World/components/lights.js

@ -1,14 +1,14 @@
import { DirectionalLight, DirectionalLightHelper } from "three"; import { DirectionalLight, DirectionalLightHelper } from 'three'
function createLights(color) { function createLights(color) {
const light = new DirectionalLight(color, 4); const light = new DirectionalLight(color, 4)
const lightHelper = new DirectionalLightHelper(light, 0); const lightHelper = new DirectionalLightHelper(light, 0)
light.position.set(60, 100, 30); light.position.set(60, 100, 30)
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
light.tick = () => {}; light.tick = () => {}
return { light, lightHelper }; return { light, lightHelper }
} }
export { createLights }; export { createLights }

194
src/components/World/components/objects/landmarks.js

@ -1,46 +1,46 @@
import axios from "axios"; import axios from 'axios'
import * as THREE from "three"; import * as THREE from 'three'
import { GLTFLoader } from "three/addons/loaders/GLTFLoader"; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader'
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils"; import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils'
import * as TWEEN from "@tweenjs/tween.js"; import * as TWEEN from '@tweenjs/tween.js'
import { retrieveSettingsForActiveAccount } from "../../../../db"; import { retrieveSettingsForActiveAccount } from '../../../../db'
import { getHeaders } from "../../../../libs/endorserServer"; import { getHeaders } from '../../../../libs/endorserServer'
import { logger } from "../../../../utils/logger"; import { logger } from '../../../../utils/logger'
const ANIMATION_DURATION_SECS = 10; const ANIMATION_DURATION_SECS = 10
const ENDORSER_ENTITY_PREFIX = "https://endorser.ch/entity/"; const ENDORSER_ENTITY_PREFIX = 'https://endorser.ch/entity/'
export async function loadLandmarks(vue, world, scene, loop) { export async function loadLandmarks(vue, world, scene, loop) {
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS); vue.setWorldProperty('animationDurationSeconds', ANIMATION_DURATION_SECS)
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
const activeDid = settings.activeDid || ""; const activeDid = settings.activeDid || ''
const apiServer = settings.apiServer; const apiServer = settings.apiServer
const headers = await getHeaders(activeDid); const headers = await getHeaders(activeDid)
const url = apiServer + "/api/v2/report/claims?claimType=GiveAction"; const url = apiServer + '/api/v2/report/claims?claimType=GiveAction'
const resp = await axios.get(url, { headers: headers }); const resp = await axios.get(url, { headers: headers })
if (resp.status === 200) { if (resp.status === 200) {
const landmarks = resp.data.data; const landmarks = resp.data.data
const minDate = landmarks[landmarks.length - 1].issuedAt; const minDate = landmarks[landmarks.length - 1].issuedAt
const maxDate = landmarks[0].issuedAt; const maxDate = landmarks[0].issuedAt
world.setExposedWorldProperties("startTime", minDate.replace("T", " ")); world.setExposedWorldProperties('startTime', minDate.replace('T', ' '))
world.setExposedWorldProperties("endTime", maxDate.replace("T", " ")); world.setExposedWorldProperties('endTime', maxDate.replace('T', ' '))
const minTimeMillis = new Date(minDate).getTime(); const minTimeMillis = new Date(minDate).getTime()
const fullTimeMillis = const fullTimeMillis =
maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1; // avoid divide by zero maxDate > minDate ? new Date(maxDate).getTime() - minTimeMillis : 1 // avoid divide by zero
// ratio of animation time to real time // ratio of animation time to real time
const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis; const fakeRealRatio = (ANIMATION_DURATION_SECS * 1000) / fullTimeMillis
// load plant model first because it takes a second // load plant model first because it takes a second
const loader = new GLTFLoader(); const loader = new GLTFLoader()
// choose the right plant // choose the right plant
const modelLoc = "/models/lupine_plant/scene.gltf", // push with pokies const modelLoc = '/models/lupine_plant/scene.gltf', // push with pokies
modScale = 0.1; modScale = 0.1
//const modelLoc = "/models/round_bush/scene.gltf", // green & pink //const modelLoc = "/models/round_bush/scene.gltf", // green & pink
// modScale = 1; // modScale = 1;
//const modelLoc = "/models/coreopsis-flower.glb", // 3 flowers //const modelLoc = "/models/coreopsis-flower.glb", // 3 flowers
@ -50,59 +50,55 @@ export async function loadLandmarks(vue, world, scene, loop) {
// calculate positions for each claim, especially because some are random // calculate positions for each claim, especially because some are random
const locations = landmarks.map((claim) => const locations = landmarks.map((claim) =>
locForGive( locForGive(claim, world.PLATFORM_SIZE, world.PLATFORM_EDGE_FOR_UNKNOWNS)
claim, )
world.PLATFORM_SIZE,
world.PLATFORM_EDGE_FOR_UNKNOWNS,
),
);
// eslint-disable-next-line @typescript-eslint/no-this-alias // eslint-disable-next-line @typescript-eslint/no-this-alias
loader.load( loader.load(
modelLoc, modelLoc,
function (gltf) { function (gltf) {
gltf.scene.scale.set(0, 0, 0); gltf.scene.scale.set(0, 0, 0)
for (let i = 0; i < landmarks.length; i++) { for (let i = 0; i < landmarks.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts) // claim is a GiveServerRecord (see endorserServer.ts)
const claim = landmarks[i]; const claim = landmarks[i]
const newPlant = SkeletonUtils.clone(gltf.scene); const newPlant = SkeletonUtils.clone(gltf.scene)
const loc = locations[i]; const loc = locations[i]
newPlant.position.set(loc.x, 0, loc.z); newPlant.position.set(loc.x, 0, loc.z)
world.scene.add(newPlant); world.scene.add(newPlant)
const timeDelayMillis = const timeDelayMillis =
fakeRealRatio * fakeRealRatio *
(new Date(claim.issuedAt).getTime() - minTimeMillis); (new Date(claim.issuedAt).getTime() - minTimeMillis)
new TWEEN.Tween(newPlant.scale) new TWEEN.Tween(newPlant.scale)
.delay(timeDelayMillis) .delay(timeDelayMillis)
.to({ x: modScale, y: modScale, z: modScale }, 5000) .to({ x: modScale, y: modScale, z: modScale }, 5000)
.start(); .start()
world.bushes = [...world.bushes, newPlant]; world.bushes = [...world.bushes, newPlant]
} }
}, },
undefined, undefined,
function (error) { function (error) {
logger.error(error); logger.error(error)
}, }
); )
// calculate when lights shine on appearing claim area // calculate when lights shine on appearing claim area
for (let i = 0; i < landmarks.length; i++) { for (let i = 0; i < landmarks.length; i++) {
// claim is a GiveServerRecord (see endorserServer.ts) // claim is a GiveServerRecord (see endorserServer.ts)
const claim = landmarks[i]; const claim = landmarks[i]
const loc = locations[i]; const loc = locations[i]
const light = createLight(); const light = createLight()
light.position.set(loc.x, 20, loc.z); light.position.set(loc.x, 20, loc.z)
light.target.position.set(loc.x, 0, loc.z); light.target.position.set(loc.x, 0, loc.z)
loop.updatables.push(light); loop.updatables.push(light)
scene.add(light); scene.add(light)
scene.add(light.target); scene.add(light.target)
// now figure out the timing and shine a light // now figure out the timing and shine a light
const timeDelayMillis = const timeDelayMillis =
fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis); fakeRealRatio * (new Date(claim.issuedAt).getTime() - minTimeMillis)
new TWEEN.Tween(light) new TWEEN.Tween(light)
.delay(timeDelayMillis) .delay(timeDelayMillis)
.to({ intensity: 100 }, 10) .to({ intensity: 100 }, 10)
@ -110,30 +106,30 @@ export async function loadLandmarks(vue, world, scene, loop) {
new TWEEN.Tween(light.position) new TWEEN.Tween(light.position)
.to({ y: 5 }, 5000) .to({ y: 5 }, 5000)
.onComplete(() => { .onComplete(() => {
scene.remove(light); scene.remove(light)
light.dispose(); light.dispose()
}), })
) )
.start(); .start()
world.lights = [...world.lights, light]; world.lights = [...world.lights, light]
} }
} else { } else {
logger.error( logger.error(
"Got bad server response status & data of", 'Got bad server response status & data of',
resp.status, resp.status,
resp.data, resp.data
); )
vue.setAlert( vue.setAlert(
"Error With Server", 'Error With Server',
"There was an error retrieving your claims from the server.", 'There was an error retrieving your claims from the server.'
); )
} }
} catch (error) { } catch (error) {
logger.error("Got exception contacting server:", error); logger.error('Got exception contacting server:', error)
vue.setAlert( vue.setAlert(
"Error With Server", 'Error With Server',
"There was a problem retrieving your claims from the server.", 'There was a problem retrieving your claims from the server.'
); )
} }
} }
@ -143,30 +139,30 @@ export async function loadLandmarks(vue, world, scene, loop) {
* @returns {x:float, z:float} where -50 <= x & z < 50 * @returns {x:float, z:float} where -50 <= x & z < 50
*/ */
function locForGive(giveClaim, platformWidth, borderWidth) { function locForGive(giveClaim, platformWidth, borderWidth) {
let loc; let loc
if (giveClaim?.claim?.recipient?.identifier) { if (giveClaim?.claim?.recipient?.identifier) {
// this is directly to a person // this is directly to a person
loc = locForEthrDid(giveClaim.claim.recipient.identifier); loc = locForEthrDid(giveClaim.claim.recipient.identifier)
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }; loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }
} else if (giveClaim?.object?.isPartOf?.identifier) { } else if (giveClaim?.object?.isPartOf?.identifier) {
// this is probably to a project // this is probably to a project
const objId = giveClaim.object.isPartOf.identifier; const objId = giveClaim.object.isPartOf.identifier
if (objId.startsWith(ENDORSER_ENTITY_PREFIX)) { if (objId.startsWith(ENDORSER_ENTITY_PREFIX)) {
loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length)); loc = locForUlid(objId.substring(ENDORSER_ENTITY_PREFIX.length))
loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }; loc = { x: loc.x - platformWidth / 2, z: loc.z - platformWidth / 2 }
} }
} }
if (!loc) { if (!loc) {
// it must be outside our known addresses so let's put it somewhere random on the side // it must be outside our known addresses so let's put it somewhere random on the side
const leftSide = Math.random() < 0.5; const leftSide = Math.random() < 0.5
loc = { loc = {
x: leftSide x: leftSide
? -platformWidth / 2 - borderWidth / 2 ? -platformWidth / 2 - borderWidth / 2
: platformWidth / 2 + borderWidth / 2, : platformWidth / 2 + borderWidth / 2,
z: Math.random() * platformWidth - platformWidth / 2, z: Math.random() * platformWidth - platformWidth / 2
}; }
} }
return loc; return loc
} }
/** /**
@ -195,20 +191,20 @@ function locForGive(giveClaim, platformWidth, borderWidth) {
* @returns {x: float, z: float} where 0 <= x & z < 100 * @returns {x: float, z: float} where 0 <= x & z < 100
*/ */
function locForUlid(ulid) { function locForUlid(ulid) {
const xChars = ulid.substring(0, 13).split("").reverse().join(""); const xChars = ulid.substring(0, 13).split('').reverse().join('')
const zChars = ulid.substring(13, 26).split("").reverse().join(""); const zChars = ulid.substring(13, 26).split('').reverse().join('')
// from https://github.com/ulid/javascript/blob/5e9727b527aec5b841737c395a20085c4361e971/lib/index.ts#L21 // from https://github.com/ulid/javascript/blob/5e9727b527aec5b841737c395a20085c4361e971/lib/index.ts#L21
const BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford's Base32 const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // Crockford's Base32
// We're currently only using 1024 possible x and z values // We're currently only using 1024 possible x and z values
// because the display is pretty low-fidelity at this point. // because the display is pretty low-fidelity at this point.
const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0]); const rawX = BASE32.indexOf(xChars[1]) * 32 + BASE32.indexOf(xChars[0])
const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0]); const rawZ = BASE32.indexOf(zChars[1]) * 32 + BASE32.indexOf(zChars[0])
const x = (100 * rawX) / 1024; const x = (100 * rawX) / 1024
const z = (100 * rawZ) / 1024; const z = (100 * rawZ) / 1024
return { x, z }; return { x, z }
} }
/** /**
@ -219,24 +215,24 @@ function locForUlid(ulid) {
function locForEthrDid(did) { function locForEthrDid(did) {
// "did:ethr:0x..." // "did:ethr:0x..."
if (did.length < 51) { if (did.length < 51) {
return { x: 0, z: 0 }; return { x: 0, z: 0 }
} else { } else {
const randomness = did.substring("did:ethr:0x".length); const randomness = did.substring('did:ethr:0x'.length)
// We'll use all the randomness for fully unique x & z values. // We'll use all the randomness for fully unique x & z values.
// But we'll only calculate this view with the first byte since our rendering resolution is low. // But we'll only calculate this view with the first byte since our rendering resolution is low.
const xOff = parseInt(Number("0x" + randomness.substring(0, 2)), 10); const xOff = parseInt(Number('0x' + randomness.substring(0, 2)), 10)
const x = (xOff * 100) / 256; const x = (xOff * 100) / 256
// ... and since we're reserving 20 bytes total for x, start z with character 20, // ... and since we're reserving 20 bytes total for x, start z with character 20,
// again with one byte. // again with one byte.
const zOff = parseInt(Number("0x" + randomness.substring(20, 22)), 10); const zOff = parseInt(Number('0x' + randomness.substring(20, 22)), 10)
const z = (zOff * 100) / 256; const z = (zOff * 100) / 256
return { x, z }; return { x, z }
} }
} }
function createLight() { function createLight() {
const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0); const light = new THREE.SpotLight(0xffffff, 0, 0, Math.PI / 8, 0.5, 0)
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
light.tick = () => {}; light.tick = () => {}
return light; return light
} }

24
src/components/World/components/objects/terrain.js

@ -1,29 +1,29 @@
import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from "three"; import { PlaneGeometry, MeshLambertMaterial, Mesh, TextureLoader } from 'three'
export function createTerrain(props) { export function createTerrain(props) {
const loader = new TextureLoader(); const loader = new TextureLoader()
const height = loader.load("img/textures/leafy-autumn-forest-floor.jpg"); const height = loader.load('img/textures/leafy-autumn-forest-floor.jpg')
// w h // w h
const geometry = new PlaneGeometry(props.width, props.height, 64, 64); const geometry = new PlaneGeometry(props.width, props.height, 64, 64)
const material = new MeshLambertMaterial({ const material = new MeshLambertMaterial({
color: props.color, color: props.color,
flatShading: true, flatShading: true,
map: height, map: height
//displacementMap: height, //displacementMap: height,
//displacementScale: 5, //displacementScale: 5,
}); })
const plane = new Mesh(geometry, material); const plane = new Mesh(geometry, material)
plane.position.set(0, 0, 0); plane.position.set(0, 0, 0)
plane.rotation.x -= Math.PI * 0.5; plane.rotation.x -= Math.PI * 0.5
//Storing our original vertices position on a new attribute //Storing our original vertices position on a new attribute
plane.geometry.attributes.position.originalPosition = plane.geometry.attributes.position.originalPosition =
plane.geometry.attributes.position.array; plane.geometry.attributes.position.array
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
plane.tick = () => {}; plane.tick = () => {}
return plane; return plane
} }

10
src/components/World/components/scene.js

@ -1,11 +1,11 @@
import { Color, Scene } from "three"; import { Color, Scene } from 'three'
function createScene(color) { function createScene(color) {
const scene = new Scene(); const scene = new Scene()
scene.background = new Color(color); scene.background = new Color(color)
//scene.fog = new Fog(color, 60, 90); //scene.fog = new Fog(color, 60, 90);
return scene; return scene
} }
export { createScene }; export { createScene }

26
src/components/World/systems/Loop.js

@ -1,33 +1,33 @@
import { Clock } from "three"; import { Clock } from 'three'
const clock = new Clock(); const clock = new Clock()
class Loop { class Loop {
constructor(camera, scene, renderer) { constructor(camera, scene, renderer) {
this.camera = camera; this.camera = camera
this.scene = scene; this.scene = scene
this.renderer = renderer; this.renderer = renderer
this.updatables = []; this.updatables = []
} }
start() { start() {
this.renderer.setAnimationLoop(() => { this.renderer.setAnimationLoop(() => {
this.tick(); this.tick()
// render a frame // render a frame
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera)
}); })
} }
stop() { stop() {
this.renderer.setAnimationLoop(null); this.renderer.setAnimationLoop(null)
} }
tick() { tick() {
const delta = clock.getDelta(); const delta = clock.getDelta()
for (const object of this.updatables) { for (const object of this.updatables) {
object.tick(delta); object.tick(delta)
} }
} }
} }
export { Loop }; export { Loop }

26
src/components/World/systems/Resizer.js

@ -1,33 +1,33 @@
const setSize = (container, camera, renderer) => { const setSize = (container, camera, renderer) => {
// These are great for full-screen, which adjusts to a window. // These are great for full-screen, which adjusts to a window.
const height = window.innerHeight; const height = window.innerHeight
const width = window.innerWidth - 50; const width = window.innerWidth - 50
// These are better for fitting in a container, which stays that size. // These are better for fitting in a container, which stays that size.
//const height = container.scrollHeight; //const height = container.scrollHeight;
//const width = container.scrollWidth; //const width = container.scrollWidth;
camera.aspect = width / height; camera.aspect = width / height
camera.updateProjectionMatrix(); camera.updateProjectionMatrix()
renderer.setSize(width, height); renderer.setSize(width, height)
renderer.setPixelRatio(window.devicePixelRatio); renderer.setPixelRatio(window.devicePixelRatio)
}; }
class Resizer { class Resizer {
constructor(container, camera, renderer) { constructor(container, camera, renderer) {
// set initial size on load // set initial size on load
setSize(container, camera, renderer); setSize(container, camera, renderer)
window.addEventListener("resize", () => { window.addEventListener('resize', () => {
// set the size again if a resize occurs // set the size again if a resize occurs
setSize(container, camera, renderer); setSize(container, camera, renderer)
// perform any custom actions // perform any custom actions
this.onResize(); this.onResize()
}); })
} }
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
onResize() {} onResize() {}
} }
export { Resizer }; export { Resizer }

24
src/components/World/systems/controls.js

@ -1,12 +1,12 @@
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { MathUtils } from "three"; import { MathUtils } from 'three'
function createControls(camera, canvas) { function createControls(camera, canvas) {
const controls = new OrbitControls(camera, canvas); const controls = new OrbitControls(camera, canvas)
//enable controls? //enable controls?
controls.enabled = true; controls.enabled = true
controls.autoRotate = false; controls.autoRotate = false
//controls.autoRotateSpeed = 0.2; //controls.autoRotateSpeed = 0.2;
// control limits // control limits
@ -14,8 +14,8 @@ function createControls(camera, canvas) {
// to prevent the user from clipping with the objects. // to prevent the user from clipping with the objects.
// y axis // y axis
controls.minPolarAngle = MathUtils.degToRad(40); // default controls.minPolarAngle = MathUtils.degToRad(40) // default
controls.maxPolarAngle = MathUtils.degToRad(75); controls.maxPolarAngle = MathUtils.degToRad(75)
// x axis // x axis
// controls.minAzimuthAngle = ... // controls.minAzimuthAngle = ...
@ -23,16 +23,16 @@ function createControls(camera, canvas) {
//smooth camera: //smooth camera:
// remember to add to loop updatables to work // remember to add to loop updatables to work
controls.enableDamping = true; controls.enableDamping = true
//controls.enableZoom = false; //controls.enableZoom = false;
controls.maxDistance = 250; controls.maxDistance = 250
//controls.enablePan = false; //controls.enablePan = false;
controls.tick = () => controls.update(); controls.tick = () => controls.update()
return controls; return controls
} }
export { createControls }; export { createControls }

10
src/components/World/systems/renderer.js

@ -1,13 +1,13 @@
import { WebGLRenderer } from "three"; import { WebGLRenderer } from 'three'
function createRenderer() { function createRenderer() {
const renderer = new WebGLRenderer({ antialias: true }); const renderer = new WebGLRenderer({ antialias: true })
// turn on the physically correct lighting model // turn on the physically correct lighting model
// (The browser complains: "THREE.WebGLRenderer: the property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead." However, that changes the lighting in a way that doesn't look better.) // (The browser complains: "THREE.WebGLRenderer: the property .physicallyCorrectLights has been removed. Set renderer.useLegacyLights instead." However, that changes the lighting in a way that doesn't look better.)
renderer.physicallyCorrectLights = true; renderer.physicallyCorrectLights = true
return renderer; return renderer
} }
export { createRenderer }; export { createRenderer }

63
src/constants/app.ts

@ -6,64 +6,63 @@
export enum AppString { export enum AppString {
// This is used in titles and verbiage inside the app. // This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest. // There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari", APP_NAME = 'Time Safari',
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch", PROD_ENDORSER_API_SERVER = 'https://api.endorser.ch',
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch", TEST_ENDORSER_API_SERVER = 'https://test-api.endorser.ch',
LOCAL_ENDORSER_API_SERVER = "http://localhost:3000", LOCAL_ENDORSER_API_SERVER = 'http://localhost:3000',
PROD_IMAGE_API_SERVER = "https://image-api.timesafari.app", PROD_IMAGE_API_SERVER = 'https://image-api.timesafari.app',
TEST_IMAGE_API_SERVER = "https://test-image-api.timesafari.app", TEST_IMAGE_API_SERVER = 'https://test-image-api.timesafari.app',
LOCAL_IMAGE_API_SERVER = "http://localhost:3001", LOCAL_IMAGE_API_SERVER = 'http://localhost:3001',
PROD_PARTNER_API_SERVER = "https://partner-api.endorser.ch", PROD_PARTNER_API_SERVER = 'https://partner-api.endorser.ch',
TEST_PARTNER_API_SERVER = "https://test-partner-api.endorser.ch", TEST_PARTNER_API_SERVER = 'https://test-partner-api.endorser.ch',
LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER, LOCAL_PARTNER_API_SERVER = LOCAL_ENDORSER_API_SERVER,
PROD_PUSH_SERVER = "https://timesafari.app", PROD_PUSH_SERVER = 'https://timesafari.app',
TEST1_PUSH_SERVER = "https://test.timesafari.app", TEST1_PUSH_SERVER = 'https://test.timesafari.app',
TEST2_PUSH_SERVER = "https://timesafari-pwa.anomalistlabs.com", TEST2_PUSH_SERVER = 'https://timesafari-pwa.anomalistlabs.com',
NO_CONTACT_NAME = "(no name)", NO_CONTACT_NAME = '(no name)'
} }
export const APP_SERVER = export const APP_SERVER =
import.meta.env.VITE_APP_SERVER || "https://timesafari.app"; import.meta.env.VITE_APP_SERVER || 'https://timesafari.app'
export const DEFAULT_ENDORSER_API_SERVER = export const DEFAULT_ENDORSER_API_SERVER =
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER || import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
AppString.TEST_ENDORSER_API_SERVER; AppString.TEST_ENDORSER_API_SERVER
export const DEFAULT_IMAGE_API_SERVER = export const DEFAULT_IMAGE_API_SERVER =
import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER || import.meta.env.VITE_DEFAULT_IMAGE_API_SERVER ||
AppString.TEST_IMAGE_API_SERVER; AppString.TEST_IMAGE_API_SERVER
export const DEFAULT_PARTNER_API_SERVER = export const DEFAULT_PARTNER_API_SERVER =
import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER || import.meta.env.VITE_DEFAULT_PARTNER_API_SERVER ||
AppString.TEST_PARTNER_API_SERVER; AppString.TEST_PARTNER_API_SERVER
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host; window.location.protocol + '//' + window.location.host
export const IMAGE_TYPE_PROFILE = "profile"; export const IMAGE_TYPE_PROFILE = 'profile'
export const PASSKEYS_ENABLED = export const PASSKEYS_ENABLED = !!import.meta.env.VITE_PASSKEYS_ENABLED || false
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
/** /**
* The possible values for "group" and "type" are in App.vue. * The possible values for "group" and "type" are in App.vue.
* Some of this comes from the notiwind package, some is custom. * Some of this comes from the notiwind package, some is custom.
*/ */
export interface NotificationIface { export interface NotificationIface {
group: string; // "alert" | "modal" group: string // "alert" | "modal"
type: string; // "toast" | "info" | "success" | "warning" | "danger" type: string // "toast" | "info" | "success" | "warning" | "danger"
title: string; title: string
text?: string; text?: string
callback?: (success: boolean) => Promise<void>; // if this triggered an action callback?: (success: boolean) => Promise<void> // if this triggered an action
noText?: string; noText?: string
onCancel?: (stopAsking?: boolean) => Promise<void>; onCancel?: (stopAsking?: boolean) => Promise<void>
onNo?: (stopAsking?: boolean) => Promise<void>; onNo?: (stopAsking?: boolean) => Promise<void>
onYes?: () => Promise<void>; onYes?: () => Promise<void>
promptToStopAsking?: boolean; promptToStopAsking?: boolean
yesText?: string; yesText?: string
} }

174
src/db/index.ts

@ -1,44 +1,44 @@
import BaseDexie, { Table } from "dexie"; import BaseDexie, { Table } from 'dexie'
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon"; import { encrypted, Encryption } from '@pvermeer/dexie-encrypted-addon'
import * as R from "ramda"; import * as R from 'ramda'
import { Account, AccountsSchema } from "./tables/accounts"; import { Account, AccountsSchema } from './tables/accounts'
import { Contact, ContactSchema } from "./tables/contacts"; import { Contact, ContactSchema } from './tables/contacts'
import { Log, LogSchema } from "./tables/logs"; import { Log, LogSchema } from './tables/logs'
import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret"; import { MASTER_SECRET_KEY, Secret, SecretSchema } from './tables/secret'
import { import {
MASTER_SETTINGS_KEY, MASTER_SETTINGS_KEY,
Settings, Settings,
SettingsSchema, SettingsSchema
} from "./tables/settings"; } from './tables/settings'
import { Temp, TempSchema } from "./tables/temp"; import { Temp, TempSchema } from './tables/temp'
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app"; import { DEFAULT_ENDORSER_API_SERVER } from '../constants/app'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
// Define types for tables that hold sensitive and non-sensitive data // Define types for tables that hold sensitive and non-sensitive data
type SecretTable = { secret: Table<Secret> }; type SecretTable = { secret: Table<Secret> }
type SensitiveTables = { accounts: Table<Account> }; type SensitiveTables = { accounts: Table<Account> }
type NonsensitiveTables = { type NonsensitiveTables = {
contacts: Table<Contact>; contacts: Table<Contact>
logs: Table<Log>; logs: Table<Log>
settings: Table<Settings>; settings: Table<Settings>
temp: Table<Temp>; temp: Table<Temp>
}; }
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T; export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T; export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> = export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T; BaseDexie & T
//// Initialize the DBs, starting with the sensitive ones. //// Initialize the DBs, starting with the sensitive ones.
// Initialize Dexie database for secret, which is then used to encrypt accountsDB // Initialize Dexie database for secret, which is then used to encrypt accountsDB
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie; export const secretDB = new BaseDexie('TimeSafariSecret') as SecretDexie
secretDB.version(1).stores(SecretSchema); secretDB.version(1).stores(SecretSchema)
// Initialize Dexie database for accounts // Initialize Dexie database for accounts
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie; const accountsDexie = new BaseDexie('TimeSafariAccounts') as SensitiveDexie
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount // Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
// so that it's clear whether the usage needs the private key inside. // so that it's clear whether the usage needs the private key inside.
@ -49,13 +49,13 @@ const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
// to a user action required to unlock the data. // to a user action required to unlock the data.
export const accountsDBPromise = useSecretAndInitializeAccountsDB( export const accountsDBPromise = useSecretAndInitializeAccountsDB(
secretDB, secretDB,
accountsDexie, accountsDexie
); )
//// Now initialize the other DB. //// Now initialize the other DB.
// Initialize Dexie databases for non-sensitive data // Initialize Dexie databases for non-sensitive data
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie; export const db = new BaseDexie('TimeSafari') as NonsensitiveDexie
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning // Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
@ -64,31 +64,31 @@ export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
db.version(2).stores({ db.version(2).stores({
...ContactSchema, ...ContactSchema,
...LogSchema, ...LogSchema,
...{ settings: "id" }, // old Settings schema ...{ settings: 'id' } // old Settings schema
}); })
// v3 added Temp // v3 added Temp
db.version(3).stores(TempSchema); db.version(3).stores(TempSchema)
db.version(4) db.version(4)
.stores(SettingsSchema) .stores(SettingsSchema)
.upgrade((tx) => { .upgrade((tx) => {
return tx return tx
.table("settings") .table('settings')
.toCollection() .toCollection()
.modify((settings) => { .modify((settings) => {
settings.accountDid = ""; // make it non-null for the default master settings, but still indexable settings.accountDid = '' // make it non-null for the default master settings, but still indexable
}); })
}); })
const DEFAULT_SETTINGS: Settings = { const DEFAULT_SETTINGS: Settings = {
id: MASTER_SETTINGS_KEY, id: MASTER_SETTINGS_KEY,
activeDid: undefined, activeDid: undefined,
apiServer: DEFAULT_ENDORSER_API_SERVER, apiServer: DEFAULT_ENDORSER_API_SERVER
}; }
// Event handler to initialize the non-sensitive database with default settings // Event handler to initialize the non-sensitive database with default settings
db.on("populate", async () => { db.on('populate', async () => {
await db.settings.add(DEFAULT_SETTINGS); await db.settings.add(DEFAULT_SETTINGS)
}); })
// Manage the encryption key. // Manage the encryption key.
@ -114,130 +114,130 @@ db.on("populate", async () => {
async function useSecretAndInitializeAccountsDB( async function useSecretAndInitializeAccountsDB(
secretDB: SecretDexie, secretDB: SecretDexie,
accountsDB: SensitiveDexie, accountsDB: SensitiveDexie
): Promise<SensitiveDexie> { ): Promise<SensitiveDexie> {
return secretDB return secretDB
.open() .open()
.then(() => { .then(() => {
return secretDB.secret.get(MASTER_SECRET_KEY); return secretDB.secret.get(MASTER_SECRET_KEY)
}) })
.then((secretRow?: Secret) => { .then((secretRow?: Secret) => {
let secret = secretRow?.secret; let secret = secretRow?.secret
if (secret != null) { if (secret != null) {
// they already have it in IndexedDB, so just pass it along // they already have it in IndexedDB, so just pass it along
return secret; return secret
} else { } else {
// check localStorage (for users before v 0.3.37) // check localStorage (for users before v 0.3.37)
const localSecret = localStorage.getItem("secret"); const localSecret = localStorage.getItem('secret')
if (localSecret != null) { if (localSecret != null) {
// they had one, so we want to move it to IndexedDB // they had one, so we want to move it to IndexedDB
secret = localSecret; secret = localSecret
} else { } else {
// they didn't have one, so let's generate one // they didn't have one, so let's generate one
secret = Encryption.createRandomEncryptionKey(); secret = Encryption.createRandomEncryptionKey()
} }
// it is not in IndexedDB, so add it now // it is not in IndexedDB, so add it now
return secretDB.secret return secretDB.secret
.add({ id: MASTER_SECRET_KEY, secret }) .add({ id: MASTER_SECRET_KEY, secret })
.then(() => { .then(() => {
return secret; return secret
}); })
} }
}) })
.then((secret?: string) => { .then((secret?: string) => {
if (secret == null) { if (secret == null) {
throw new Error("No secret found or created."); throw new Error('No secret found or created.')
} else { } else {
// apply encryption to the sensitive database using the secret key // apply encryption to the sensitive database using the secret key
encrypted(accountsDB, { secretKey: secret }); encrypted(accountsDB, { secretKey: secret })
accountsDB.version(1).stores(AccountsSchema); accountsDB.version(1).stores(AccountsSchema)
accountsDB.open(); accountsDB.open()
return accountsDB; return accountsDB
} }
}) })
.catch((error) => { .catch((error) => {
logConsoleAndDb("Error processing secret & encrypted accountsDB.", error); logConsoleAndDb('Error processing secret & encrypted accountsDB.', error)
// alert("There was an error processing encrypted data. See the Help page."); // alert("There was an error processing encrypted data. See the Help page.");
throw error; throw error
}); })
} }
// retrieves default settings // retrieves default settings
// calls db.open() // calls db.open()
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> { export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
await db.open(); await db.open()
return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS; return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS
} }
export async function retrieveSettingsForActiveAccount(): Promise<Settings> { export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
const defaultSettings = await retrieveSettingsForDefaultAccount(); const defaultSettings = await retrieveSettingsForDefaultAccount()
if (!defaultSettings.activeDid) { if (!defaultSettings.activeDid) {
return defaultSettings; return defaultSettings
} else { } else {
const overrideSettings = const overrideSettings =
(await db.settings (await db.settings
.where("accountDid") .where('accountDid')
.equals(defaultSettings.activeDid) .equals(defaultSettings.activeDid)
.first()) || {}; .first()) || {}
return R.mergeDeepRight(defaultSettings, overrideSettings); return R.mergeDeepRight(defaultSettings, overrideSettings)
} }
} }
export async function updateDefaultSettings( export async function updateDefaultSettings(
settingsChanges: Settings, settingsChanges: Settings
): Promise<void> { ): Promise<void> {
delete settingsChanges.accountDid; // just in case delete settingsChanges.accountDid // just in case
// ensure there is no "id" that would override the key // ensure there is no "id" that would override the key
delete settingsChanges.id; delete settingsChanges.id
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges); await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges)
} }
export async function updateAccountSettings( export async function updateAccountSettings(
accountDid: string, accountDid: string,
settingsChanges: Settings, settingsChanges: Settings
): Promise<void> { ): Promise<void> {
settingsChanges.accountDid = accountDid; settingsChanges.accountDid = accountDid
delete settingsChanges.id; // key off account, not ID delete settingsChanges.id // key off account, not ID
const result = await db.settings const result = await db.settings
.where("accountDid") .where('accountDid')
.equals(settingsChanges.accountDid) .equals(settingsChanges.accountDid)
.modify(settingsChanges); .modify(settingsChanges)
if (result === 0) { if (result === 0) {
if (!settingsChanges.id) { if (!settingsChanges.id) {
// It is unfortunate that we have to set this explicitly. // It is unfortunate that we have to set this explicitly.
// We didn't make id a "++id" at the beginning and Dexie won't let us change it, // We didn't make id a "++id" at the beginning and Dexie won't let us change it,
// plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0 // plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0
settingsChanges.id = (await db.settings.count()) + 1; settingsChanges.id = (await db.settings.count()) + 1
} }
await db.settings.add(settingsChanges); await db.settings.add(settingsChanges)
} }
} }
export async function logToDb(message: string): Promise<void> { export async function logToDb(message: string): Promise<void> {
await db.open(); await db.open()
const todayKey = new Date().toDateString(); const todayKey = new Date().toDateString()
// only keep one day's worth of logs // only keep one day's worth of logs
const previous = await db.logs.get(todayKey); const previous = await db.logs.get(todayKey)
if (!previous) { if (!previous) {
// when this is today's first log, clear out everything previous // when this is today's first log, clear out everything previous
// to avoid the log table getting too large // to avoid the log table getting too large
// (let's limit a different way someday) // (let's limit a different way someday)
await db.logs.clear(); await db.logs.clear()
} }
const prevMessages = (previous && previous.message) || ""; const prevMessages = (previous && previous.message) || ''
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`; const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`
await db.logs.update(todayKey, { message: fullMessage }); await db.logs.update(todayKey, { message: fullMessage })
} }
// similar method is in the sw_scripts/additional-scripts.js file // similar method is in the sw_scripts/additional-scripts.js file
export async function logConsoleAndDb( export async function logConsoleAndDb(
message: string, message: string,
isError = false, isError = false
): Promise<void> { ): Promise<void> {
if (isError) { if (isError) {
logger.error(`${new Date().toISOString()} ${message}`); logger.error(`${new Date().toISOString()} ${message}`)
} else { } else {
logger.log(`${new Date().toISOString()} ${message}`); logger.log(`${new Date().toISOString()} ${message}`)
} }
await logToDb(message); await logToDb(message)
} }

22
src/db/tables/accounts.ts

@ -5,45 +5,45 @@ export type Account = {
/** /**
* Auto-generated ID by Dexie * Auto-generated ID by Dexie
*/ */
id?: number; // this is only blank on input, when the database assigns it id?: number // this is only blank on input, when the database assigns it
/** /**
* The date the account was created * The date the account was created
*/ */
dateCreated: string; dateCreated: string
/** /**
* The derivation path for the account, if this is from a mnemonic * The derivation path for the account, if this is from a mnemonic
*/ */
derivationPath?: string; derivationPath?: string
/** /**
* Decentralized Identifier (DID) for the account * Decentralized Identifier (DID) for the account
*/ */
did: string; did: string
/** /**
* Stringified JSON containing underlying key material, if generated from a mnemonic * Stringified JSON containing underlying key material, if generated from a mnemonic
* Based on the IIdentifier type from Veramo * Based on the IIdentifier type from Veramo
* @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts} * @see {@link https://github.com/uport-project/veramo/blob/next/packages/core-types/src/types/IIdentifier.ts}
*/ */
identity?: string; identity?: string
/** /**
* The mnemonic phrase for the account, if this is from a mnemonic * The mnemonic phrase for the account, if this is from a mnemonic
*/ */
mnemonic?: string; mnemonic?: string
/** /**
* The Webauthn credential ID in hex, if this is from a passkey * The Webauthn credential ID in hex, if this is from a passkey
*/ */
passkeyCredIdHex?: string; passkeyCredIdHex?: string
/** /**
* The public key in hexadecimal format * The public key in hexadecimal format
*/ */
publicKeyHex: string; publicKeyHex: string
}; }
/** /**
* Schema for the accounts table in the database. * Schema for the accounts table in the database.
@ -52,5 +52,5 @@ export type Account = {
*/ */
export const AccountsSchema = { export const AccountsSchema = {
accounts: accounts:
"++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex", '++id, dateCreated, derivationPath, did, $identity, $mnemonic, publicKeyHex'
}; }

28
src/db/tables/contacts.ts

@ -1,24 +1,24 @@
export interface ContactMethod { export interface ContactMethod {
label: string; label: string
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API" type: string // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string; value: string
} }
export interface Contact { export interface Contact {
// //
// When adding a property, consider whether it should be added when exporting & sharing contacts. // When adding a property, consider whether it should be added when exporting & sharing contacts.
did: string; did: string
contactMethods?: Array<ContactMethod>; contactMethods?: Array<ContactMethod>
name?: string; name?: string
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key nextPubKeyHashB64?: string // base64-encoded SHA256 hash of next public key
notes?: string; notes?: string
profileImageUrl?: string; profileImageUrl?: string
publicKeyBase64?: string; publicKeyBase64?: string
seesMe?: boolean; // cached value of the server setting seesMe?: boolean // cached value of the server setting
registered?: boolean; // cached value of the server setting registered?: boolean // cached value of the server setting
} }
export const ContactSchema = { export const ContactSchema = {
contacts: "&did, name", // no need to key by other things contacts: '&did, name' // no need to key by other things
}; }

8
src/db/tables/logs.ts

@ -1,11 +1,11 @@
export interface Log { export interface Log {
date: string; date: string
message: string; message: string
} }
export const LogSchema = { export const LogSchema = {
// Currently keyed by "date" because A) today's log data is what we need so we append, and // Currently keyed by "date" because A) today's log data is what we need so we append, and
// B) we don't want it to grow so we remove everything if this is the first entry today. // B) we don't want it to grow so we remove everything if this is the first entry today.
// See safari-notifications.js logMessage for the associated logic. // See safari-notifications.js logMessage for the associated logic.
logs: "date", // definitely don't key by the potentially large message field logs: 'date' // definitely don't key by the potentially large message field
}; }

10
src/db/tables/secret.ts

@ -5,14 +5,14 @@ export type Secret = {
/** /**
* Auto-generated ID by Dexie * Auto-generated ID by Dexie
*/ */
id: number; id: number
/** /**
* The secret key used to decrypt the identity if they're not using their own password * The secret key used to decrypt the identity if they're not using their own password
*/ */
secret: string; secret: string
}; }
export const SecretSchema = { secret: "++id, secret" }; export const SecretSchema = { secret: '++id, secret' }
export const MASTER_SECRET_KEY = 0; export const MASTER_SECRET_KEY = 0

86
src/db/tables/settings.ts

@ -2,82 +2,82 @@
* BoundingBox type describes the geographical bounding box coordinates. * BoundingBox type describes the geographical bounding box coordinates.
*/ */
export type BoundingBox = { export type BoundingBox = {
eastLong: number; // Eastern longitude eastLong: number // Eastern longitude
maxLat: number; // Maximum (Northernmost) latitude maxLat: number // Maximum (Northernmost) latitude
minLat: number; // Minimum (Southernmost) latitude minLat: number // Minimum (Southernmost) latitude
westLong: number; // Western longitude westLong: number // Western longitude
}; }
/** /**
* Settings type encompasses user-specific configuration details. * Settings type encompasses user-specific configuration details.
*/ */
export type Settings = { export type Settings = {
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID // default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
id?: number; // this is erased for all those entries that are keyed with accountDid id?: number // this is erased for all those entries that are keyed with accountDid
// if supplied, this settings record overrides the master record when the user switches to this account // if supplied, this settings record overrides the master record when the user switches to this account
accountDid?: string; // not used in the MASTER_SETTINGS_KEY entry accountDid?: string // not used in the MASTER_SETTINGS_KEY entry
// active Decentralized ID // active Decentralized ID
activeDid?: string; // only used in the MASTER_SETTINGS_KEY entry activeDid?: string // only used in the MASTER_SETTINGS_KEY entry
apiServer?: string; // API server URL apiServer?: string // API server URL
filterFeedByNearby?: boolean; // filter by nearby filterFeedByNearby?: boolean // filter by nearby
filterFeedByVisible?: boolean; // filter by visible users ie. anyone not hidden filterFeedByVisible?: boolean // filter by visible users ie. anyone not hidden
finishedOnboarding?: boolean; // the user has completed the onboarding process finishedOnboarding?: boolean // the user has completed the onboarding process
firstName?: string; // user's full name, may be null if unwanted for a particular account firstName?: string // user's full name, may be null if unwanted for a particular account
hideRegisterPromptOnNewContact?: boolean; hideRegisterPromptOnNewContact?: boolean
isRegistered?: boolean; isRegistered?: boolean
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable // imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
lastName?: string; // deprecated - put all names in firstName lastName?: string // deprecated - put all names in firstName
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing lastAckedOfferToUserJwtId?: string // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing lastAckedOfferToUserProjectsJwtId?: string // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed // The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string; lastNotifiedClaimId?: string
lastViewedClaimId?: string; lastViewedClaimId?: string
notifyingNewActivityTime?: string; // set to their chosen time if they have turned on daily check for new activity via the push server notifyingNewActivityTime?: string // set to their chosen time if they have turned on daily check for new activity via the push server
notifyingReminderMessage?: string; // set to their chosen message for a daily reminder notifyingReminderMessage?: string // set to their chosen message for a daily reminder
notifyingReminderTime?: string; // set to their chosen time for a daily reminder notifyingReminderTime?: string // set to their chosen time for a daily reminder
partnerApiServer?: string; // partner server API URL partnerApiServer?: string // partner server API URL
passkeyExpirationMinutes?: number; // passkey access token time-to-live in minutes passkeyExpirationMinutes?: number // passkey access token time-to-live in minutes
profileImageUrl?: string; // may be null if unwanted for a particular account profileImageUrl?: string // may be null if unwanted for a particular account
// Array of named search boxes defined by bounding boxes // Array of named search boxes defined by bounding boxes
searchBoxes?: Array<{ searchBoxes?: Array<{
name: string; name: string
bbox: BoundingBox; bbox: BoundingBox
}>; }>
showContactGivesInline?: boolean; // Display contact inline or not showContactGivesInline?: boolean // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag showGeneralAdvanced?: boolean // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions showShortcutBvc?: boolean // Show shortcut for Bountiful Voluntaryist Community actions
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push vapid?: string // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server warnIfProdServer?: boolean // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server warnIfTestServer?: boolean // Warn if using a testing server
webPushServer?: string; // Web Push server URL webPushServer?: string // Web Push server URL
}; }
export function checkIsAnyFeedFilterOn(settings: Settings): boolean { export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible); return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible)
} }
/** /**
* Schema for the Settings table in the database. * Schema for the Settings table in the database.
*/ */
export const SettingsSchema = { export const SettingsSchema = {
settings: "id, &accountDid", settings: 'id, &accountDid'
}; }
/** /**
* Constants. * Constants.
*/ */
export const MASTER_SETTINGS_KEY = 1; export const MASTER_SETTINGS_KEY = 1
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15; export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15

10
src/db/tables/temp.ts

@ -1,12 +1,12 @@
// for ephemeral uses, eg. passing a blob from the service worker to the main thread // for ephemeral uses, eg. passing a blob from the service worker to the main thread
export type Temp = { export type Temp = {
id: string; id: string
blob?: Blob; // deprecated because webkit (Safari) does not support Blob blob?: Blob // deprecated because webkit (Safari) does not support Blob
blobB64?: string; // base64-encoded blob blobB64?: string // base64-encoded blob
}; }
/** /**
* Schema for the Temp table in the database. * Schema for the Temp table in the database.
*/ */
export const TempSchema = { temp: "id" }; export const TempSchema = { temp: 'id' }

166
src/electron/main.js

@ -1,16 +1,16 @@
const { app, BrowserWindow } = require("electron"); const { app, BrowserWindow } = require('electron')
const path = require("path"); const path = require('path')
const fs = require("fs"); const fs = require('fs')
const logger = require("../utils/logger"); const logger = require('../utils/logger')
// Check if running in dev mode // Check if running in dev mode
const isDev = process.argv.includes("--inspect"); const isDev = process.argv.includes('--inspect')
function createWindow() { function createWindow() {
// Add before createWindow function // Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js"); const preloadPath = path.join(__dirname, 'preload.js')
logger.log("Checking preload path:", preloadPath); logger.log('Checking preload path:', preloadPath)
logger.log("Preload exists:", fs.existsSync(preloadPath)); logger.log('Preload exists:', fs.existsSync(preloadPath))
// Create the browser window. // Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
@ -21,71 +21,71 @@ function createWindow() {
contextIsolation: true, contextIsolation: true,
webSecurity: true, webSecurity: true,
allowRunningInsecureContent: false, allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"), preload: path.join(__dirname, 'preload.js')
}, }
}); })
// Always open DevTools for now // Always open DevTools for now
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools()
// Intercept requests to fix asset paths // Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest( mainWindow.webContents.session.webRequest.onBeforeRequest(
{ {
urls: [ urls: [
"file://*/*/assets/*", 'file://*/*/assets/*',
"file://*/assets/*", 'file://*/assets/*',
"file:///assets/*", // Catch absolute paths 'file:///assets/*', // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback '<all_urls>' // Catch all URLs as a fallback
], ]
}, },
(details, callback) => { (details, callback) => {
let url = details.url; let url = details.url
// Handle paths that don't start with file:// // Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) { if (!url.startsWith('file://') && url.includes('/assets/')) {
url = `file://${path.join(__dirname, "www", url)}`; url = `file://${path.join(__dirname, 'www', url)}`
} }
// Handle absolute paths starting with /assets/ // Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) { if (url.includes('/assets/') && !url.includes('/www/assets/')) {
const baseDir = url.includes("dist-electron") const baseDir = url.includes('dist-electron')
? url.substring( ? url.substring(
0, 0,
url.indexOf("/dist-electron") + "/dist-electron".length, url.indexOf('/dist-electron') + '/dist-electron'.length
) )
: `file://${__dirname}`; : `file://${__dirname}`
const assetPath = url.split("/assets/")[1]; const assetPath = url.split('/assets/')[1]
const newUrl = `${baseDir}/www/assets/${assetPath}`; const newUrl = `${baseDir}/www/assets/${assetPath}`
callback({ redirectURL: newUrl }); callback({ redirectURL: newUrl })
return; return
} }
callback({}); // No redirect for other URLs callback({}) // No redirect for other URLs
}, }
); )
if (isDev) { if (isDev) {
// Debug info // Debug info
logger.log("Debug Info:"); logger.log('Debug Info:')
logger.log("Running in dev mode:", isDev); logger.log('Running in dev mode:', isDev)
logger.log("App is packaged:", app.isPackaged); logger.log('App is packaged:', app.isPackaged)
logger.log("Process resource path:", process.resourcesPath); logger.log('Process resource path:', process.resourcesPath)
logger.log("App path:", app.getAppPath()); logger.log('App path:', app.getAppPath())
logger.log("__dirname:", __dirname); logger.log('__dirname:', __dirname)
logger.log("process.cwd():", process.cwd()); logger.log('process.cwd():', process.cwd())
} }
const indexPath = path.join(__dirname, "www", "index.html"); const indexPath = path.join(__dirname, 'www', 'index.html')
if (isDev) { if (isDev) {
logger.log("Loading index from:", indexPath); logger.log('Loading index from:', indexPath)
logger.log("www path:", path.join(__dirname, "www")); logger.log('www path:', path.join(__dirname, 'www'))
logger.log("www assets path:", path.join(__dirname, "www", "assets")); logger.log('www assets path:', path.join(__dirname, 'www', 'assets'))
} }
if (!fs.existsSync(indexPath)) { if (!fs.existsSync(indexPath)) {
logger.error(`Index file not found at: ${indexPath}`); logger.error(`Index file not found at: ${indexPath}`)
throw new Error("Index file not found"); throw new Error('Index file not found')
} }
// Add CSP headers to allow API connections // Add CSP headers to allow API connections
@ -94,81 +94,81 @@ function createWindow() {
callback({ callback({
responseHeaders: { responseHeaders: {
...details.responseHeaders, ...details.responseHeaders,
"Content-Security-Policy": [ 'Content-Security-Policy': [
"default-src 'self';" + "default-src 'self';" +
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" + "connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
"img-src 'self' data: https: blob:;" + "img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" + "script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
"style-src 'self' 'unsafe-inline';" + "style-src 'self' 'unsafe-inline';" +
"font-src 'self' data:;", "font-src 'self' data:;"
], ]
}, }
}); })
}, }
); )
// Load the index.html // Load the index.html
mainWindow mainWindow
.loadFile(indexPath) .loadFile(indexPath)
.then(() => { .then(() => {
logger.log("Successfully loaded index.html"); logger.log('Successfully loaded index.html')
if (isDev) { if (isDev) {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools()
logger.log("DevTools opened - running in dev mode"); logger.log('DevTools opened - running in dev mode')
} }
}) })
.catch((err) => { .catch((err) => {
logger.error("Failed to load index.html:", err); logger.error('Failed to load index.html:', err)
logger.error("Attempted path:", indexPath); logger.error('Attempted path:', indexPath)
}); })
// Listen for console messages from the renderer // Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, level, message) => { mainWindow.webContents.on('console-message', (_event, level, message) => {
logger.log("Renderer Console:", message); logger.log('Renderer Console:', message)
}); })
// Add right after creating the BrowserWindow // Add right after creating the BrowserWindow
mainWindow.webContents.on( mainWindow.webContents.on(
"did-fail-load", 'did-fail-load',
(event, errorCode, errorDescription) => { (event, errorCode, errorDescription) => {
logger.error("Page failed to load:", errorCode, errorDescription); logger.error('Page failed to load:', errorCode, errorDescription)
}, }
); )
mainWindow.webContents.on("preload-error", (event, preloadPath, error) => { mainWindow.webContents.on('preload-error', (event, preloadPath, error) => {
logger.error("Preload script error:", preloadPath, error); logger.error('Preload script error:', preloadPath, error)
}); })
mainWindow.webContents.on( mainWindow.webContents.on(
"console-message", 'console-message',
(event, level, message, line, sourceId) => { (event, level, message, line, sourceId) => {
logger.log("Renderer Console:", line, sourceId, message); logger.log('Renderer Console:', line, sourceId, message)
}, }
); )
// Enable remote debugging when in dev mode // Enable remote debugging when in dev mode
if (isDev) { if (isDev) {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools()
} }
} }
// Handle app ready // Handle app ready
app.whenReady().then(createWindow); app.whenReady().then(createWindow)
// Handle all windows closed // Handle all windows closed
app.on("window-all-closed", () => { app.on('window-all-closed', () => {
if (process.platform !== "darwin") { if (process.platform !== 'darwin') {
app.quit(); app.quit()
} }
}); })
app.on("activate", () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow()
} }
}); })
// Handle any errors // Handle any errors
process.on("uncaughtException", (error) => { process.on('uncaughtException', (error) => {
logger.error("Uncaught Exception:", error); logger.error('Uncaught Exception:', error)
}); })

58
src/electron/preload.js

@ -1,78 +1,78 @@
const { contextBridge, ipcRenderer } = require("electron"); const { contextBridge, ipcRenderer } = require('electron')
const logger = { const logger = {
log: (message, ...args) => { log: (message, ...args) => {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */ /* eslint-disable no-console */
console.log(message, ...args); console.log(message, ...args)
/* eslint-enable no-console */ /* eslint-enable no-console */
} }
}, },
warn: (message, ...args) => { warn: (message, ...args) => {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== 'production') {
/* eslint-disable no-console */ /* eslint-disable no-console */
console.warn(message, ...args); console.warn(message, ...args)
/* eslint-enable no-console */ /* eslint-enable no-console */
} }
}, },
error: (message, ...args) => { error: (message, ...args) => {
/* eslint-disable no-console */ /* eslint-disable no-console */
console.error(message, ...args); // Errors should always be logged console.error(message, ...args) // Errors should always be logged
/* eslint-enable no-console */ /* eslint-enable no-console */
}, }
}; }
// Use a more direct path resolution approach // Use a more direct path resolution approach
const getPath = (pathType) => { const getPath = (pathType) => {
switch (pathType) { switch (pathType) {
case "userData": case 'userData':
return ( return (
process.env.APPDATA || process.env.APPDATA ||
(process.platform === "darwin" (process.platform === 'darwin'
? `${process.env.HOME}/Library/Application Support` ? `${process.env.HOME}/Library/Application Support`
: `${process.env.HOME}/.local/share`) : `${process.env.HOME}/.local/share`)
); )
case "home": case 'home':
return process.env.HOME; return process.env.HOME
case "appPath": case 'appPath':
return process.resourcesPath; return process.resourcesPath
default: default:
return ""; return ''
} }
}; }
logger.log("Preload script starting..."); logger.log('Preload script starting...')
try { try {
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld('electronAPI', {
// Path utilities // Path utilities
getPath, getPath,
// IPC functions // IPC functions
send: (channel, data) => { send: (channel, data) => {
const validChannels = ["toMain"]; const validChannels = ['toMain']
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data); ipcRenderer.send(channel, data)
} }
}, },
receive: (channel, func) => { receive: (channel, func) => {
const validChannels = ["fromMain"]; const validChannels = ['fromMain']
if (validChannels.includes(channel)) { if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(...args)); ipcRenderer.on(channel, (event, ...args) => func(...args))
} }
}, },
// Environment info // Environment info
env: { env: {
isElectron: true, isElectron: true,
isDev: process.env.NODE_ENV === "development", isDev: process.env.NODE_ENV === 'development'
}, },
// Path utilities // Path utilities
getBasePath: () => { getBasePath: () => {
return process.env.NODE_ENV === "development" ? "/" : "./"; return process.env.NODE_ENV === 'development' ? '/' : './'
}, }
}); })
logger.log("Preload script completed successfully"); logger.log('Preload script completed successfully')
} catch (error) { } catch (error) {
logger.error("Error in preload script:", error); logger.error('Error in preload script:', error)
} }

54
src/interfaces/claims-result.ts

@ -1,58 +1,58 @@
import { AxiosResponse } from "axios"; import { AxiosResponse } from 'axios'
import { GiverReceiverInputInfo } from "../libs/util"; import { GiverReceiverInputInfo } from '../libs/util'
import { ErrorResult, ResultWithType } from "./common"; import { ErrorResult, ResultWithType } from './common'
export interface GiverOutputInfo { export interface GiverOutputInfo {
action: string; action: string
giver?: GiverReceiverInputInfo; giver?: GiverReceiverInputInfo
description?: string; description?: string
amount?: number; amount?: number
unitCode?: string; unitCode?: string
} }
export interface ClaimResult { export interface ClaimResult {
success: { claimId: string; handleId: string }; success: { claimId: string; handleId: string }
error: { code: string; message: string }; error: { code: string; message: string }
} }
export interface VerifiableCredential { export interface VerifiableCredential {
exp?: number; exp?: number
iat: number; iat: number
iss: string; iss: string
vc: { vc: {
"@context": string[]; '@context': string[]
type: string[]; type: string[]
credentialSubject: VerifiableCredentialSubject; credentialSubject: VerifiableCredentialSubject
}; }
} }
export interface VerifiableCredentialSubject { export interface VerifiableCredentialSubject {
"@context": string; '@context': string
"@type": string; '@type': string
[key: string]: unknown; [key: string]: unknown
} }
export interface WorldProperties { export interface WorldProperties {
startTime?: string; startTime?: string
endTime?: string; endTime?: string
} }
export interface ProviderInfo { export interface ProviderInfo {
/** /**
* Could be a DID or a handleId that identifies the provider * Could be a DID or a handleId that identifies the provider
*/ */
identifier: string; identifier: string
/** /**
* Indicates if the provider link has been confirmed * Indicates if the provider link has been confirmed
*/ */
linkConfirmed: boolean; linkConfirmed: boolean
} }
// Type for createAndSubmitClaim result // Type for createAndSubmitClaim result
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult; export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult
// Update SuccessResult to use ClaimResult // Update SuccessResult to use ClaimResult
export interface SuccessResult extends ResultWithType { export interface SuccessResult extends ResultWithType {
type: "success"; type: 'success'
response: AxiosResponse<ClaimResult>; response: AxiosResponse<ClaimResult>
} }

86
src/interfaces/claims.ts

@ -1,68 +1,68 @@
import { GenericVerifiableCredential } from "./common"; import { GenericVerifiableCredential } from './common'
export interface AgreeVerifiableCredential { export interface AgreeVerifiableCredential {
"@context": string; '@context': string
"@type": string; '@type': string
object: Record<string, unknown>; object: Record<string, unknown>
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4 // https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential { export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; '@context'?: string
"@type": "GiveAction"; '@type': 'GiveAction'
agent?: { identifier: string }; agent?: { identifier: string }
description?: string; description?: string
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[]; fulfills?: { '@type': string; identifier?: string; lastClaimId?: string }[]
identifier?: string; identifier?: string
image?: string; image?: string
object?: { amountOfThisGood: number; unitCode: string }; object?: { amountOfThisGood: number; unitCode: string }
provider?: GenericVerifiableCredential; provider?: GenericVerifiableCredential
recipient?: { identifier: string }; recipient?: { identifier: string }
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8 // https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential { export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string; '@context'?: string
"@type": "Offer"; '@type': 'Offer'
description?: string; description?: string
includesObject?: { amountOfThisGood: number; unitCode: string }; includesObject?: { amountOfThisGood: number; unitCode: string }
itemOffered?: { itemOffered?: {
description?: string; description?: string
isPartOf?: { isPartOf?: {
identifier?: string; identifier?: string
lastClaimId?: string; lastClaimId?: string
"@type"?: string; '@type'?: string
name?: string; name?: string
}; }
}; }
offeredBy?: { identifier: string }; offeredBy?: { identifier: string }
recipient?: { identifier: string }; recipient?: { identifier: string }
validThrough?: string; validThrough?: string
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7 // https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential { export interface PlanVerifiableCredential extends GenericVerifiableCredential {
"@context": "https://schema.org"; '@context': 'https://schema.org'
"@type": "PlanAction"; '@type': 'PlanAction'
name: string; name: string
agent?: { identifier: string }; agent?: { identifier: string }
description?: string; description?: string
identifier?: string; identifier?: string
lastClaimId?: string; lastClaimId?: string
location?: { location?: {
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number }; geo: { '@type': 'GeoCoordinates'; latitude: number; longitude: number }
}; }
} }
// AKA Registration & RegisterAction // AKA Registration & RegisterAction
export interface RegisterVerifiableCredential { export interface RegisterVerifiableCredential {
"@context": string; '@context': string
"@type": "RegisterAction"; '@type': 'RegisterAction'
agent: { identifier: string }; agent: { identifier: string }
identifier?: string; identifier?: string
object: string; object: string
participant?: { identifier: string }; participant?: { identifier: string }
} }

34
src/interfaces/common.ts

@ -1,36 +1,36 @@
// similar to VerifiableCredentialSubject... maybe rename this // similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context"?: string; '@context'?: string
"@type": string; '@type': string
[key: string]: unknown; [key: string]: unknown
} }
export interface GenericCredWrapper<T extends GenericVerifiableCredential> { export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
claim: T; claim: T
claimType?: string; claimType?: string
handleId: string; handleId: string
id: string; id: string
issuedAt: string; issuedAt: string
issuer: string; issuer: string
publicUrls?: Record<string, string>; publicUrls?: Record<string, string>
} }
export interface ResultWithType { export interface ResultWithType {
type: string; type: string
} }
export interface ErrorResponse { export interface ErrorResponse {
error?: { error?: {
message?: string; message?: string
}; }
} }
export interface InternalError { export interface InternalError {
error: string; error: string
userMessage?: string; userMessage?: string
} }
export interface ErrorResult extends ResultWithType { export interface ErrorResult extends ResultWithType {
type: "error"; type: 'error'
error: InternalError; error: InternalError
} }

4
src/interfaces/deepLinks.ts

@ -8,6 +8,6 @@
*/ */
export interface DeepLinkError extends Error { export interface DeepLinkError extends Error {
code: string; code: string
details?: unknown; details?: unknown
} }

14
src/interfaces/index.ts

@ -1,7 +1,7 @@
export * from "./claims"; export * from './claims'
export * from "./claims-result"; export * from './claims-result'
export * from "./common"; export * from './common'
export * from "./limits"; export * from './limits'
export * from "./records"; export * from './records'
export * from "./user"; export * from './user'
export * from "./deepLinks"; export * from './deepLinks'

18
src/interfaces/limits.ts

@ -1,14 +1,14 @@
export interface EndorserRateLimits { export interface EndorserRateLimits {
doneClaimsThisWeek: string; doneClaimsThisWeek: string
doneRegistrationsThisMonth: string; doneRegistrationsThisMonth: string
maxClaimsPerWeek: string; maxClaimsPerWeek: string
maxRegistrationsPerMonth: string; maxRegistrationsPerMonth: string
nextMonthBeginDateTime: string; nextMonthBeginDateTime: string
nextWeekBeginDateTime: string; nextWeekBeginDateTime: string
} }
export interface ImageRateLimits { export interface ImageRateLimits {
doneImagesThisWeek: string; doneImagesThisWeek: string
maxImagesPerWeek: string; maxImagesPerWeek: string
nextWeekBeginDateTime: string; nextWeekBeginDateTime: string
} }

106
src/interfaces/records.ts

@ -1,64 +1,64 @@
import { GiveVerifiableCredential, OfferVerifiableCredential } from "./claims"; import { GiveVerifiableCredential, OfferVerifiableCredential } from './claims'
// a summary record; the VC is found the fullClaim field // a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord { export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveVerifiableCredential; [x: string]: PropertyKey | undefined | GiveVerifiableCredential
type?: string; type?: string
agentDid: string; agentDid: string
amount: number; amount: number
amountConfirmed: number; amountConfirmed: number
description: string; description: string
fullClaim: GiveVerifiableCredential; fullClaim: GiveVerifiableCredential
fulfillsHandleId: string; fulfillsHandleId: string
fulfillsPlanHandleId?: string; fulfillsPlanHandleId?: string
fulfillsType?: string; fulfillsType?: string
handleId: string; handleId: string
issuedAt: string; issuedAt: string
issuerDid: string; issuerDid: string
jwtId: string; jwtId: string
providerPlanHandleId?: string; providerPlanHandleId?: string
recipientDid: string; recipientDid: string
unit: string; unit: string
} }
// a summary record; the VC is found the fullClaim field // a summary record; the VC is found the fullClaim field
export interface OfferSummaryRecord { export interface OfferSummaryRecord {
amount: number; amount: number
amountGiven: number; amountGiven: number
amountGivenConfirmed: number; amountGivenConfirmed: number
fullClaim: OfferVerifiableCredential; fullClaim: OfferVerifiableCredential
fulfillsPlanHandleId: string; fulfillsPlanHandleId: string
handleId: string; handleId: string
issuerDid: string; issuerDid: string
jwtId: string; jwtId: string
nonAmountGivenConfirmed: number; nonAmountGivenConfirmed: number
objectDescription: string; objectDescription: string
offeredByDid: string; offeredByDid: string
recipientDid: string; recipientDid: string
requirementsMet: boolean; requirementsMet: boolean
unit: string; unit: string
validThrough: string; validThrough: string
} }
export interface OfferToPlanSummaryRecord extends OfferSummaryRecord { export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
planName: string; planName: string
} }
// a summary record; the VC is not currently part of this record // a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord { export interface PlanSummaryRecord {
agentDid?: string; agentDid?: string
description: string; description: string
endTime?: string; endTime?: string
fulfillsPlanHandleId: string; fulfillsPlanHandleId: string
handleId: string; handleId: string
image?: string; image?: string
issuerDid: string; issuerDid: string
locLat?: number; locLat?: number
locLon?: number; locLon?: number
name?: string; name?: string
startTime?: string; startTime?: string
url?: string; url?: string
jwtId?: string; jwtId?: string
} }
/** /**
@ -71,23 +71,23 @@ export interface PlanData {
/** /**
* Description of the project * Description of the project
**/ **/
description: string; description: string
/** /**
* URL referencing information about the project * URL referencing information about the project
**/ **/
handleId: string; handleId: string
image?: string; image?: string
/** /**
* The DID of the issuer * The DID of the issuer
*/ */
issuerDid: string; issuerDid: string
/** /**
* Name of the project * Name of the project
**/ **/
name: string; name: string
/** /**
* The identifier of the project record -- different from jwtId * The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.) * (Maybe we should use the jwtId to iterate through the records instead.)
**/ **/
rowId?: string; rowId?: string
} }

12
src/interfaces/user.ts

@ -1,8 +1,8 @@
export interface UserInfo { export interface UserInfo {
did: string; did: string
name: string; name: string
publicEncKey: string; publicEncKey: string
registered: boolean; registered: boolean
profileImageUrl?: string; profileImageUrl?: string
nextPublicEncKeyHash?: string; nextPublicEncKeyHash?: string
} }

36
src/lib/capacitor/app.ts

@ -3,9 +3,9 @@
import { import {
App as CapacitorApp, App as CapacitorApp,
AppLaunchUrl, AppLaunchUrl,
BackButtonListener, BackButtonListener
} from "../../../node_modules/@capacitor/app"; } from '../../../node_modules/@capacitor/app'
import type { PluginListenerHandle } from "@capacitor/core"; import type { PluginListenerHandle } from '@capacitor/core'
/** /**
* Interface defining the app event listener functionality * Interface defining the app event listener functionality
@ -19,9 +19,9 @@ interface AppInterface {
* @returns Promise that resolves with a removable listener handle * @returns Promise that resolves with a removable listener handle
*/ */
addListener( addListener(
eventName: "backButton", eventName: 'backButton',
listenerFunc: BackButtonListener, listenerFunc: BackButtonListener
): Promise<PluginListenerHandle> & PluginListenerHandle; ): Promise<PluginListenerHandle> & PluginListenerHandle
/** /**
* Add listener for app URL open events * Add listener for app URL open events
@ -30,9 +30,9 @@ interface AppInterface {
* @returns Promise that resolves with a removable listener handle * @returns Promise that resolves with a removable listener handle
*/ */
addListener( addListener(
eventName: "appUrlOpen", eventName: 'appUrlOpen',
listenerFunc: (data: AppLaunchUrl) => void, listenerFunc: (data: AppLaunchUrl) => void
): Promise<PluginListenerHandle> & PluginListenerHandle; ): Promise<PluginListenerHandle> & PluginListenerHandle
} }
/** /**
@ -41,19 +41,19 @@ interface AppInterface {
*/ */
export const App: AppInterface = { export const App: AppInterface = {
addListener( addListener(
eventName: "backButton" | "appUrlOpen", eventName: 'backButton' | 'appUrlOpen',
listenerFunc: BackButtonListener | ((data: AppLaunchUrl) => void), listenerFunc: BackButtonListener | ((data: AppLaunchUrl) => void)
): Promise<PluginListenerHandle> & PluginListenerHandle { ): Promise<PluginListenerHandle> & PluginListenerHandle {
if (eventName === "backButton") { if (eventName === 'backButton') {
return CapacitorApp.addListener( return CapacitorApp.addListener(
eventName, eventName,
listenerFunc as BackButtonListener, listenerFunc as BackButtonListener
) as Promise<PluginListenerHandle> & PluginListenerHandle; ) as Promise<PluginListenerHandle> & PluginListenerHandle
} else { } else {
return CapacitorApp.addListener( return CapacitorApp.addListener(
eventName, eventName,
listenerFunc as (data: AppLaunchUrl) => void, listenerFunc as (data: AppLaunchUrl) => void
) as Promise<PluginListenerHandle> & PluginListenerHandle; ) as Promise<PluginListenerHandle> & PluginListenerHandle
} }
}, }
}; }

12
src/lib/fontawesome.ts

@ -4,7 +4,7 @@
* @author Matthew Raymer * @author Matthew Raymer
*/ */
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faArrowDown, faArrowDown,
faArrowLeft, faArrowLeft,
@ -81,8 +81,8 @@ import {
faTriangleExclamation, faTriangleExclamation,
faUser, faUser,
faUsers, faUsers,
faXmark, faXmark
} from "@fortawesome/free-solid-svg-icons"; } from '@fortawesome/free-solid-svg-icons'
// Initialize Font Awesome library with all required icons // Initialize Font Awesome library with all required icons
library.add( library.add(
@ -161,8 +161,8 @@ library.add(
faTriangleExclamation, faTriangleExclamation,
faUser, faUser,
faUsers, faUsers,
faXmark, faXmark
); )
// Export the FontAwesomeIcon component for use in other files // Export the FontAwesomeIcon component for use in other files
export { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; export { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'

258
src/libs/crypto/index.ts

@ -1,21 +1,21 @@
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from '@veramo/core'
import { getRandomBytesSync } from "ethereum-cryptography/random"; import { getRandomBytesSync } from 'ethereum-cryptography/random'
import { entropyToMnemonic } from "ethereum-cryptography/bip39"; import { entropyToMnemonic } from 'ethereum-cryptography/bip39'
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english"; import { wordlist } from 'ethereum-cryptography/bip39/wordlists/english'
import { HDNode } from "@ethersproject/hdnode"; import { HDNode } from '@ethersproject/hdnode'
import { import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid, createEndorserJwtForDid,
CONTACT_URL_PATH_ENDORSER_CH_OLD, CONTACT_URL_PATH_ENDORSER_CH_OLD,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI
} from "../../libs/endorserServer"; } from '../../libs/endorserServer'
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; import { DEFAULT_DID_PROVIDER_NAME } from '../veramo/setup'
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger'
export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"; export const DEFAULT_ROOT_DERIVATION_PATH = "m/84737769'/0'/0'/0'"
export const LOCAL_KMS_NAME = "local"; export const LOCAL_KMS_NAME = 'local'
/** /**
* *
@ -30,10 +30,10 @@ export const newIdentifier = (
address: string, address: string,
publicHex: string, publicHex: string,
privateHex: string, privateHex: string,
derivationPath: string, derivationPath: string
): Omit<IIdentifier, keyof "provider"> => { ): Omit<IIdentifier, keyof 'provider'> => {
return { return {
did: DEFAULT_DID_PROVIDER_NAME + ":" + address, did: DEFAULT_DID_PROVIDER_NAME + ':' + address,
keys: [ keys: [
{ {
kid: publicHex, kid: publicHex,
@ -41,13 +41,13 @@ export const newIdentifier = (
meta: { derivationPath: derivationPath }, meta: { derivationPath: derivationPath },
privateKeyHex: privateHex, privateKeyHex: privateHex,
publicKeyHex: publicHex, publicKeyHex: publicHex,
type: "Secp256k1", type: 'Secp256k1'
}, }
], ],
provider: DEFAULT_DID_PROVIDER_NAME, provider: DEFAULT_DID_PROVIDER_NAME,
services: [], services: []
}; }
}; }
/** /**
* *
@ -57,22 +57,22 @@ export const newIdentifier = (
*/ */
export const deriveAddress = ( export const deriveAddress = (
mnemonic: string, mnemonic: string,
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH, derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH
): [string, string, string, string] => { ): [string, string, string, string] => {
mnemonic = mnemonic.trim().toLowerCase(); mnemonic = mnemonic.trim().toLowerCase()
const hdnode: HDNode = HDNode.fromMnemonic(mnemonic); const hdnode: HDNode = HDNode.fromMnemonic(mnemonic)
const rootNode: HDNode = hdnode.derivePath(derivationPath); const rootNode: HDNode = hdnode.derivePath(derivationPath)
const privateHex = rootNode.privateKey.substring(2); // original starts with '0x' const privateHex = rootNode.privateKey.substring(2) // original starts with '0x'
const publicHex = rootNode.publicKey.substring(2); // original starts with '0x' const publicHex = rootNode.publicKey.substring(2) // original starts with '0x'
const address = rootNode.address; const address = rootNode.address
return [address, privateHex, publicHex, derivationPath]; return [address, privateHex, publicHex, derivationPath]
}; }
export const generateRandomBytes = (numBytes: number): Uint8Array => { export const generateRandomBytes = (numBytes: number): Uint8Array => {
return getRandomBytesSync(numBytes); return getRandomBytesSync(numBytes)
}; }
/** /**
* *
@ -80,11 +80,11 @@ export const generateRandomBytes = (numBytes: number): Uint8Array => {
* @return {*} {string} * @return {*} {string}
*/ */
export const generateSeed = (): string => { export const generateSeed = (): string => {
const entropy: Uint8Array = getRandomBytesSync(32); const entropy: Uint8Array = getRandomBytesSync(32)
const mnemonic = entropyToMnemonic(entropy, wordlist); const mnemonic = entropyToMnemonic(entropy, wordlist)
return mnemonic; return mnemonic
}; }
/** /**
* Retrieve an access token, or "" if no DID is provided. * Retrieve an access token, or "" if no DID is provided.
@ -94,14 +94,14 @@ export const generateSeed = (): string => {
*/ */
export const accessToken = async (did?: string) => { export const accessToken = async (did?: string) => {
if (did) { if (did) {
const nowEpoch = Math.floor(Date.now() / 1000); const nowEpoch = Math.floor(Date.now() / 1000)
const endEpoch = nowEpoch + 60; // add one minute const endEpoch = nowEpoch + 60 // add one minute
const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }; const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did }
return createEndorserJwtForDid(did, tokenPayload); return createEndorserJwtForDid(did, tokenPayload)
} else { } else {
return ""; return ''
} }
}; }
/** /**
* Extract JWT from various URL formats * Extract JWT from various URL formats
@ -109,204 +109,204 @@ export const accessToken = async (did?: string) => {
* @return JWT string if found, original text otherwise * @return JWT string if found, original text otherwise
*/ */
export const getContactJwtFromJwtUrl = (jwtUrlText: string) => { export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText; let jwtText = jwtUrlText
// Handle various URL patterns // Handle various URL patterns
const URL_PATTERNS = [ const URL_PATTERNS = [
"/contact/confirm/", '/contact/confirm/',
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD, CONTACT_URL_PATH_ENDORSER_CH_OLD
]; ]
// Try each pattern // Try each pattern
for (const pattern of URL_PATTERNS) { for (const pattern of URL_PATTERNS) {
const patternIndex = jwtText.indexOf(pattern); const patternIndex = jwtText.indexOf(pattern)
if (patternIndex > -1) { if (patternIndex > -1) {
jwtText = jwtText.substring(patternIndex + pattern.length); jwtText = jwtText.substring(patternIndex + pattern.length)
break; break
} }
} }
// If no patterns matched but text starts with 'ey', assume it's already a JWT // If no patterns matched but text starts with 'ey', assume it's already a JWT
if (jwtText === jwtUrlText && jwtText.startsWith("ey")) { if (jwtText === jwtUrlText && jwtText.startsWith('ey')) {
return jwtText; return jwtText
} }
// Clean up any trailing URL parameters or fragments // Clean up any trailing URL parameters or fragments
const endIndex = jwtText.indexOf("?"); const endIndex = jwtText.indexOf('?')
if (endIndex > -1) { if (endIndex > -1) {
jwtText = jwtText.substring(0, endIndex); jwtText = jwtText.substring(0, endIndex)
} }
return jwtText; return jwtText
}; }
export const nextDerivationPath = (origDerivPath: string) => { export const nextDerivationPath = (origDerivPath: string) => {
let lastStr = origDerivPath.split("/").slice(-1)[0]; let lastStr = origDerivPath.split('/').slice(-1)[0]
if (lastStr.endsWith("'")) { if (lastStr.endsWith("'")) {
lastStr = lastStr.slice(0, -1); lastStr = lastStr.slice(0, -1)
} }
const lastNum = parseInt(lastStr, 10); const lastNum = parseInt(lastStr, 10)
const newLastNum = lastNum + 1; const newLastNum = lastNum + 1
const newLastStr = newLastNum.toString() + (lastStr.endsWith("'") ? "'" : ""); const newLastStr = newLastNum.toString() + (lastStr.endsWith("'") ? "'" : '')
const newDerivPath = origDerivPath const newDerivPath = origDerivPath
.split("/") .split('/')
.slice(0, -1) .slice(0, -1)
.concat([newLastStr]) .concat([newLastStr])
.join("/"); .join('/')
return newDerivPath; return newDerivPath
}; }
// Base64 encoding/decoding utilities for browser // Base64 encoding/decoding utilities for browser
function base64ToArrayBuffer(base64: string): Uint8Array { function base64ToArrayBuffer(base64: string): Uint8Array {
const binaryString = atob(base64); const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length); const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) { for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i); bytes[i] = binaryString.charCodeAt(i)
} }
return bytes; return bytes
} }
function arrayBufferToBase64(buffer: ArrayBuffer): string { function arrayBufferToBase64(buffer: ArrayBuffer): string {
const binary = String.fromCharCode(...new Uint8Array(buffer)); const binary = String.fromCharCode(...new Uint8Array(buffer))
return btoa(binary); return btoa(binary)
} }
const SALT_LENGTH = 16; const SALT_LENGTH = 16
const IV_LENGTH = 12; const IV_LENGTH = 12
const KEY_LENGTH = 256; const KEY_LENGTH = 256
const ITERATIONS = 100000; const ITERATIONS = 100000
// Encryption helper function // Encryption helper function
export async function encryptMessage(message: string, password: string) { export async function encryptMessage(message: string, password: string) {
const encoder = new TextEncoder(); const encoder = new TextEncoder()
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH))
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH))
// Derive key from password using PBKDF2 // Derive key from password using PBKDF2
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
"raw", 'raw',
encoder.encode(password), encoder.encode(password),
"PBKDF2", 'PBKDF2',
false, false,
["deriveBits", "deriveKey"], ['deriveBits', 'deriveKey']
); )
const key = await crypto.subtle.deriveKey( const key = await crypto.subtle.deriveKey(
{ {
name: "PBKDF2", name: 'PBKDF2',
salt, salt,
iterations: ITERATIONS, iterations: ITERATIONS,
hash: "SHA-256", hash: 'SHA-256'
}, },
keyMaterial, keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH }, { name: 'AES-GCM', length: KEY_LENGTH },
false, false,
["encrypt"], ['encrypt']
); )
// Encrypt the message // Encrypt the message
const encryptedContent = await crypto.subtle.encrypt( const encryptedContent = await crypto.subtle.encrypt(
{ {
name: "AES-GCM", name: 'AES-GCM',
iv, iv
}, },
key, key,
encoder.encode(message), encoder.encode(message)
); )
// Return a JSON structure with base64-encoded components // Return a JSON structure with base64-encoded components
const result = { const result = {
salt: arrayBufferToBase64(salt), salt: arrayBufferToBase64(salt),
iv: arrayBufferToBase64(iv), iv: arrayBufferToBase64(iv),
encrypted: arrayBufferToBase64(encryptedContent), encrypted: arrayBufferToBase64(encryptedContent)
}; }
return btoa(JSON.stringify(result)); return btoa(JSON.stringify(result))
} }
// Decryption helper function // Decryption helper function
export async function decryptMessage(encryptedJson: string, password: string) { export async function decryptMessage(encryptedJson: string, password: string) {
const decoder = new TextDecoder(); const decoder = new TextDecoder()
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson)); const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson))
// Convert base64 components back to Uint8Arrays // Convert base64 components back to Uint8Arrays
const saltArray = base64ToArrayBuffer(salt); const saltArray = base64ToArrayBuffer(salt)
const ivArray = base64ToArrayBuffer(iv); const ivArray = base64ToArrayBuffer(iv)
const encryptedContent = base64ToArrayBuffer(encrypted); const encryptedContent = base64ToArrayBuffer(encrypted)
// Derive the same key using PBKDF2 with the extracted salt // Derive the same key using PBKDF2 with the extracted salt
const keyMaterial = await crypto.subtle.importKey( const keyMaterial = await crypto.subtle.importKey(
"raw", 'raw',
new TextEncoder().encode(password), new TextEncoder().encode(password),
"PBKDF2", 'PBKDF2',
false, false,
["deriveBits", "deriveKey"], ['deriveBits', 'deriveKey']
); )
const key = await crypto.subtle.deriveKey( const key = await crypto.subtle.deriveKey(
{ {
name: "PBKDF2", name: 'PBKDF2',
salt: saltArray, salt: saltArray,
iterations: ITERATIONS, iterations: ITERATIONS,
hash: "SHA-256", hash: 'SHA-256'
}, },
keyMaterial, keyMaterial,
{ name: "AES-GCM", length: KEY_LENGTH }, { name: 'AES-GCM', length: KEY_LENGTH },
false, false,
["decrypt"], ['decrypt']
); )
// Decrypt the content // Decrypt the content
const decryptedContent = await crypto.subtle.decrypt( const decryptedContent = await crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: 'AES-GCM',
iv: ivArray, iv: ivArray
}, },
key, key,
encryptedContent, encryptedContent
); )
// Convert the decrypted content back to a string // Convert the decrypted content back to a string
return decoder.decode(decryptedContent); return decoder.decode(decryptedContent)
} }
// Test function to verify encryption/decryption // Test function to verify encryption/decryption
export async function testEncryptionDecryption() { export async function testEncryptionDecryption() {
try { try {
const testMessage = "Hello, this is a test message! 🚀"; const testMessage = 'Hello, this is a test message! 🚀'
const testPassword = "myTestPassword123"; const testPassword = 'myTestPassword123'
logger.log("Original message:", testMessage); logger.log('Original message:', testMessage)
// Test encryption // Test encryption
logger.log("Encrypting..."); logger.log('Encrypting...')
const encrypted = await encryptMessage(testMessage, testPassword); const encrypted = await encryptMessage(testMessage, testPassword)
logger.log("Encrypted result:", encrypted); logger.log('Encrypted result:', encrypted)
// Test decryption // Test decryption
logger.log("Decrypting..."); logger.log('Decrypting...')
const decrypted = await decryptMessage(encrypted, testPassword); const decrypted = await decryptMessage(encrypted, testPassword)
logger.log("Decrypted result:", decrypted); logger.log('Decrypted result:', decrypted)
// Verify // Verify
const success = testMessage === decrypted; const success = testMessage === decrypted
logger.log("Test " + (success ? "PASSED ✅" : "FAILED ❌")); logger.log('Test ' + (success ? 'PASSED ✅' : 'FAILED ❌'))
logger.log("Messages match:", success); logger.log('Messages match:', success)
// Test with wrong password // Test with wrong password
logger.log("\nTesting with wrong password..."); logger.log('\nTesting with wrong password...')
try { try {
await decryptMessage(encrypted, "wrongPassword"); await decryptMessage(encrypted, 'wrongPassword')
logger.log("Should not reach here"); logger.log('Should not reach here')
} catch (error) { } catch (error) {
logger.log("Correctly failed with wrong password ✅"); logger.log('Correctly failed with wrong password ✅')
} }
return success; return success
} catch (error) { } catch (error) {
logger.error("Test failed with error:", error); logger.error('Test failed with error:', error)
return false; return false
} }
} }

32
src/libs/crypto/vc/did-eth-local-resolver.ts

@ -10,37 +10,37 @@
* Similar code resides in endorser-ch and image-api * Similar code resides in endorser-ch and image-api
*/ */
export const didEthLocalResolver = async (did: string) => { export const didEthLocalResolver = async (did: string) => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/; const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/
const match = did.match(didRegex); const match = did.match(didRegex)
if (match) { if (match) {
const address = match[1]; // Extract eth address: 0x... const address = match[1] // Extract eth address: 0x...
const publicKeyHex = address; // Use the address directly as a public key placeholder const publicKeyHex = address // Use the address directly as a public key placeholder
return { return {
didDocumentMetadata: {}, didDocumentMetadata: {},
didResolutionMetadata: { didResolutionMetadata: {
contentType: "application/did+ld+json", contentType: 'application/did+ld+json'
}, },
didDocument: { didDocument: {
"@context": [ '@context': [
"https://www.w3.org/ns/did/v1", 'https://www.w3.org/ns/did/v1',
"https://w3id.org/security/suites/secp256k1recovery-2020/v2", 'https://w3id.org/security/suites/secp256k1recovery-2020/v2'
], ],
id: did, id: did,
verificationMethod: [ verificationMethod: [
{ {
id: `${did}#controller`, id: `${did}#controller`,
type: "EcdsaSecp256k1RecoveryMethod2020", type: 'EcdsaSecp256k1RecoveryMethod2020',
controller: did, controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex, blockchainAccountId: 'eip155:1:' + publicKeyHex
}, }
], ],
authentication: [`${did}#controller`], authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`], assertionMethod: [`${did}#controller`]
}, }
}; }
} }
throw new Error(`Unsupported DID format: ${did}`); throw new Error(`Unsupported DID format: ${did}`)
}; }

78
src/libs/crypto/vc/didPeer.ts

@ -1,11 +1,11 @@
import { Buffer } from "buffer/"; import { Buffer } from 'buffer/'
import { decode as cborDecode } from "cbor-x"; import { decode as cborDecode } from 'cbor-x'
import { bytesToMultibase, multibaseToBytes } from "did-jwt"; import { bytesToMultibase, multibaseToBytes } from 'did-jwt'
import { getWebCrypto } from "../../../libs/crypto/vc/passkeyHelpers"; import { getWebCrypto } from '../../../libs/crypto/vc/passkeyHelpers'
export const PEER_DID_PREFIX = "did:peer:"; export const PEER_DID_PREFIX = 'did:peer:'
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0"; const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + '0'
/** /**
* *
@ -17,38 +17,38 @@ const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
export async function verifyPeerSignature( export async function verifyPeerSignature(
payloadBytes: Buffer, payloadBytes: Buffer,
issuerDid: string, issuerDid: string,
signatureBytes: Uint8Array, signatureBytes: Uint8Array
): Promise<boolean> { ): Promise<boolean> {
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid)
const WebCrypto = await getWebCrypto(); const WebCrypto = await getWebCrypto()
const verifyAlgorithm = { const verifyAlgorithm = {
name: "ECDSA", name: 'ECDSA',
hash: { name: "SHA-256" }, hash: { name: 'SHA-256' }
}; }
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk; const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk
const keyAlgorithm = { const keyAlgorithm = {
name: "ECDSA", name: 'ECDSA',
namedCurve: publicKeyJwk.crv, namedCurve: publicKeyJwk.crv
}; }
const publicKeyCryptoKey = await WebCrypto.subtle.importKey( const publicKeyCryptoKey = await WebCrypto.subtle.importKey(
"jwk", 'jwk',
publicKeyJwk, publicKeyJwk,
keyAlgorithm, keyAlgorithm,
false, false,
["verify"], ['verify']
); )
const verified = await WebCrypto.subtle.verify( const verified = await WebCrypto.subtle.verify(
verifyAlgorithm, verifyAlgorithm,
publicKeyCryptoKey, publicKeyCryptoKey,
signatureBytes, signatureBytes,
payloadBytes, payloadBytes
); )
return verified; return verified
} }
export function cborToKeys(publicKeyBytes: Uint8Array) { export function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes); const jwkObj = cborDecode(publicKeyBytes)
if ( if (
jwkObj[1] != 2 || // kty "EC" jwkObj[1] != 2 || // kty "EC"
jwkObj[3] != -7 || // alg "ES256" jwkObj[3] != -7 || // alg "ES256"
@ -56,32 +56,32 @@ export function cborToKeys(publicKeyBytes: Uint8Array) {
jwkObj[-2].length != 32 || // x jwkObj[-2].length != 32 || // x
jwkObj[-3].length != 32 // y jwkObj[-3].length != 32 // y
) { ) {
throw new Error("Unable to extract key."); throw new Error('Unable to extract key.')
} }
const publicKeyJwk = { const publicKeyJwk = {
alg: "ES256", alg: 'ES256',
crv: "P-256", crv: 'P-256',
kty: "EC", kty: 'EC',
x: arrayToBase64Url(jwkObj[-2]), x: arrayToBase64Url(jwkObj[-2]),
y: arrayToBase64Url(jwkObj[-3]), y: arrayToBase64Url(jwkObj[-3])
}; }
const publicKeyBuffer = Buffer.concat([ const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2]), Buffer.from(jwkObj[-2]),
Buffer.from(jwkObj[-3]), Buffer.from(jwkObj[-3])
]); ])
return { publicKeyJwk, publicKeyBuffer }; return { publicKeyJwk, publicKeyBuffer }
} }
export function toBase64Url(anythingB64: string) { export function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return anythingB64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} }
export function arrayToBase64Url(anything: Uint8Array) { export function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64")); return toBase64Url(Buffer.from(anything).toString('base64'))
} }
export function peerDidToPublicKeyBytes(did: string) { export function peerDidToPublicKeyBytes(did: string) {
return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length)); return multibaseToBytes(did.substring(PEER_DID_MULTIBASE_PREFIX.length))
} }
export function createPeerDid(publicKeyBytes: Uint8Array) { export function createPeerDid(publicKeyBytes: Uint8Array) {
@ -89,8 +89,8 @@ export function createPeerDid(publicKeyBytes: Uint8Array) {
//const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME }); //const provider = new PeerDIDProvider({ defaultKms: LOCAL_KMS_NAME });
const methodSpecificId = bytesToMultibase( const methodSpecificId = bytesToMultibase(
publicKeyBytes, publicKeyBytes,
"base58btc", 'base58btc',
"p256-pub", 'p256-pub'
); )
return PEER_DID_MULTIBASE_PREFIX + methodSpecificId; return PEER_DID_MULTIBASE_PREFIX + methodSpecificId
} }

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

@ -6,21 +6,21 @@
* *
*/ */
import { Buffer } from "buffer/"; import { Buffer } from 'buffer/'
import * as didJwt from "did-jwt"; import * as didJwt from 'did-jwt'
import { JWTVerified } from "did-jwt"; import { JWTVerified } from 'did-jwt'
import { Resolver } from "did-resolver"; import { Resolver } from 'did-resolver'
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from '@veramo/core'
import * as u8a from "uint8arrays"; import * as u8a from 'uint8arrays'
import { didEthLocalResolver } from "./did-eth-local-resolver"; import { didEthLocalResolver } from './did-eth-local-resolver'
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer"; import { PEER_DID_PREFIX, verifyPeerSignature } from './didPeer'
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer"; import { base64urlDecodeString, createDidPeerJwt } from './passkeyDidPeer'
import { urlBase64ToUint8Array } from "./util"; import { urlBase64ToUint8Array } from './util'
export const ETHR_DID_PREFIX = "did:ethr:"; export const ETHR_DID_PREFIX = 'did:ethr:'
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED"; export const JWT_VERIFY_FAILED_CODE = 'JWT_VERIFY_FAILED'
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD"; export const UNSUPPORTED_DID_METHOD_CODE = 'UNSUPPORTED_DID_METHOD'
/** /**
* Meta info about a key * Meta info about a key
@ -29,51 +29,51 @@ export interface KeyMeta {
/** /**
* Decentralized ID for the key * Decentralized ID for the key
*/ */
did: string; did: string
/** /**
* Stringified IIDentifier object from Veramo * Stringified IIDentifier object from Veramo
*/ */
identity?: string; identity?: string
/** /**
* The Webauthn credential ID in hex, if this is from a passkey * The Webauthn credential ID in hex, if this is from a passkey
*/ */
passkeyCredIdHex?: string; passkeyCredIdHex?: string
} }
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver }); const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver })
/** /**
* Tell whether a key is from a passkey * Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey * @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
*/ */
export function isFromPasskey(keyMeta?: KeyMeta): boolean { export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex; return !!keyMeta?.passkeyCredIdHex
} }
export async function createEndorserJwtForKey( export async function createEndorserJwtForKey(
account: KeyMeta, account: KeyMeta,
payload: object, payload: object,
expiresIn?: number, expiresIn?: number
) { ) {
if (account?.identity) { if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!); const identity: IIdentifier = JSON.parse(account.identity!)
const privateKeyHex = identity.keys[0].privateKeyHex; const privateKeyHex = identity.keys[0].privateKeyHex
const signer = await SimpleSigner(privateKeyHex as string); const signer = await SimpleSigner(privateKeyHex as string)
const options = { const options = {
// alg: "ES256K", // "K" is the default, "K-R" is used by the server in tests // alg: "ES256K", // "K" is the default, "K-R" is used by the server in tests
issuer: account.did, issuer: account.did,
signer: signer, signer: signer,
expiresIn: undefined as number | undefined, expiresIn: undefined as number | undefined
}; }
if (expiresIn) { if (expiresIn) {
options.expiresIn = expiresIn; options.expiresIn = expiresIn
} }
return didJwt.createJWT(payload, options); return didJwt.createJWT(payload, options)
} else if (account?.passkeyCredIdHex) { } else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload); return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload)
} else { } else {
throw new Error("No identity data found to sign for DID " + account.did); throw new Error('No identity data found to sign for DID ' + account.did)
} }
} }
@ -92,35 +92,35 @@ export async function createEndorserJwtForKey(
* @return {Function} a configured signer function * @return {Function} a configured signer function
*/ */
function SimpleSigner(hexPrivateKey: string): didJwt.Signer { function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true); const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true)
return async (data) => { return async (data) => {
const signature = (await signer(data)) as string; const signature = (await signer(data)) as string
return fromJose(signature); return fromJose(signature)
}; }
} }
// from did-jwt/util; see SimpleSigner above // from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): { function fromJose(signature: string): {
r: string; r: string
s: string; s: string
recoveryParam?: number; recoveryParam?: number
} { } {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature); const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature)
if (signatureBytes.length < 64 || signatureBytes.length > 65) { if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError( throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`, `Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`
); )
} }
const r = bytesToHex(signatureBytes.slice(0, 32)); const r = bytesToHex(signatureBytes.slice(0, 32))
const s = bytesToHex(signatureBytes.slice(32, 64)); const s = bytesToHex(signatureBytes.slice(32, 64))
const recoveryParam = const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined; signatureBytes.length === 65 ? signatureBytes[64] : undefined
return { r, s, recoveryParam }; return { r, s, recoveryParam }
} }
// from did-jwt/util; see SimpleSigner above // from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string { function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16"); return u8a.toString(b, 'base16')
} }
// We should be calling 'verify' in more places, showing warnings if it fails. // We should be calling 'verify' in more places, showing warnings if it fails.
@ -128,21 +128,21 @@ function bytesToHex(b: Uint8Array): string {
export function decodeEndorserJwt(jwt: string) { export function decodeEndorserJwt(jwt: string) {
try { try {
// First try the standard did-jwt decode // First try the standard did-jwt decode
return didJwt.decodeJWT(jwt); return didJwt.decodeJWT(jwt)
} catch (error) { } catch (error) {
// If that fails, try manual decoding // If that fails, try manual decoding
try { try {
const parts = jwt.split("."); const parts = jwt.split('.')
if (parts.length !== 3) { if (parts.length !== 3) {
throw new Error("JWT must have 3 parts"); throw new Error('JWT must have 3 parts')
} }
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString()); const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString())
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString())
// Validate the header // Validate the header
if (header.typ !== "JWT" || !header.alg) { if (header.typ !== 'JWT' || !header.alg) {
throw new Error("Invalid JWT header format"); throw new Error('Invalid JWT header format')
} }
// Return in the same format as didJwt.decodeJWT // Return in the same format as didJwt.decodeJWT
@ -150,10 +150,10 @@ export function decodeEndorserJwt(jwt: string) {
header, header,
payload, payload,
signature: parts[2], signature: parts[2],
data: parts[0] + "." + parts[1], data: parts[0] + '.' + parts[1]
}; }
} catch (e) { } catch (e) {
throw new Error(`invalid_argument: Incorrect format JWT - ${e.message}`); throw new Error(`invalid_argument: Incorrect format JWT - ${e.message}`)
} }
} }
} }
@ -161,70 +161,70 @@ export function decodeEndorserJwt(jwt: string) {
// return Promise of at least { issuer, payload, verified boolean } // return Promise of at least { issuer, payload, verified boolean }
// ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer // ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer
export async function decodeAndVerifyJwt( export async function decodeAndVerifyJwt(
jwt: string, jwt: string
): Promise<Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt">> { ): Promise<Omit<JWTVerified, 'didResolutionResult' | 'signer' | 'jwt'>> {
const pieces = jwt.split("."); const pieces = jwt.split('.')
const header = JSON.parse(base64urlDecodeString(pieces[0])); const header = JSON.parse(base64urlDecodeString(pieces[0]))
const payload = JSON.parse(base64urlDecodeString(pieces[1])); const payload = JSON.parse(base64urlDecodeString(pieces[1]))
const issuerDid = payload.iss; const issuerDid = payload.iss
if (!issuerDid) { if (!issuerDid) {
return Promise.reject({ return Promise.reject({
clientError: { clientError: {
message: `Missing "iss" field in JWT.`, message: `Missing "iss" field in JWT.`
}, }
}); })
} }
if (issuerDid.startsWith(ETHR_DID_PREFIX)) { if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try { try {
const verified = await didJwt.verifyJWT(jwt, { const verified = await didJwt.verifyJWT(jwt, {
resolver: ethLocalResolver, resolver: ethLocalResolver
}); })
return verified; return verified
} catch (e: unknown) { } catch (e: unknown) {
return Promise.reject({ return Promise.reject({
clientError: { clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
message: `JWT failed verification: ` + e.toString(), message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE, code: JWT_VERIFY_FAILED_CODE
}, }
}); })
} }
} }
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") { if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === 'JWANT') {
const verified = await verifyPeerSignature( const verified = await verifyPeerSignature(
Buffer.from(payload), Buffer.from(payload),
issuerDid, issuerDid,
urlBase64ToUint8Array(pieces[2]), urlBase64ToUint8Array(pieces[2])
); )
if (!verified) { if (!verified) {
return Promise.reject({ return Promise.reject({
clientError: { clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error // @ts-expect-error
message: `JWT failed verification: ` + e.toString(), message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE, code: JWT_VERIFY_FAILED_CODE
}, }
}); })
} else { } else {
return { issuer: issuerDid, payload: payload, verified: true }; return { issuer: issuerDid, payload: payload, verified: true }
} }
} }
if (issuerDid.startsWith(PEER_DID_PREFIX)) { if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({ return Promise.reject({
clientError: { clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`, message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`
}, }
}); })
} }
return Promise.reject({ return Promise.reject({
clientError: { clientError: {
message: `Unsupported DID method ${issuerDid}`, message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE, code: UNSUPPORTED_DID_METHOD_CODE
}, }
}); })
} }

415
src/libs/crypto/vc/passkeyDidPeer.ts

@ -1,39 +1,36 @@
import { Buffer } from "buffer/"; import { Buffer } from 'buffer/'
import { JWTPayload } from "did-jwt"; import { JWTPayload } from 'did-jwt'
import { DIDResolutionResult } from "did-resolver"; import { DIDResolutionResult } from 'did-resolver'
import { sha256 } from "ethereum-cryptography/sha256.js"; import { sha256 } from 'ethereum-cryptography/sha256.js'
import { import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
startAuthentication,
startRegistration,
} from "@simplewebauthn/browser";
import { import {
generateAuthenticationOptions, generateAuthenticationOptions,
generateRegistrationOptions, generateRegistrationOptions,
verifyAuthenticationResponse, verifyAuthenticationResponse,
verifyRegistrationResponse, verifyRegistrationResponse
} from "@simplewebauthn/server"; } from '@simplewebauthn/server'
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse"; import { VerifyAuthenticationResponseOpts } from '@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse'
import { import {
Base64URLString, Base64URLString,
PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsJSON
} from "@simplewebauthn/types"; } from '@simplewebauthn/types'
import { AppString } from "../../../constants/app"; import { AppString } from '../../../constants/app'
import { unwrapEC2Signature } from "../../../libs/crypto/vc/passkeyHelpers"; import { unwrapEC2Signature } from '../../../libs/crypto/vc/passkeyHelpers'
import { import {
arrayToBase64Url, arrayToBase64Url,
cborToKeys, cborToKeys,
peerDidToPublicKeyBytes, peerDidToPublicKeyBytes,
verifyPeerSignature, verifyPeerSignature
} from "../../../libs/crypto/vc/didPeer"; } from '../../../libs/crypto/vc/didPeer'
import { logger } from "../../../utils/logger"; import { logger } from '../../../utils/logger'
export interface JWK { export interface JWK {
kty: string; kty: string
crv: string; crv: string
x: string; x: string
y: string; y: string
} }
export async function registerCredential(passkeyName?: string) { export async function registerCredential(passkeyName?: string) {
@ -41,203 +38,203 @@ export async function registerCredential(passkeyName?: string) {
await generateRegistrationOptions({ await generateRegistrationOptions({
rpName: AppString.APP_NAME, rpName: AppString.APP_NAME,
rpID: window.location.hostname, rpID: window.location.hostname,
userName: passkeyName || AppString.APP_NAME + " User", userName: passkeyName || AppString.APP_NAME + ' User',
// Don't prompt users for additional information about the authenticator // Don't prompt users for additional information about the authenticator
// (Recommended for smoother UX) // (Recommended for smoother UX)
attestationType: "none", attestationType: 'none',
authenticatorSelection: { authenticatorSelection: {
// Defaults // Defaults
residentKey: "preferred", residentKey: 'preferred',
userVerification: "preferred", userVerification: 'preferred',
// Optional // Optional
authenticatorAttachment: "platform", authenticatorAttachment: 'platform'
}, }
}); })
// someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions // someday, instead of simplwebauthn, we'll go direct: navigator.credentials.create with PublicKeyCredentialCreationOptions
// with pubKeyCredParams: { type: "public-key", alg: -7 } // with pubKeyCredParams: { type: "public-key", alg: -7 }
const attResp = await startRegistration(options); const attResp = await startRegistration(options)
const verification = await verifyRegistrationResponse({ const verification = await verifyRegistrationResponse({
response: attResp, response: attResp,
expectedChallenge: options.challenge, expectedChallenge: options.challenge,
expectedOrigin: window.location.origin, expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname, expectedRPID: window.location.hostname
}); })
// references for parsing auth data and getting the public key // references for parsing auth data and getting the public key
// https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11 // https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/parseAuthenticatorData.ts#L11
// https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa // https://chatgpt.com/share/78a5c91d-099d-46dc-aa6d-fc0c916509fa
// https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7 // https://chatgpt.com/share/3c13f061-6031-45bc-a2d7-3347c1e7a2d7
const credIdBase64Url = verification.registrationInfo?.credentialID as string; const credIdBase64Url = verification.registrationInfo?.credentialID as string
if (attResp.rawId !== credIdBase64Url) { if (attResp.rawId !== credIdBase64Url) {
logger.warn("Warning! The raw ID does not match the credential ID."); logger.warn('Warning! The raw ID does not match the credential ID.')
} }
const credIdHex = Buffer.from( const credIdHex = Buffer.from(
base64URLStringToArrayBuffer(credIdBase64Url), base64URLStringToArrayBuffer(credIdBase64Url)
).toString("hex"); ).toString('hex')
const { publicKeyJwk } = cborToKeys( const { publicKeyJwk } = cborToKeys(
verification.registrationInfo?.credentialPublicKey as Uint8Array, verification.registrationInfo?.credentialPublicKey as Uint8Array
); )
return { return {
authData: verification.registrationInfo?.attestationObject, authData: verification.registrationInfo?.attestationObject,
credIdHex: credIdHex, credIdHex: credIdHex,
publicKeyJwk: publicKeyJwk, publicKeyJwk: publicKeyJwk,
publicKeyBytes: verification.registrationInfo publicKeyBytes: verification.registrationInfo
?.credentialPublicKey as Uint8Array, ?.credentialPublicKey as Uint8Array
}; }
} }
export class PeerSetup { export class PeerSetup {
public authenticatorData?: ArrayBuffer; public authenticatorData?: ArrayBuffer
public challenge?: Uint8Array; public challenge?: Uint8Array
public clientDataJsonBase64Url?: Base64URLString; public clientDataJsonBase64Url?: Base64URLString
public signature?: Base64URLString; public signature?: Base64URLString
public async createJwtSimplewebauthn( public async createJwtSimplewebauthn(
issuerDid: string, issuerDid: string,
payload: object, payload: object,
credIdHex: string, credIdHex: string,
expMinutes: number = 1, expMinutes: number = 1
) { ) {
const credentialId = arrayBufferToBase64URLString( const credentialId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer, Buffer.from(credIdHex, 'hex').buffer
); )
const issuedAt = Math.floor(Date.now() / 1000); const issuedAt = Math.floor(Date.now() / 1000)
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60 // some minutes from now
const fullPayload = { const fullPayload = {
...payload, ...payload,
exp: expiryTime, exp: expiryTime,
iat: issuedAt, iat: issuedAt,
iss: issuerDid, iss: issuerDid
}; }
this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload))); this.challenge = new Uint8Array(Buffer.from(JSON.stringify(fullPayload)))
// const payloadHash: Uint8Array = sha256(this.challenge); // const payloadHash: Uint8Array = sha256(this.challenge);
const options: PublicKeyCredentialRequestOptionsJSON = const options: PublicKeyCredentialRequestOptionsJSON =
await generateAuthenticationOptions({ await generateAuthenticationOptions({
challenge: this.challenge, challenge: this.challenge,
rpID: window.location.hostname, rpID: window.location.hostname,
allowCredentials: [{ id: credentialId }], allowCredentials: [{ id: credentialId }]
}); })
// console.log("simple authentication options", options); // console.log("simple authentication options", options);
const clientAuth = await startAuthentication(options); const clientAuth = await startAuthentication(options)
// console.log("simple credential get", clientAuth); // console.log("simple credential get", clientAuth);
const authenticatorDataBase64Url = clientAuth.response.authenticatorData; const authenticatorDataBase64Url = clientAuth.response.authenticatorData
this.authenticatorData = Buffer.from( this.authenticatorData = Buffer.from(
clientAuth.response.authenticatorData, clientAuth.response.authenticatorData,
"base64", 'base64'
).buffer; ).buffer
this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON; this.clientDataJsonBase64Url = clientAuth.response.clientDataJSON
// console.log("simple authenticatorData for signing", this.authenticatorData); // console.log("simple authenticatorData for signing", this.authenticatorData);
this.signature = clientAuth.response.signature; this.signature = clientAuth.response.signature
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties // Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; const header: JWTPayload = { typ: 'JWANT', alg: 'ES256' }
const headerBase64 = Buffer.from(JSON.stringify(header)) const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64") .toString('base64')
.replace(/\+/g, "-") .replace(/\+/g, '-')
.replace(/\//g, "_") .replace(/\//g, '_')
.replace(/=+$/, ""); .replace(/=+$/, '')
const dataInJwt = { const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url, AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url, ClientDataJSONB64URL: this.clientDataJsonBase64Url,
exp: expiryTime, exp: expiryTime,
iat: issuedAt, iat: issuedAt,
iss: issuerDid, iss: issuerDid
}; }
const dataInJwtString = JSON.stringify(dataInJwt); const dataInJwtString = JSON.stringify(dataInJwt)
const payloadBase64 = Buffer.from(dataInJwtString) const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64") .toString('base64')
.replace(/\+/g, "-") .replace(/\+/g, '-')
.replace(/\//g, "_") .replace(/\//g, '_')
.replace(/=+$/, ""); .replace(/=+$/, '')
const signature = clientAuth.response.signature; const signature = clientAuth.response.signature
return headerBase64 + "." + payloadBase64 + "." + signature; return headerBase64 + '.' + payloadBase64 + '.' + signature
} }
public async createJwtNavigator( public async createJwtNavigator(
issuerDid: string, issuerDid: string,
payload: object, payload: object,
credIdHex: string, credIdHex: string,
expMinutes: number = 1, expMinutes: number = 1
) { ) {
const issuedAt = Math.floor(Date.now() / 1000); const issuedAt = Math.floor(Date.now() / 1000)
const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60; // some minutes from now const expiryTime = Math.floor(Date.now() / 1000) + expMinutes * 60 // some minutes from now
const fullPayload = { const fullPayload = {
...payload, ...payload,
exp: expiryTime, exp: expiryTime,
iat: issuedAt, iat: issuedAt,
iss: issuerDid, iss: issuerDid
}; }
const dataToSignString = JSON.stringify(fullPayload); const dataToSignString = JSON.stringify(fullPayload)
const dataToSignBuffer = Buffer.from(dataToSignString); const dataToSignBuffer = Buffer.from(dataToSignString)
const credentialId = Buffer.from(credIdHex, "hex"); const credentialId = Buffer.from(credIdHex, 'hex')
// console.log("lower credentialId", credentialId); // console.log("lower credentialId", credentialId);
this.challenge = new Uint8Array(dataToSignBuffer); this.challenge = new Uint8Array(dataToSignBuffer)
const options = { const options = {
publicKey: { publicKey: {
allowCredentials: [ allowCredentials: [
{ {
id: credentialId, id: credentialId,
type: "public-key" as const, type: 'public-key' as const
}, }
], ],
challenge: this.challenge.buffer, challenge: this.challenge.buffer,
rpID: window.location.hostname, rpID: window.location.hostname,
userVerification: "preferred" as const, userVerification: 'preferred' as const
}, }
}; }
const credential = await navigator.credentials.get(options); const credential = await navigator.credentials.get(options)
// console.log("nav credential get", credential); // console.log("nav credential get", credential);
this.authenticatorData = credential?.response.authenticatorData; this.authenticatorData = credential?.response.authenticatorData
const authenticatorDataBase64Url = arrayBufferToBase64URLString( const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData as ArrayBuffer, this.authenticatorData as ArrayBuffer
); )
this.clientDataJsonBase64Url = arrayBufferToBase64URLString( this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
credential?.response.clientDataJSON, credential?.response.clientDataJSON
); )
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties // Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
const header: JWTPayload = { typ: "JWANT", alg: "ES256" }; const header: JWTPayload = { typ: 'JWANT', alg: 'ES256' }
const headerBase64 = Buffer.from(JSON.stringify(header)) const headerBase64 = Buffer.from(JSON.stringify(header))
.toString("base64") .toString('base64')
.replace(/\+/g, "-") .replace(/\+/g, '-')
.replace(/\//g, "_") .replace(/\//g, '_')
.replace(/=+$/, ""); .replace(/=+$/, '')
const dataInJwt = { const dataInJwt = {
AuthenticationDataB64URL: authenticatorDataBase64Url, AuthenticationDataB64URL: authenticatorDataBase64Url,
ClientDataJSONB64URL: this.clientDataJsonBase64Url, ClientDataJSONB64URL: this.clientDataJsonBase64Url,
exp: expiryTime, exp: expiryTime,
iat: issuedAt, iat: issuedAt,
iss: issuerDid, iss: issuerDid
}; }
const dataInJwtString = JSON.stringify(dataInJwt); const dataInJwtString = JSON.stringify(dataInJwt)
const payloadBase64 = Buffer.from(dataInJwtString) const payloadBase64 = Buffer.from(dataInJwtString)
.toString("base64") .toString('base64')
.replace(/\+/g, "-") .replace(/\+/g, '-')
.replace(/\//g, "_") .replace(/\//g, '_')
.replace(/=+$/, ""); .replace(/=+$/, '')
const origSignature = Buffer.from(credential?.response.signature).toString( const origSignature = Buffer.from(credential?.response.signature).toString(
"base64", 'base64'
); )
this.signature = origSignature this.signature = origSignature
.replace(/\+/g, "-") .replace(/\+/g, '-')
.replace(/\//g, "_") .replace(/\//g, '_')
.replace(/=+$/, ""); .replace(/=+$/, '')
const jwt = headerBase64 + "." + payloadBase64 + "." + this.signature; const jwt = headerBase64 + '.' + payloadBase64 + '.' + this.signature
return jwt; return jwt
} }
// To use this, add the asn1-ber library and add this import: // To use this, add the asn1-ber library and add this import:
@ -302,11 +299,11 @@ export class PeerSetup {
export async function createDidPeerJwt( export async function createDidPeerJwt(
did: string, did: string,
credIdHex: string, credIdHex: string,
payload: object, payload: object
): Promise<string> { ): Promise<string> {
const peerSetup = new PeerSetup(); const peerSetup = new PeerSetup()
const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex); const jwt = await peerSetup.createJwtNavigator(did, payload, credIdHex)
return jwt; return jwt
} }
// I'd love to use this but it doesn't verify. // I'd love to use this but it doesn't verify.
@ -320,26 +317,26 @@ export async function verifyJwtP256(
authenticatorData: ArrayBuffer, authenticatorData: ArrayBuffer,
challenge: Uint8Array, challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString, clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString, signature: Base64URLString
) { ) {
const authDataFromBase = Buffer.from(authenticatorData); const authDataFromBase = Buffer.from(authenticatorData)
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, 'base64')
const sigBuffer = Buffer.from(signature, "base64"); const sigBuffer = Buffer.from(signature, 'base64')
const finalSigBuffer = unwrapEC2Signature(sigBuffer); const finalSigBuffer = unwrapEC2Signature(sigBuffer)
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid)
// Hash the client data // Hash the client data
const hash = sha256(clientDataFromBase); const hash = sha256(clientDataFromBase)
// Construct the preimage // Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]); const preimage = Buffer.concat([authDataFromBase, hash])
const isValid = p256.verify( const isValid = p256.verify(
finalSigBuffer, finalSigBuffer,
new Uint8Array(preimage), new Uint8Array(preimage),
publicKeyBytes, publicKeyBytes
); )
return isValid; return isValid
} }
export async function verifyJwtSimplewebauthn( export async function verifyJwtSimplewebauthn(
@ -348,37 +345,37 @@ export async function verifyJwtSimplewebauthn(
authenticatorData: ArrayBuffer, authenticatorData: ArrayBuffer,
challenge: Uint8Array, challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString, clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString, signature: Base64URLString
) { ) {
const authData = arrayToBase64Url(Buffer.from(authenticatorData)); const authData = arrayToBase64Url(Buffer.from(authenticatorData))
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid)
const credId = arrayBufferToBase64URLString( const credId = arrayBufferToBase64URLString(
Buffer.from(credIdHex, "hex").buffer, Buffer.from(credIdHex, 'hex').buffer
); )
const authOpts: VerifyAuthenticationResponseOpts = { const authOpts: VerifyAuthenticationResponseOpts = {
authenticator: { authenticator: {
credentialID: credId, credentialID: credId,
credentialPublicKey: publicKeyBytes, credentialPublicKey: publicKeyBytes,
counter: 0, counter: 0
}, },
expectedChallenge: arrayToBase64Url(challenge), expectedChallenge: arrayToBase64Url(challenge),
expectedOrigin: window.location.origin, expectedOrigin: window.location.origin,
expectedRPID: window.location.hostname, expectedRPID: window.location.hostname,
response: { response: {
authenticatorAttachment: "platform", authenticatorAttachment: 'platform',
clientExtensionResults: {}, clientExtensionResults: {},
id: credId, id: credId,
rawId: credId, rawId: credId,
response: { response: {
authenticatorData: authData, authenticatorData: authData,
clientDataJSON: clientDataJsonBase64Url, clientDataJSON: clientDataJsonBase64Url,
signature: signature, signature: signature
}, },
type: "public-key", type: 'public-key'
}, }
}; }
const verification = await verifyAuthenticationResponse(authOpts); const verification = await verifyAuthenticationResponse(authOpts)
return verification.verified; return verification.verified
} }
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature // similar code is in endorser-ch util-crypto.ts verifyPeerSignature
@ -388,27 +385,27 @@ export async function verifyJwtWebCrypto(
authenticatorData: ArrayBuffer, authenticatorData: ArrayBuffer,
challenge: Uint8Array, challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString, clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString, signature: Base64URLString
) { ) {
const authDataFromBase = Buffer.from(authenticatorData); const authDataFromBase = Buffer.from(authenticatorData)
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64"); const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, 'base64')
const sigBuffer = Buffer.from(signature, "base64"); const sigBuffer = Buffer.from(signature, 'base64')
const finalSigBuffer = unwrapEC2Signature(sigBuffer); const finalSigBuffer = unwrapEC2Signature(sigBuffer)
// Hash the client data // Hash the client data
const hash = sha256(clientDataFromBase); const hash = sha256(clientDataFromBase)
// Construct the preimage // Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]); const preimage = Buffer.concat([authDataFromBase, hash])
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer); return verifyPeerSignature(preimage, issuerDid, finalSigBuffer)
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> { async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
if (!did.startsWith("did:peer:0z")) { if (!did.startsWith('did:peer:0z')) {
throw new Error( throw new Error(
"This only verifies a peer DID, method 0, encoded base58btc.", 'This only verifies a peer DID, method 0, encoded base58btc.'
); )
} }
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types // this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
// (another reference is the @aviarytech/did-peer resolver) // (another reference is the @aviarytech/did-peer resolver)
@ -420,131 +417,131 @@ async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> {
* - change type to JsonWebKey2020 * - change type to JsonWebKey2020
*/ */
const id = did.split(":")[2]; const id = did.split(':')[2]
const multibase = id.slice(1); const multibase = id.slice(1)
const encnumbasis = multibase.slice(1); const encnumbasis = multibase.slice(1)
const didDocument = { const didDocument = {
"@context": [ '@context': [
"https://www.w3.org/ns/did/v1", 'https://www.w3.org/ns/did/v1',
"https://w3id.org/security/suites/secp256k1-2019/v1", 'https://w3id.org/security/suites/secp256k1-2019/v1'
], ],
assertionMethod: [did + "#" + encnumbasis], assertionMethod: [did + '#' + encnumbasis],
authentication: [did + "#" + encnumbasis], authentication: [did + '#' + encnumbasis],
capabilityDelegation: [did + "#" + encnumbasis], capabilityDelegation: [did + '#' + encnumbasis],
capabilityInvocation: [did + "#" + encnumbasis], capabilityInvocation: [did + '#' + encnumbasis],
id: did, id: did,
keyAgreement: undefined, keyAgreement: undefined,
service: undefined, service: undefined,
verificationMethod: [ verificationMethod: [
{ {
controller: did, controller: did,
id: did + "#" + encnumbasis, id: did + '#' + encnumbasis,
publicKeyMultibase: multibase, publicKeyMultibase: multibase,
type: "EcdsaSecp256k1VerificationKey2019", type: 'EcdsaSecp256k1VerificationKey2019'
}, }
], ]
}; }
return { return {
didDocument, didDocument,
didDocumentMetadata: {}, didDocumentMetadata: {},
didResolutionMetadata: { contentType: "application/did+ld+json" }, didResolutionMetadata: { contentType: 'application/did+ld+json' }
}; }
} }
// convert COSE public key to PEM format // convert COSE public key to PEM format
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
function COSEtoPEM(cose: Buffer) { function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm // const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate const x = cose[-2] // x-coordinate
const y = cose[-3]; // y-coordinate const y = cose[-3] // y-coordinate
// Ensure the coordinates are in the correct format // Ensure the coordinates are in the correct format
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error because it complains about the type of x and y // @ts-expect-error because it complains about the type of x and y
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]); const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y])
// Convert to PEM format // Convert to PEM format
const pem = `-----BEGIN PUBLIC KEY----- const pem = `-----BEGIN PUBLIC KEY-----
${pubKeyBuffer.toString("base64")} ${pubKeyBuffer.toString('base64')}
-----END PUBLIC KEY-----`; -----END PUBLIC KEY-----`
return pem; return pem
} }
// tried the base64url library but got an error using their Buffer // tried the base64url library but got an error using their Buffer
export function base64urlDecodeString(input: string) { export function base64urlDecodeString(input: string) {
return atob(input.replace(/-/g, "+").replace(/_/g, "/")); return atob(input.replace(/-/g, '+').replace(/_/g, '/'))
} }
// tried the base64url library but got an error using their Buffer // tried the base64url library but got an error using their Buffer
export function base64urlEncodeString(input: string) { export function base64urlEncodeString(input: string) {
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return btoa(input).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlDecodeArrayBuffer(input: string) { function base64urlDecodeArrayBuffer(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/"); input = input.replace(/-/g, '+').replace(/_/g, '/')
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4); const pad = input.length % 4 === 0 ? '' : '===='.slice(input.length % 4)
const str = atob(input + pad); const str = atob(input + pad)
const bytes = new Uint8Array(str.length); const bytes = new Uint8Array(str.length)
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i); bytes[i] = str.charCodeAt(i)
} }
return bytes.buffer; return bytes.buffer
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) { function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer)); const str = String.fromCharCode(...new Uint8Array(buffer))
return base64urlEncodeString(str); return base64urlEncodeString(str)
} }
// from @simplewebauthn/browser // from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer: ArrayBuffer) { function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer)
let str = ""; let str = ''
for (const charCode of bytes) { for (const charCode of bytes) {
str += String.fromCharCode(charCode); str += String.fromCharCode(charCode)
} }
const base64String = btoa(str); const base64String = btoa(str)
return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
} }
// from @simplewebauthn/browser // from @simplewebauthn/browser
function base64URLStringToArrayBuffer(base64URLString: string) { function base64URLStringToArrayBuffer(base64URLString: string) {
const base64 = base64URLString.replace(/-/g, "+").replace(/_/g, "/"); const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/')
const padLength = (4 - (base64.length % 4)) % 4; const padLength = (4 - (base64.length % 4)) % 4
const padded = base64.padEnd(base64.length + padLength, "="); const padded = base64.padEnd(base64.length + padLength, '=')
const binary = atob(padded); const binary = atob(padded)
const buffer = new ArrayBuffer(binary.length); const buffer = new ArrayBuffer(binary.length)
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer)
for (let i = 0; i < binary.length; i++) { for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i); bytes[i] = binary.charCodeAt(i)
} }
return buffer; return buffer
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
async function pemToCryptoKey(pem: string) { async function pemToCryptoKey(pem: string) {
const binaryDerString = atob( const binaryDerString = atob(
pem pem
.split("\n") .split('\n')
.filter((x) => !x.includes("-----")) .filter((x) => !x.includes('-----'))
.join(""), .join('')
); )
const binaryDer = new Uint8Array(binaryDerString.length); const binaryDer = new Uint8Array(binaryDerString.length)
for (let i = 0; i < binaryDerString.length; i++) { for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i); binaryDer[i] = binaryDerString.charCodeAt(i)
} }
// console.log("binaryDer", binaryDer.buffer); // console.log("binaryDer", binaryDer.buffer);
return await window.crypto.subtle.importKey( return await window.crypto.subtle.importKey(
"spki", 'spki',
binaryDer.buffer, binaryDer.buffer,
{ {
name: "RSASSA-PKCS1-v1_5", name: 'RSASSA-PKCS1-v1_5',
hash: "SHA-256", hash: 'SHA-256'
}, },
true, true,
["verify"], ['verify']
); )
} }

64
src/libs/crypto/vc/passkeyHelpers.ts

@ -1,6 +1,6 @@
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts // from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/unwrapEC2Signature.ts
import { AsnParser } from "@peculiar/asn1-schema"; import { AsnParser } from '@peculiar/asn1-schema'
import { ECDSASigValue } from "@peculiar/asn1-ecc"; import { ECDSASigValue } from '@peculiar/asn1-ecc'
/** /**
* In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart. * In WebAuthn, EC2 signatures are wrapped in ASN.1 structure so we need to peel r and s apart.
@ -8,21 +8,21 @@ import { ECDSASigValue } from "@peculiar/asn1-ecc";
* See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types * See https://www.w3.org/TR/webauthn-2/#sctn-signature-attestation-types
*/ */
export function unwrapEC2Signature(signature: Uint8Array): Uint8Array { export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue); const parsedSignature = AsnParser.parse(signature, ECDSASigValue)
let rBytes = new Uint8Array(parsedSignature.r); let rBytes = new Uint8Array(parsedSignature.r)
let sBytes = new Uint8Array(parsedSignature.s); let sBytes = new Uint8Array(parsedSignature.s)
if (shouldRemoveLeadingZero(rBytes)) { if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1); rBytes = rBytes.slice(1)
} }
if (shouldRemoveLeadingZero(sBytes)) { if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1); sBytes = sBytes.slice(1)
} }
const finalSignature = isoUint8ArrayConcat([rBytes, sBytes]); const finalSignature = isoUint8ArrayConcat([rBytes, sBytes])
return finalSignature; return finalSignature
} }
/** /**
@ -33,7 +33,7 @@ export function unwrapEC2Signature(signature: Uint8Array): Uint8Array {
* then remove the leading 0x0 byte" * then remove the leading 0x0 byte"
*/ */
function shouldRemoveLeadingZero(bytes: Uint8Array): boolean { function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0; return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0
} }
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49 // from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoUint8Array.ts#L49
@ -41,21 +41,21 @@ function shouldRemoveLeadingZero(bytes: Uint8Array): boolean {
* Combine multiple Uint8Arrays into a single Uint8Array * Combine multiple Uint8Arrays into a single Uint8Array
*/ */
export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array { export function isoUint8ArrayConcat(arrays: Uint8Array[]): Uint8Array {
let pointer = 0; let pointer = 0
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0); const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0)
const toReturn = new Uint8Array(totalLength); const toReturn = new Uint8Array(totalLength)
arrays.forEach((arr) => { arrays.forEach((arr) => {
toReturn.set(arr, pointer); toReturn.set(arr, pointer)
pointer += arr.length; pointer += arr.length
}); })
return toReturn; return toReturn
} }
// from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts // from https://github.com/MasterKale/SimpleWebAuthn/blob/master/packages/server/src/helpers/iso/isoCrypto/getWebCrypto.ts
let webCrypto: { subtle: SubtleCrypto } | undefined = undefined; let webCrypto: { subtle: SubtleCrypto } | undefined = undefined
export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> { export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> {
/** /**
* Hello there! If you came here wondering why this method is asynchronous when use of * Hello there! If you came here wondering why this method is asynchronous when use of
@ -70,29 +70,29 @@ export function getWebCrypto(): Promise<{ subtle: SubtleCrypto }> {
const toResolve: Promise<{ subtle: SubtleCrypto }> = new Promise( const toResolve: Promise<{ subtle: SubtleCrypto }> = new Promise(
(resolve, reject) => { (resolve, reject) => {
if (webCrypto) { if (webCrypto) {
return resolve(webCrypto); return resolve(webCrypto)
} }
/** /**
* Naively attempt to access Crypto as a global object, which popular ESM-centric run-times * Naively attempt to access Crypto as a global object, which popular ESM-centric run-times
* support (and Node v20+) * support (and Node v20+)
*/ */
const _globalThisCrypto = const _globalThisCrypto =
_getWebCryptoInternals.stubThisGlobalThisCrypto(); _getWebCryptoInternals.stubThisGlobalThisCrypto()
if (_globalThisCrypto) { if (_globalThisCrypto) {
webCrypto = _globalThisCrypto; webCrypto = _globalThisCrypto
return resolve(webCrypto); return resolve(webCrypto)
} }
// We tried to access it both in Node and globally, so bail out // We tried to access it both in Node and globally, so bail out
return reject(new MissingWebCrypto()); return reject(new MissingWebCrypto())
}, }
); )
return toResolve; return toResolve
} }
class MissingWebCrypto extends Error { class MissingWebCrypto extends Error {
constructor() { constructor() {
const message = "An instance of the Crypto API could not be located"; const message = 'An instance of the Crypto API could not be located'
super(message); super(message)
this.name = "MissingWebCrypto"; this.name = 'MissingWebCrypto'
} }
} }
// Make it possible to stub return values during testing // Make it possible to stub return values during testing
@ -100,6 +100,6 @@ const _getWebCryptoInternals = {
stubThisGlobalThisCrypto: () => globalThis.crypto, stubThisGlobalThisCrypto: () => globalThis.crypto,
// Make it possible to reset the `webCrypto` at the top of the file // Make it possible to reset the `webCrypto` at the top of the file
setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => { setCachedCrypto: (newCrypto: { subtle: SubtleCrypto }) => {
webCrypto = newCrypto; webCrypto = newCrypto
}, }
}; }

12
src/libs/crypto/vc/util.ts

@ -1,11 +1,11 @@
export function urlBase64ToUint8Array(base64String: string): Uint8Array { export function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64); const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length); const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) { for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i); outputArray[i] = rawData.charCodeAt(i)
} }
return outputArray; return outputArray
} }

863
src/libs/endorserServer.ts

File diff suppressed because it is too large

14
src/libs/partnerServer.ts

@ -1,9 +1,9 @@
export interface UserProfile { export interface UserProfile {
description: string; description: string
locLat?: number; locLat?: number
locLon?: number; locLon?: number
locLat2?: number; locLat2?: number
locLon2?: number; locLon2?: number
issuerDid: string; issuerDid: string
rowId?: string; // set on profile retrieved from server rowId?: string // set on profile retrieved from server
} }

510
src/libs/util.ts

@ -1,50 +1,50 @@
// many of these are also found in endorser-mobile utility.ts // many of these are also found in endorser-mobile utility.ts
import axios, { AxiosResponse } from "axios"; import axios, { AxiosResponse } from 'axios'
import { Buffer } from "buffer"; import { Buffer } from 'buffer'
import * as R from "ramda"; import * as R from 'ramda'
import { useClipboard } from "@vueuse/core"; import { useClipboard } from '@vueuse/core'
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app"; import { DEFAULT_PUSH_SERVER, NotificationIface } from '../constants/app'
import { import {
accountsDBPromise, accountsDBPromise,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings, updateAccountSettings,
updateDefaultSettings, updateDefaultSettings
} from "../db/index"; } from '../db/index'
import { Account } from "../db/tables/accounts"; import { Account } from '../db/tables/accounts'
import { Contact } from "../db/tables/contacts"; import { Contact } from '../db/tables/contacts'
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from '../db/tables/settings'
import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto"; import { deriveAddress, generateSeed, newIdentifier } from '../libs/crypto'
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from '../libs/endorserServer'
import { import {
containsHiddenDid, containsHiddenDid,
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
GiveSummaryRecord, GiveSummaryRecord,
OfferVerifiableCredential, OfferVerifiableCredential
} from "../libs/endorserServer"; } from '../libs/endorserServer'
import { KeyMeta } from "../libs/crypto/vc"; import { KeyMeta } from '../libs/crypto/vc'
import { createPeerDid } from "../libs/crypto/vc/didPeer"; import { createPeerDid } from '../libs/crypto/vc/didPeer'
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer"; import { registerCredential } from '../libs/crypto/vc/passkeyDidPeer'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
export interface GiverReceiverInputInfo { export interface GiverReceiverInputInfo {
did?: string; did?: string
name?: string; name?: string
} }
export enum OnboardPage { export enum OnboardPage {
Home = "HOME", Home = 'HOME',
Discover = "DISCOVER", Discover = 'DISCOVER',
Create = "CREATE", Create = 'CREATE',
Contact = "CONTACT", Contact = 'CONTACT',
Account = "ACCOUNT", Account = 'ACCOUNT'
} }
export const PRIVACY_MESSAGE = export const PRIVACY_MESSAGE =
"The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow."; 'The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.'
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64"; export const SHARED_PHOTO_BASE64_KEY = 'shared-photo-base64'
/* eslint-disable prettier/prettier */ /* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = { export const UNIT_SHORT: Record<string, string> = {
@ -68,21 +68,21 @@ export const UNIT_LONG: Record<string, string> = {
const UNIT_CODES: Record<string, Record<string, string>> = { const UNIT_CODES: Record<string, Record<string, string>> = {
BTC: { BTC: {
name: "Bitcoin", name: 'Bitcoin',
faIcon: "bitcoin-sign", faIcon: 'bitcoin-sign'
}, },
HUR: { HUR: {
name: "hours", name: 'hours',
faIcon: "clock", faIcon: 'clock'
}, },
USD: { USD: {
name: "US Dollars", name: 'US Dollars',
faIcon: "dollar", faIcon: 'dollar'
}, }
}; }
export function iconForUnitCode(unitCode: string) { export function iconForUnitCode(unitCode: string) {
return UNIT_CODES[unitCode]?.faIcon || "question"; return UNIT_CODES[unitCode]?.faIcon || 'question'
} }
// from https://stackoverflow.com/a/175787/845494 // from https://stackoverflow.com/a/175787/845494
@ -92,11 +92,11 @@ export function isNumeric(str: string): boolean {
// This ignore commentary is because typescript complains when you pass a string to isNaN. // This ignore commentary is because typescript complains when you pass a string to isNaN.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
return !isNaN(str) && !isNaN(parseFloat(str)); return !isNaN(str) && !isNaN(parseFloat(str))
} }
export function numberOrZero(str: string): number { export function numberOrZero(str: string): number {
return isNumeric(str) ? +str : 0; return isNumeric(str) ? +str : 0
} }
/** /**
@ -104,67 +104,67 @@ export function numberOrZero(str: string): number {
* also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition * also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition
**/ **/
export const isGlobalUri = (uri: string) => { export const isGlobalUri = (uri: string) => {
return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/)); return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/))
}; }
export const isGiveClaimType = (claimType?: string) => { export const isGiveClaimType = (claimType?: string) => {
return claimType === "GiveAction"; return claimType === 'GiveAction'
}; }
export const isGiveAction = ( export const isGiveAction = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>, veriClaim: GenericCredWrapper<GenericVerifiableCredential>
) => { ) => {
return isGiveClaimType(veriClaim.claimType); return isGiveClaimType(veriClaim.claimType)
}; }
export const shortDid = (did: string) => { export const shortDid = (did: string) => {
if (did.startsWith("did:peer:")) { if (did.startsWith('did:peer:')) {
return ( return (
did.substring(0, "did:peer:".length + 2) + did.substring(0, 'did:peer:'.length + 2) +
"..." + '...' +
did.substring("did:peer:".length + 18, "did:peer:".length + 25) + did.substring('did:peer:'.length + 18, 'did:peer:'.length + 25) +
"..." '...'
); )
} else if (did.startsWith("did:ethr:")) { } else if (did.startsWith('did:ethr:')) {
return did.substring(0, "did:ethr:".length + 9) + "..."; return did.substring(0, 'did:ethr:'.length + 9) + '...'
} else { } else {
return did.substring(0, did.indexOf(":", 4) + 7) + "..."; return did.substring(0, did.indexOf(':', 4) + 7) + '...'
} }
}; }
export const nameForDid = ( export const nameForDid = (
activeDid: string, activeDid: string,
contacts: Array<Contact>, contacts: Array<Contact>,
did: string, did: string
): string => { ): string => {
if (did === activeDid) { if (did === activeDid) {
return "you"; return 'you'
} }
const contact = R.find((con) => con.did == did, contacts); const contact = R.find((con) => con.did == did, contacts)
return nameForContact(contact); return nameForContact(contact)
}; }
export const nameForContact = ( export const nameForContact = (
contact?: Contact, contact?: Contact,
capitalize?: boolean, capitalize?: boolean
): string => { ): string => {
return ( return (
(contact?.name as string) || (contact?.name as string) ||
(capitalize ? "This" : "this") + " unnamed user" (capitalize ? 'This' : 'this') + ' unnamed user'
); )
}; }
export const doCopyTwoSecRedo = (text: string, fn: () => void) => { export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
fn(); fn()
useClipboard() useClipboard()
.copy(text) .copy(text)
.then(() => setTimeout(fn, 2000)); .then(() => setTimeout(fn, 2000))
}; }
export interface ConfirmerData { export interface ConfirmerData {
confirmerIdList: string[]; confirmerIdList: string[]
confsVisibleToIdList: string[]; confsVisibleToIdList: string[]
numConfsNotVisible: number; numConfsNotVisible: number
} }
// // This is meant to be a second argument to JSON.stringify to avoid circular references. // // This is meant to be a second argument to JSON.stringify to avoid circular references.
@ -191,49 +191,49 @@ export async function retrieveConfirmerIdList(
apiServer: string, apiServer: string,
claimId: string, claimId: string,
claimIssuerId: string, claimIssuerId: string,
userDid: string, userDid: string
): Promise<ConfirmerData | undefined> { ): Promise<ConfirmerData | undefined> {
const confirmUrl = const confirmUrl =
apiServer + apiServer +
"/api/report/issuersWhoClaimedOrConfirmed?claimId=" + '/api/report/issuersWhoClaimedOrConfirmed?claimId=' +
encodeURIComponent(serverUtil.stripEndorserPrefix(claimId)); encodeURIComponent(serverUtil.stripEndorserPrefix(claimId))
const confirmHeaders = await serverUtil.getHeaders(userDid); const confirmHeaders = await serverUtil.getHeaders(userDid)
const response = await axios.get(confirmUrl, { const response = await axios.get(confirmUrl, {
headers: confirmHeaders, headers: confirmHeaders
}); })
if (response.status === 200) { if (response.status === 200) {
const resultList1 = response.data.result || []; const resultList1 = response.data.result || []
//const publicUrls = resultList.publicUrls || []; //const publicUrls = resultList.publicUrls || [];
delete resultList1.publicUrls; delete resultList1.publicUrls
// exclude hidden DIDs // exclude hidden DIDs
const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1); const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1)
// exclude the issuer // exclude the issuer
const resultList3 = R.reject( const resultList3 = R.reject(
(did: string) => did === claimIssuerId, (did: string) => did === claimIssuerId,
resultList2, resultList2
); )
const confirmerIdList = resultList3; const confirmerIdList = resultList3
let numConfsNotVisible = resultList1.length - resultList2.length; let numConfsNotVisible = resultList1.length - resultList2.length
if (resultList3.length === resultList2.length) { if (resultList3.length === resultList2.length) {
// the issuer was not in the "visible" list so they must be hidden // the issuer was not in the "visible" list so they must be hidden
// so subtract them from the non-visible confirmers count // so subtract them from the non-visible confirmers count
numConfsNotVisible = numConfsNotVisible - 1; numConfsNotVisible = numConfsNotVisible - 1
} }
const confsVisibleToIdList = response.data.result.resultVisibleToDids || []; const confsVisibleToIdList = response.data.result.resultVisibleToDids || []
const result: ConfirmerData = { const result: ConfirmerData = {
confirmerIdList, confirmerIdList,
confsVisibleToIdList, confsVisibleToIdList,
numConfsNotVisible, numConfsNotVisible
}; }
return result; return result
} else { } else {
logger.error( logger.error(
"Bad response status of", 'Bad response status of',
response.status, response.status,
"for confirmers:", 'for confirmers:',
response, response
); )
return undefined; return undefined
} }
} }
@ -245,7 +245,7 @@ export function isGiveRecordTheUserCanConfirm(
isRegistered: boolean, isRegistered: boolean,
veriClaim: GenericCredWrapper<GenericVerifiableCredential>, veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
activeDid: string, activeDid: string,
confirmerIdList: string[] = [], confirmerIdList: string[] = []
): boolean { ): boolean {
return ( return (
isRegistered && isRegistered &&
@ -253,7 +253,7 @@ export function isGiveRecordTheUserCanConfirm(
!confirmerIdList.includes(activeDid) && !confirmerIdList.includes(activeDid) &&
veriClaim.issuer !== activeDid && veriClaim.issuer !== activeDid &&
!containsHiddenDid(veriClaim.claim) !containsHiddenDid(veriClaim.claim)
); )
} }
export function notifyWhyCannotConfirm( export function notifyWhyCannotConfirm(
@ -262,101 +262,101 @@ export function notifyWhyCannotConfirm(
claimType: string | undefined, claimType: string | undefined,
giveDetails: GiveSummaryRecord | undefined, giveDetails: GiveSummaryRecord | undefined,
activeDid: string, activeDid: string,
confirmerIdList: string[] = [], confirmerIdList: string[] = []
) { ) {
if (!isRegistered) { if (!isRegistered) {
notifyFun( notifyFun(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Not Registered", title: 'Not Registered',
text: "Someone needs to register you before you can confirm.", text: 'Someone needs to register you before you can confirm.'
}, },
3000, 3000
); )
} else if (!isGiveClaimType(claimType)) { } else if (!isGiveClaimType(claimType)) {
notifyFun( notifyFun(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Not A Give", title: 'Not A Give',
text: "This is not a giving action to confirm.", text: 'This is not a giving action to confirm.'
}, },
3000, 3000
); )
} else if (confirmerIdList.includes(activeDid)) { } else if (confirmerIdList.includes(activeDid)) {
notifyFun( notifyFun(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Already Confirmed", title: 'Already Confirmed',
text: "You already confirmed this claim.", text: 'You already confirmed this claim.'
}, },
3000, 3000
); )
} else if (giveDetails?.issuerDid == activeDid) { } else if (giveDetails?.issuerDid == activeDid) {
notifyFun( notifyFun(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Cannot Confirm", title: 'Cannot Confirm',
text: "You cannot confirm this because you issued this claim.", text: 'You cannot confirm this because you issued this claim.'
}, },
3000, 3000
); )
} else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) { } else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) {
notifyFun( notifyFun(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Cannot Confirm", title: 'Cannot Confirm',
text: "You cannot confirm this because some people are hidden.", text: 'You cannot confirm this because some people are hidden.'
}, },
3000, 3000
); )
} else { } else {
notifyFun( notifyFun(
{ {
group: "alert", group: 'alert',
type: "info", type: 'info',
title: "Cannot Confirm", title: 'Cannot Confirm',
text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.", text: 'You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.'
}, },
3000, 3000
); )
} }
} }
export async function blobToBase64(blob: Blob): Promise<string> { export async function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer? reader.onloadend = () => resolve(reader.result as string) // potential problem if it returns an ArrayBuffer?
reader.onerror = reject; reader.onerror = reject
reader.readAsDataURL(blob); reader.readAsDataURL(blob)
}); })
} }
export function base64ToBlob(base64DataUrl: string, sliceSize = 512) { export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
// Extract the content type and the Base64 data // Extract the content type and the Base64 data
const [metadata, base64] = base64DataUrl.split(","); const [metadata, base64] = base64DataUrl.split(',')
const contentTypeMatch = metadata.match(/data:(.*?);base64/); const contentTypeMatch = metadata.match(/data:(.*?);base64/)
const contentType = contentTypeMatch ? contentTypeMatch[1] : ""; const contentType = contentTypeMatch ? contentTypeMatch[1] : ''
const byteCharacters = atob(base64); const byteCharacters = atob(base64)
const byteArrays = []; const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize); const slice = byteCharacters.slice(offset, offset + sliceSize)
const byteNumbers = new Array(slice.length); const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) { for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i); byteNumbers[i] = slice.charCodeAt(i)
} }
const byteArray = new Uint8Array(byteNumbers); const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray); byteArrays.push(byteArray)
} }
return new Blob(byteArrays, { type: contentType }); return new Blob(byteArrays, { type: contentType })
} }
/** /**
@ -364,18 +364,18 @@ export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
* @param veriClaim is expected to have fields: claim and issuer * @param veriClaim is expected to have fields: claim and issuer
*/ */
export function offerGiverDid( export function offerGiverDid(
veriClaim: GenericCredWrapper<OfferVerifiableCredential>, veriClaim: GenericCredWrapper<OfferVerifiableCredential>
): string | undefined { ): string | undefined {
let giver; let giver
if ( if (
veriClaim.claim.offeredBy?.identifier && veriClaim.claim.offeredBy?.identifier &&
!serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string) !serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string)
) { ) {
giver = veriClaim.claim.offeredBy.identifier; giver = veriClaim.claim.offeredBy.identifier
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) { } else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
giver = veriClaim.issuer; giver = veriClaim.issuer
} }
return giver; return giver
} }
/** /**
@ -383,53 +383,53 @@ export function offerGiverDid(
* @param veriClaim is expected to have fields: claim, claimType, and issuer * @param veriClaim is expected to have fields: claim, claimType, and issuer
*/ */
export const canFulfillOffer = ( export const canFulfillOffer = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>, veriClaim: GenericCredWrapper<GenericVerifiableCredential>
) => { ) => {
return ( return (
veriClaim.claimType === "Offer" && veriClaim.claimType === 'Offer' &&
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>) !!offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
); )
}; }
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid" // return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
export function findAllVisibleToDids( export function findAllVisibleToDids(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
input: any, input: any,
humanReadable = false, humanReadable = false
): Record<string, Array<string>> { ): Record<string, Array<string>> {
if (Array.isArray(input)) { if (Array.isArray(input)) {
const result: Record<string, Array<string>> = {}; const result: Record<string, Array<string>> = {}
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
const inside = findAllVisibleToDids(input[i], humanReadable); const inside = findAllVisibleToDids(input[i], humanReadable)
for (const key in inside) { for (const key in inside) {
const pathKey = humanReadable const pathKey = humanReadable
? "#" + (i + 1) + " " + key ? '#' + (i + 1) + ' ' + key
: "[" + i + "]" + key; : '[' + i + ']' + key
result[pathKey] = inside[key]; result[pathKey] = inside[key]
} }
} }
return result; return result
} else if (input instanceof Object) { } else if (input instanceof Object) {
// regular map (non-array) object // regular map (non-array) object
const result: Record<string, Array<string>> = {}; const result: Record<string, Array<string>> = {}
for (const key in input) { for (const key in input) {
if (key.endsWith("VisibleToDids")) { if (key.endsWith('VisibleToDids')) {
const newKey = key.slice(0, -"VisibleToDids".length); const newKey = key.slice(0, -'VisibleToDids'.length)
const pathKey = humanReadable ? newKey : "." + newKey; const pathKey = humanReadable ? newKey : '.' + newKey
result[pathKey] = input[key]; result[pathKey] = input[key]
} else { } else {
const inside = findAllVisibleToDids(input[key], humanReadable); const inside = findAllVisibleToDids(input[key], humanReadable)
for (const insideKey in inside) { for (const insideKey in inside) {
const pathKey = humanReadable const pathKey = humanReadable
? key + "'s " + insideKey ? key + "'s " + insideKey
: "." + key + insideKey; : '.' + key + insideKey
result[pathKey] = inside[insideKey]; result[pathKey] = inside[insideKey]
} }
} }
} }
return result; return result
} else { } else {
return {}; return {}
} }
} }
@ -461,151 +461,151 @@ export interface AccountKeyInfo extends Account, KeyMeta {}
export const retrieveAccountCount = async (): Promise<number> => { export const retrieveAccountCount = async (): Promise<number> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
return await accountsDB.accounts.count(); return await accountsDB.accounts.count()
}; }
export const retrieveAccountDids = async (): Promise<string[]> => { export const retrieveAccountDids = async (): Promise<string[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray()
const allDids = allAccounts.map((acc) => acc.did); const allDids = allAccounts.map((acc) => acc.did)
return allDids; return allDids
}; }
// This is provided and recommended when the full key is not necessary so that // This is provided and recommended when the full key is not necessary so that
// future work could separate this info from the sensitive key material. // future work could separate this info from the sensitive key material.
export const retrieveAccountMetadata = async ( export const retrieveAccountMetadata = async (
activeDid: string, activeDid: string
): Promise<AccountKeyInfo | undefined> => { ): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
const account = (await accountsDB.accounts const account = (await accountsDB.accounts
.where("did") .where('did')
.equals(activeDid) .equals(activeDid)
.first()) as Account; .first()) as Account
if (account) { if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account; const { identity, mnemonic, ...metadata } = account
return metadata; return metadata
} else { } else {
return undefined; return undefined
} }
}; }
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => { export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
const array = await accountsDB.accounts.toArray(); const array = await accountsDB.accounts.toArray()
return array.map((account) => { return array.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account; const { identity, mnemonic, ...metadata } = account
return metadata; return metadata
}); })
}; }
export const retrieveFullyDecryptedAccount = async ( export const retrieveFullyDecryptedAccount = async (
activeDid: string, activeDid: string
): Promise<AccountKeyInfo | undefined> => { ): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
const account = (await accountsDB.accounts const account = (await accountsDB.accounts
.where("did") .where('did')
.equals(activeDid) .equals(activeDid)
.first()) as Account; .first()) as Account
return account; return account
}; }
// let's try and eliminate this // let's try and eliminate this
export const retrieveAllFullyDecryptedAccounts = async (): Promise< export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountKeyInfo> Array<AccountKeyInfo>
> => { > => {
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
const allAccounts = await accountsDB.accounts.toArray(); const allAccounts = await accountsDB.accounts.toArray()
return allAccounts; return allAccounts
}; }
/** /**
* Generates a new identity, saves it to the database, and sets it as the active identity. * Generates a new identity, saves it to the database, and sets it as the active identity.
* @return {Promise<string>} with the DID of the new identity * @return {Promise<string>} with the DID of the new identity
*/ */
export const generateSaveAndActivateIdentity = async (): Promise<string> => { export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const mnemonic = generateSeed(); const mnemonic = generateSeed()
// address is 0x... ETH address, without "did:eth:" // address is 0x... ETH address, without "did:eth:"
const [address, privateHex, publicHex, derivationPath] = const [address, privateHex, publicHex, derivationPath] =
deriveAddress(mnemonic); deriveAddress(mnemonic)
const newId = newIdentifier(address, publicHex, privateHex, derivationPath); const newId = newIdentifier(address, publicHex, privateHex, derivationPath)
const identity = JSON.stringify(newId); const identity = JSON.stringify(newId)
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
await accountsDB.accounts.add({ await accountsDB.accounts.add({
dateCreated: new Date().toISOString(), dateCreated: new Date().toISOString(),
derivationPath: derivationPath, derivationPath: derivationPath,
did: newId.did, did: newId.did,
identity: identity, identity: identity,
mnemonic: mnemonic, mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex, publicKeyHex: newId.keys[0].publicKeyHex
}); })
await updateDefaultSettings({ activeDid: newId.did }); await updateDefaultSettings({ activeDid: newId.did })
//console.log("Updated default settings in util"); //console.log("Updated default settings in util");
await updateAccountSettings(newId.did, { isRegistered: false }); await updateAccountSettings(newId.did, { isRegistered: false })
return newId.did; return newId.did
}; }
export const registerAndSavePasskey = async ( export const registerAndSavePasskey = async (
keyName: string, keyName: string
): Promise<Account> => { ): Promise<Account> => {
const cred = await registerCredential(keyName); const cred = await registerCredential(keyName)
const publicKeyBytes = cred.publicKeyBytes; const publicKeyBytes = cred.publicKeyBytes
const did = createPeerDid(publicKeyBytes as Uint8Array); const did = createPeerDid(publicKeyBytes as Uint8Array)
const passkeyCredIdHex = cred.credIdHex as string; const passkeyCredIdHex = cred.credIdHex as string
const account = { const account = {
dateCreated: new Date().toISOString(), dateCreated: new Date().toISOString(),
did, did,
passkeyCredIdHex, passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"), publicKeyHex: Buffer.from(publicKeyBytes).toString('hex')
}; }
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
await accountsDB.accounts.add(account); await accountsDB.accounts.add(account)
return account; return account
}; }
export const registerSaveAndActivatePasskey = async ( export const registerSaveAndActivatePasskey = async (
keyName: string, keyName: string
): Promise<Account> => { ): Promise<Account> => {
const account = await registerAndSavePasskey(keyName); const account = await registerAndSavePasskey(keyName)
await updateDefaultSettings({ activeDid: account.did }); await updateDefaultSettings({ activeDid: account.did })
await updateAccountSettings(account.did, { isRegistered: false }); await updateAccountSettings(account.did, { isRegistered: false })
return account; return account
}; }
export const getPasskeyExpirationSeconds = async (): Promise<number> => { export const getPasskeyExpirationSeconds = async (): Promise<number> => {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
return ( return (
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) * (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
60 60
); )
}; }
// These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js // These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
export const DAILY_CHECK_TITLE = "DAILY_CHECK"; export const DAILY_CHECK_TITLE = 'DAILY_CHECK'
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters. // This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION"; export const DIRECT_PUSH_TITLE = 'DIRECT_NOTIFICATION'
export const sendTestThroughPushServer = async ( export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON, subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean, skipFilter: boolean
): Promise<AxiosResponse> => { ): Promise<AxiosResponse> => {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
let pushUrl: string = DEFAULT_PUSH_SERVER as string; let pushUrl: string = DEFAULT_PUSH_SERVER as string
if (settings?.webPushServer) { if (settings?.webPushServer) {
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer
} }
const newPayload = { const newPayload = {
@ -613,20 +613,20 @@ export const sendTestThroughPushServer = async (
// ... overridden with the following // ... overridden with the following
// eslint-disable-next-line prettier/prettier // eslint-disable-next-line prettier/prettier
message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`, message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push", title: skipFilter ? DIRECT_PUSH_TITLE : 'Your Web Push'
}; }
logger.log("Sending a test web push message:", newPayload); logger.log('Sending a test web push message:', newPayload)
const payloadStr = JSON.stringify(newPayload); const payloadStr = JSON.stringify(newPayload)
const response = await axios.post( const response = await axios.post(
pushUrl + "/web-push/send-test", pushUrl + '/web-push/send-test',
payloadStr, payloadStr,
{ {
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, }
}, }
); )
logger.log("Got response from web push server:", response); logger.log('Got response from web push server:', response)
return response; return response
}; }

4
src/libs/veramo/setup.ts

@ -1,7 +1,7 @@
// see also ../constants/app.ts and // see also ../constants/app.ts and
function didProviderName(netName: string) { function didProviderName(netName: string) {
return "did:ethr" + (netName === "mainnet" ? "" : ":" + netName); return 'did:ethr' + (netName === 'mainnet' ? '' : ':' + netName)
} }
export const DEFAULT_DID_PROVIDER_NAME = didProviderName("mainnet"); export const DEFAULT_DID_PROVIDER_NAME = didProviderName('mainnet')

52
src/main.capacitor.ts

@ -28,28 +28,28 @@
* // Processed and routed to appropriate view with type-safe parameters * // Processed and routed to appropriate view with type-safe parameters
*/ */
import { initializeApp } from "./main.common"; import { initializeApp } from './main.common'
import { App } from "./lib/capacitor/app"; import { App } from './lib/capacitor/app'
import router from "./router"; import router from './router'
import { handleApiError } from "./services/api"; import { handleApiError } from './services/api'
import { AxiosError } from "axios"; import { AxiosError } from 'axios'
import { DeepLinkHandler } from "./services/deepLinks"; import { DeepLinkHandler } from './services/deepLinks'
import { logConsoleAndDb } from "./db"; import { logConsoleAndDb } from './db'
import { logger } from "./utils/logger"; import { logger } from './utils/logger'
logger.log("[Capacitor] Starting initialization"); logger.log('[Capacitor] Starting initialization')
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM); logger.log('[Capacitor] Platform:', process.env.VITE_PLATFORM)
const app = initializeApp(); const app = initializeApp()
// Initialize API error handling for unhandled promise rejections // Initialize API error handling for unhandled promise rejections
window.addEventListener("unhandledrejection", (event) => { window.addEventListener('unhandledrejection', (event) => {
if (event.reason?.response) { if (event.reason?.response) {
handleApiError(event.reason, event.reason.config?.url || "unknown"); handleApiError(event.reason, event.reason.config?.url || 'unknown')
} }
}); })
const deepLinkHandler = new DeepLinkHandler(router); const deepLinkHandler = new DeepLinkHandler(router)
/** /**
* Handles deep link routing for the application * Handles deep link routing for the application
@ -69,22 +69,22 @@ const deepLinkHandler = new DeepLinkHandler(router);
*/ */
const handleDeepLink = async (data: { url: string }) => { const handleDeepLink = async (data: { url: string }) => {
try { try {
await router.isReady(); await router.isReady()
await deepLinkHandler.handleDeepLink(data.url); await deepLinkHandler.handleDeepLink(data.url)
} catch (error) { } catch (error) {
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true); logConsoleAndDb('[DeepLink] Error handling deep link: ' + error, true)
handleApiError( handleApiError(
{ {
message: error instanceof Error ? error.message : String(error), message: error instanceof Error ? error.message : String(error)
} as AxiosError, } as AxiosError,
"deep-link", 'deep-link'
); )
} }
}; }
// Register deep link handler with Capacitor // Register deep link handler with Capacitor
App.addListener("appUrlOpen", handleDeepLink); App.addListener('appUrlOpen', handleDeepLink)
logger.log("[Capacitor] Mounting app"); logger.log('[Capacitor] Mounting app')
app.mount("#app"); app.mount('#app')
logger.log("[Capacitor] App mounted"); logger.log('[Capacitor] App mounted')

76
src/main.common.ts

@ -1,61 +1,61 @@
import { createPinia } from "pinia"; import { createPinia } from 'pinia'
import { App as VueApp, ComponentPublicInstance, createApp } from "vue"; import { App as VueApp, ComponentPublicInstance, createApp } from 'vue'
import App from "./App.vue"; import App from './App.vue'
import router from "./router"; import router from './router'
import axios from "axios"; import axios from 'axios'
import VueAxios from "vue-axios"; import VueAxios from 'vue-axios'
import Notifications from "notiwind"; import Notifications from 'notiwind'
import "./assets/styles/tailwind.css"; import './assets/styles/tailwind.css'
import { FontAwesomeIcon } from "./lib/fontawesome"; import { FontAwesomeIcon } from './lib/fontawesome'
import Camera from "simple-vue-camera"; import Camera from 'simple-vue-camera'
import { logger } from "./utils/logger"; import { logger } from './utils/logger'
// Global Error Handler // Global Error Handler
function setupGlobalErrorHandler(app: VueApp) { function setupGlobalErrorHandler(app: VueApp) {
logger.log("[App Init] Setting up global error handler"); logger.log('[App Init] Setting up global error handler')
app.config.errorHandler = ( app.config.errorHandler = (
err: unknown, err: unknown,
instance: ComponentPublicInstance | null, instance: ComponentPublicInstance | null,
info: string, info: string
) => { ) => {
logger.error("[App Error] Global Error Handler:", { logger.error('[App Error] Global Error Handler:', {
error: err, error: err,
info, info,
component: instance?.$options.name || "unknown", component: instance?.$options.name || 'unknown'
}); })
alert( alert(
(err instanceof Error ? err.message : "Something bad happened") + (err instanceof Error ? err.message : 'Something bad happened') +
" - Try reloading or restarting the app.", ' - Try reloading or restarting the app.'
); )
}; }
} }
// Function to initialize the app // Function to initialize the app
export function initializeApp() { export function initializeApp() {
logger.log("[App Init] Starting app initialization"); logger.log('[App Init] Starting app initialization')
logger.log("[App Init] Platform:", process.env.VITE_PLATFORM); logger.log('[App Init] Platform:', process.env.VITE_PLATFORM)
const app = createApp(App); const app = createApp(App)
logger.log("[App Init] Vue app created"); logger.log('[App Init] Vue app created')
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera); app.component('FontAwesome', FontAwesomeIcon).component('camera', Camera)
logger.log("[App Init] Components registered"); logger.log('[App Init] Components registered')
const pinia = createPinia(); const pinia = createPinia()
app.use(pinia); app.use(pinia)
logger.log("[App Init] Pinia store initialized"); logger.log('[App Init] Pinia store initialized')
app.use(VueAxios, axios); app.use(VueAxios, axios)
logger.log("[App Init] Axios initialized"); logger.log('[App Init] Axios initialized')
app.use(router); app.use(router)
logger.log("[App Init] Router initialized"); logger.log('[App Init] Router initialized')
app.use(Notifications); app.use(Notifications)
logger.log("[App Init] Notifications initialized"); logger.log('[App Init] Notifications initialized')
setupGlobalErrorHandler(app); setupGlobalErrorHandler(app)
logger.log("[App Init] App initialization complete"); logger.log('[App Init] App initialization complete')
return app; return app
} }

6
src/main.electron.ts

@ -1,4 +1,4 @@
import { initializeApp } from "./main.common"; import { initializeApp } from './main.common'
const app = initializeApp(); const app = initializeApp()
app.mount("#app"); app.mount('#app')

6
src/main.pywebview.ts

@ -1,4 +1,4 @@
import { initializeApp } from "./main.common"; import { initializeApp } from './main.common'
const app = initializeApp(); const app = initializeApp()
app.mount("#app"); app.mount('#app')

68
src/main.ts

@ -1,14 +1,14 @@
import { createPinia } from "pinia"; import { createPinia } from 'pinia'
import { App as VueApp, ComponentPublicInstance, createApp } from "vue"; import { App as VueApp, ComponentPublicInstance, createApp } from 'vue'
import App from "./App.vue"; import App from './App.vue'
import "./registerServiceWorker"; import './registerServiceWorker'
import router from "./router"; import router from './router'
import axios from "axios"; import axios from 'axios'
import VueAxios from "vue-axios"; import VueAxios from 'vue-axios'
import Notifications from "notiwind"; import Notifications from 'notiwind'
import "./assets/styles/tailwind.css"; import './assets/styles/tailwind.css'
import { library } from "@fortawesome/fontawesome-svg-core"; import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faArrowDown, faArrowDown,
faArrowLeft, faArrowLeft,
@ -86,8 +86,8 @@ import {
faTriangleExclamation, faTriangleExclamation,
faUser, faUser,
faUsers, faUsers,
faXmark, faXmark
} from "@fortawesome/free-solid-svg-icons"; } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
faArrowDown, faArrowDown,
@ -166,12 +166,12 @@ library.add(
faTriangleExclamation, faTriangleExclamation,
faUser, faUser,
faUsers, faUsers,
faXmark, faXmark
); )
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import Camera from "simple-vue-camera"; import Camera from 'simple-vue-camera'
import { logger } from "./utils/logger"; import { logger } from './utils/logger'
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView // Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
function setupGlobalErrorHandler(app: VueApp) { function setupGlobalErrorHandler(app: VueApp) {
@ -179,35 +179,35 @@ function setupGlobalErrorHandler(app: VueApp) {
app.config.errorHandler = ( app.config.errorHandler = (
err: Error, err: Error,
instance: ComponentPublicInstance | null, instance: ComponentPublicInstance | null,
info: string, info: string
) => { ) => {
logger.error( logger.error(
"Ouch! Global Error Handler.", 'Ouch! Global Error Handler.',
"Error:", 'Error:',
err, err,
"- Error toString:", '- Error toString:',
err.toString(), err.toString(),
"- Info:", '- Info:',
info, info,
"- Instance:", '- Instance:',
instance, instance
); )
// Want to show a nice notiwind notification but can't figure out how. // Want to show a nice notiwind notification but can't figure out how.
alert( alert(
(err.message || "Something bad happened") + (err.message || 'Something bad happened') +
" - Try reloading or restarting the app.", ' - Try reloading or restarting the app.'
); )
}; }
} }
const app = createApp(App) const app = createApp(App)
.component("fa", FontAwesomeIcon) .component('fa', FontAwesomeIcon)
.component("camera", Camera) .component('camera', Camera)
.use(createPinia()) .use(createPinia())
.use(VueAxios, axios) .use(VueAxios, axios)
.use(router) .use(router)
.use(Notifications); .use(Notifications)
setupGlobalErrorHandler(app); setupGlobalErrorHandler(app)
app.mount("#app"); app.mount('#app')

8
src/main.web.ts

@ -1,5 +1,5 @@
import { initializeApp } from "./main.common"; import { initializeApp } from './main.common'
import "./registerServiceWorker"; // Web PWA support import './registerServiceWorker' // Web PWA support
const app = initializeApp(); const app = initializeApp()
app.mount("#app"); app.mount('#app')

30
src/registerServiceWorker.ts

@ -1,39 +1,39 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { register } from "register-service-worker"; import { register } from 'register-service-worker'
// Only register service worker if explicitly enabled and in production // Only register service worker if explicitly enabled and in production
if ( if (
process.env.VITE_PWA_ENABLED === "true" && process.env.VITE_PWA_ENABLED === 'true' &&
process.env.NODE_ENV === "production" process.env.NODE_ENV === 'production'
) { ) {
register(`${process.env.BASE_URL}sw.js`, { register(`${process.env.BASE_URL}sw.js`, {
ready() { ready() {
console.log("Service worker is active."); console.log('Service worker is active.')
}, },
registered() { registered() {
console.log("Service worker has been registered."); console.log('Service worker has been registered.')
}, },
cached() { cached() {
console.log("Content has been cached for offline use."); console.log('Content has been cached for offline use.')
}, },
updatefound() { updatefound() {
console.log("New content is downloading."); console.log('New content is downloading.')
}, },
updated() { updated() {
console.log("New content is available; please refresh."); console.log('New content is available; please refresh.')
}, },
offline() { offline() {
console.log( console.log(
"No internet connection found. App is running in offline mode.", 'No internet connection found. App is running in offline mode.'
); )
}, },
error(error) { error(error) {
console.error("Error during service worker registration:", error); console.error('Error during service worker registration:', error)
}, }
}); })
} else { } else {
console.log( console.log(
"Service worker registration skipped - not enabled or not in production", 'Service worker registration skipped - not enabled or not in production'
); )
} }

364
src/router/index.ts

@ -4,10 +4,10 @@ import {
createMemoryHistory, createMemoryHistory,
NavigationGuardNext, NavigationGuardNext,
RouteLocationNormalized, RouteLocationNormalized,
RouteRecordRaw, RouteRecordRaw
} from "vue-router"; } from 'vue-router'
import { accountsDBPromise } from "../db/index"; import { accountsDBPromise } from '../db/index'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
/** /**
* *
@ -18,317 +18,317 @@ import { logger } from "../utils/logger";
const enterOrStart = async ( const enterOrStart = async (
to: RouteLocationNormalized, to: RouteLocationNormalized,
from: RouteLocationNormalized, from: RouteLocationNormalized,
next: NavigationGuardNext, next: NavigationGuardNext
) => { ) => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage // one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; const accountsDB = await accountsDBPromise
const num_accounts = await accountsDB.accounts.count(); const num_accounts = await accountsDB.accounts.count()
if (num_accounts > 0) { if (num_accounts > 0) {
next(); next()
} else { } else {
next({ name: "start" }); next({ name: 'start' })
} }
}; }
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
{ {
path: "/account", path: '/account',
name: "account", name: 'account',
component: () => import("../views/AccountViewView.vue"), component: () => import('../views/AccountViewView.vue')
}, },
{ {
path: "/claim/:id?", path: '/claim/:id?',
name: "claim", name: 'claim',
component: () => import("../views/ClaimView.vue"), component: () => import('../views/ClaimView.vue')
}, },
{ {
path: "/claim-add-raw/:id?", path: '/claim-add-raw/:id?',
name: "claim-add-raw", name: 'claim-add-raw',
component: () => import("../views/ClaimAddRawView.vue"), component: () => import('../views/ClaimAddRawView.vue')
}, },
{ {
path: "/claim-cert/:id", path: '/claim-cert/:id',
name: "claim-cert", name: 'claim-cert',
component: () => import("../views/ClaimCertificateView.vue"), component: () => import('../views/ClaimCertificateView.vue')
}, },
{ {
path: "/confirm-contact", path: '/confirm-contact',
name: "confirm-contact", name: 'confirm-contact',
component: () => import("../views/ConfirmContactView.vue"), component: () => import('../views/ConfirmContactView.vue')
}, },
{ {
path: "/confirm-gift/:id?", path: '/confirm-gift/:id?',
name: "confirm-gift", name: 'confirm-gift',
component: () => import("../views/ConfirmGiftView.vue"), component: () => import('../views/ConfirmGiftView.vue')
}, },
{ {
path: "/contact-amounts", path: '/contact-amounts',
name: "contact-amounts", name: 'contact-amounts',
component: () => import("../views/ContactAmountsView.vue"), component: () => import('../views/ContactAmountsView.vue')
}, },
{ {
path: "/contact-edit/:did", path: '/contact-edit/:did',
name: "contact-edit", name: 'contact-edit',
component: () => import("../views/ContactEditView.vue"), component: () => import('../views/ContactEditView.vue')
}, },
{ {
path: "/contact-gift", path: '/contact-gift',
name: "contact-gift", name: 'contact-gift',
component: () => import("../views/ContactGiftingView.vue"), component: () => import('../views/ContactGiftingView.vue')
}, },
{ {
path: "/contact-import/:jwt?", path: '/contact-import/:jwt?',
name: "contact-import", name: 'contact-import',
component: () => import("../views/ContactImportView.vue"), component: () => import('../views/ContactImportView.vue')
}, },
{ {
path: "/contact-qr", path: '/contact-qr',
name: "contact-qr", name: 'contact-qr',
component: () => import("../views/ContactQRScanShowView.vue"), component: () => import('../views/ContactQRScanShowView.vue')
}, },
{ {
path: "/contacts", path: '/contacts',
name: "contacts", name: 'contacts',
component: () => import("../views/ContactsView.vue"), component: () => import('../views/ContactsView.vue')
}, },
{ {
path: "/did/:did?", path: '/did/:did?',
name: "did", name: 'did',
component: () => import("../views/DIDView.vue"), component: () => import('../views/DIDView.vue')
}, },
{ {
path: "/discover", path: '/discover',
name: "discover", name: 'discover',
component: () => import("../views/DiscoverView.vue"), component: () => import('../views/DiscoverView.vue')
}, },
{ {
path: "/gifted-details", path: '/gifted-details',
name: "gifted-details", name: 'gifted-details',
component: () => import("../views/GiftedDetailsView.vue"), component: () => import('../views/GiftedDetailsView.vue')
}, },
{ {
path: "/help", path: '/help',
name: "help", name: 'help',
component: () => import("../views/HelpView.vue"), component: () => import('../views/HelpView.vue')
}, },
{ {
path: "/help-notifications", path: '/help-notifications',
name: "help-notifications", name: 'help-notifications',
component: () => import("../views/HelpNotificationsView.vue"), component: () => import('../views/HelpNotificationsView.vue')
}, },
{ {
path: "/help-notification-types", path: '/help-notification-types',
name: "help-notification-types", name: 'help-notification-types',
component: () => import("../views/HelpNotificationTypesView.vue"), component: () => import('../views/HelpNotificationTypesView.vue')
}, },
{ {
path: "/help-onboarding", path: '/help-onboarding',
name: "help-onboarding", name: 'help-onboarding',
component: () => import("../views/HelpOnboardingView.vue"), component: () => import('../views/HelpOnboardingView.vue')
}, },
{ {
path: "/", path: '/',
name: "home", name: 'home',
component: () => import("../views/HomeView.vue"), component: () => import('../views/HomeView.vue')
}, },
{ {
path: "/identity-switcher", path: '/identity-switcher',
name: "identity-switcher", name: 'identity-switcher',
component: () => import("../views/IdentitySwitcherView.vue"), component: () => import('../views/IdentitySwitcherView.vue')
}, },
{ {
path: "/import-account", path: '/import-account',
name: "import-account", name: 'import-account',
component: () => import("../views/ImportAccountView.vue"), component: () => import('../views/ImportAccountView.vue')
}, },
{ {
path: "/import-derive", path: '/import-derive',
name: "import-derive", name: 'import-derive',
component: () => import("../views/ImportDerivedAccountView.vue"), component: () => import('../views/ImportDerivedAccountView.vue')
}, },
{ {
path: "/invite-one", path: '/invite-one',
name: "invite-one", name: 'invite-one',
component: () => import("../views/InviteOneView.vue"), component: () => import('../views/InviteOneView.vue')
}, },
{ {
path: "/invite-one-accept/:jwt?", path: '/invite-one-accept/:jwt?',
name: "InviteOneAcceptView", name: 'InviteOneAcceptView',
component: () => import("../views/InviteOneAcceptView.vue"), component: () => import('../views/InviteOneAcceptView.vue')
}, },
{ {
path: "/logs", path: '/logs',
name: "logs", name: 'logs',
component: () => import("../views/LogView.vue"), component: () => import('../views/LogView.vue')
}, },
{ {
path: "/new-activity", path: '/new-activity',
name: "new-activity", name: 'new-activity',
component: () => import("../views/NewActivityView.vue"), component: () => import('../views/NewActivityView.vue')
}, },
{ {
path: "/new-edit-account", path: '/new-edit-account',
name: "new-edit-account", name: 'new-edit-account',
component: () => import("../views/NewEditAccountView.vue"), component: () => import('../views/NewEditAccountView.vue')
}, },
{ {
path: "/new-edit-project", path: '/new-edit-project',
name: "new-edit-project", name: 'new-edit-project',
component: () => import("../views/NewEditProjectView.vue"), component: () => import('../views/NewEditProjectView.vue')
}, },
{ {
path: "/new-identifier", path: '/new-identifier',
name: "new-identifier", name: 'new-identifier',
component: () => import("../views/NewIdentifierView.vue"), component: () => import('../views/NewIdentifierView.vue')
}, },
{ {
path: "/offer-details/:id?", path: '/offer-details/:id?',
name: "offer-details", name: 'offer-details',
component: () => import("../views/OfferDetailsView.vue"), component: () => import('../views/OfferDetailsView.vue')
}, },
{ {
path: "/onboard-meeting-list", path: '/onboard-meeting-list',
name: "onboard-meeting-list", name: 'onboard-meeting-list',
component: () => import("../views/OnboardMeetingListView.vue"), component: () => import('../views/OnboardMeetingListView.vue')
}, },
{ {
path: "/onboard-meeting-members/:groupId", path: '/onboard-meeting-members/:groupId',
name: "onboard-meeting-members", name: 'onboard-meeting-members',
component: () => import("../views/OnboardMeetingMembersView.vue"), component: () => import('../views/OnboardMeetingMembersView.vue')
}, },
{ {
path: "/onboard-meeting-setup", path: '/onboard-meeting-setup',
name: "onboard-meeting-setup", name: 'onboard-meeting-setup',
component: () => import("../views/OnboardMeetingSetupView.vue"), component: () => import('../views/OnboardMeetingSetupView.vue')
}, },
{ {
path: "/project/:id?", path: '/project/:id?',
name: "project", name: 'project',
component: () => import("../views/ProjectViewView.vue"), component: () => import('../views/ProjectViewView.vue')
}, },
{ {
path: "/projects", path: '/projects',
name: "projects", name: 'projects',
component: () => import("../views/ProjectsView.vue"), component: () => import('../views/ProjectsView.vue'),
beforeEnter: enterOrStart, beforeEnter: enterOrStart
}, },
{ {
path: "/quick-action-bvc", path: '/quick-action-bvc',
name: "quick-action-bvc", name: 'quick-action-bvc',
component: () => import("../views/QuickActionBvcView.vue"), component: () => import('../views/QuickActionBvcView.vue')
}, },
{ {
path: "/quick-action-bvc-begin", path: '/quick-action-bvc-begin',
name: "quick-action-bvc-begin", name: 'quick-action-bvc-begin',
component: () => import("../views/QuickActionBvcBeginView.vue"), component: () => import('../views/QuickActionBvcBeginView.vue')
}, },
{ {
path: "/quick-action-bvc-end", path: '/quick-action-bvc-end',
name: "quick-action-bvc-end", name: 'quick-action-bvc-end',
component: () => import("../views/QuickActionBvcEndView.vue"), component: () => import('../views/QuickActionBvcEndView.vue')
}, },
{ {
path: "/recent-offers-to-user", path: '/recent-offers-to-user',
name: "recent-offers-to-user", name: 'recent-offers-to-user',
component: () => import("../views/RecentOffersToUserView.vue"), component: () => import('../views/RecentOffersToUserView.vue')
}, },
{ {
path: "/recent-offers-to-user-projects", path: '/recent-offers-to-user-projects',
name: "recent-offers-to-user-projects", name: 'recent-offers-to-user-projects',
component: () => import("../views/RecentOffersToUserProjectsView.vue"), component: () => import('../views/RecentOffersToUserProjectsView.vue')
}, },
{ {
path: "/scan-contact", path: '/scan-contact',
name: "scan-contact", name: 'scan-contact',
component: () => import("../views/ContactScanView.vue"), component: () => import('../views/ContactScanView.vue')
}, },
{ {
path: "/search-area", path: '/search-area',
name: "search-area", name: 'search-area',
component: () => import("../views/SearchAreaView.vue"), component: () => import('../views/SearchAreaView.vue')
}, },
{ {
path: "/seed-backup", path: '/seed-backup',
name: "seed-backup", name: 'seed-backup',
component: () => import("../views/SeedBackupView.vue"), component: () => import('../views/SeedBackupView.vue')
}, },
{ {
path: "/share-my-contact-info", path: '/share-my-contact-info',
name: "share-my-contact-info", name: 'share-my-contact-info',
component: () => import("../views/ShareMyContactInfoView.vue"), component: () => import('../views/ShareMyContactInfoView.vue')
}, },
{ {
path: "/shared-photo", path: '/shared-photo',
name: "shared-photo", name: 'shared-photo',
component: () => import("../views/SharedPhotoView.vue"), component: () => import('../views/SharedPhotoView.vue')
}, },
// /share-target is also an endpoint in the service worker // /share-target is also an endpoint in the service worker
{ {
path: "/start", path: '/start',
name: "start", name: 'start',
component: () => import("../views/StartView.vue"), component: () => import('../views/StartView.vue')
}, },
{ {
path: "/statistics", path: '/statistics',
name: "statistics", name: 'statistics',
component: () => import("../views/StatisticsView.vue"), component: () => import('../views/StatisticsView.vue')
}, },
{ {
path: "/test", path: '/test',
name: "test", name: 'test',
component: () => import("../views/TestView.vue"), component: () => import('../views/TestView.vue')
}, },
{ {
path: "/user-profile/:id?", path: '/user-profile/:id?',
name: "user-profile", name: 'user-profile',
component: () => import("../views/UserProfileView.vue"), component: () => import('../views/UserProfileView.vue')
}, },
{ {
path: "/deep-link-error", path: '/deep-link-error',
name: "deep-link-error", name: 'deep-link-error',
component: () => import("../views/DeepLinkErrorView.vue"), component: () => import('../views/DeepLinkErrorView.vue'),
meta: { meta: {
title: "Invalid Deep Link", title: 'Invalid Deep Link',
requiresAuth: false, requiresAuth: false
}, }
}, }
]; ]
const isElectron = window.location.protocol === "file:"; const isElectron = window.location.protocol === 'file:'
const initialPath = isElectron const initialPath = isElectron
? window.location.pathname.split("/dist-electron/www/")[1] || "/" ? window.location.pathname.split('/dist-electron/www/')[1] || '/'
: window.location.pathname; : window.location.pathname
const history = isElectron const history = isElectron
? createMemoryHistory() // Memory history for Electron ? createMemoryHistory() // Memory history for Electron
: createWebHistory("/"); // Add base path for web apps : createWebHistory('/') // Add base path for web apps
/** @type {*} */ /** @type {*} */
const router = createRouter({ const router = createRouter({
history, history,
routes, routes
}); })
// Replace initial URL to start at `/` if necessary // Replace initial URL to start at `/` if necessary
router.replace(initialPath || "/"); router.replace(initialPath || '/')
const errorHandler = ( const errorHandler = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any, error: any,
to: RouteLocationNormalized, to: RouteLocationNormalized,
from: RouteLocationNormalized, from: RouteLocationNormalized
) => { ) => {
// Handle the error here // Handle the error here
logger.error("Caught in top level error handler:", error, to, from); logger.error('Caught in top level error handler:', error, to, from)
alert("Something is very wrong. Try reloading or restarting the app."); alert('Something is very wrong. Try reloading or restarting the app.')
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page // You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
}; }
router.onError(errorHandler); // Assign the error handler to the router instance router.onError(errorHandler) // Assign the error handler to the router instance
// router.beforeEach((to, from, next) => { // router.beforeEach((to, from, next) => {
// console.log("Navigating to view:", to.name); // console.log("Navigating to view:", to.name);
@ -336,4 +336,4 @@ router.onError(errorHandler); // Assign the error handler to the router instance
// next(); // next();
// }); // });
export default router; export default router

38
src/services/PlatformService.ts

@ -4,9 +4,9 @@
*/ */
export interface ImageResult { export interface ImageResult {
/** The image data as a Blob object */ /** The image data as a Blob object */
blob: Blob; blob: Blob
/** The filename associated with the image */ /** The filename associated with the image */
fileName: string; fileName: string
} }
/** /**
@ -15,17 +15,17 @@ export interface ImageResult {
*/ */
export interface PlatformCapabilities { export interface PlatformCapabilities {
/** Whether the platform supports native file system access */ /** Whether the platform supports native file system access */
hasFileSystem: boolean; hasFileSystem: boolean
/** Whether the platform supports native camera access */ /** Whether the platform supports native camera access */
hasCamera: boolean; hasCamera: boolean
/** Whether the platform is a mobile device */ /** Whether the platform is a mobile device */
isMobile: boolean; isMobile: boolean
/** Whether the platform is iOS specifically */ /** Whether the platform is iOS specifically */
isIOS: boolean; isIOS: boolean
/** Whether the platform supports native file download */ /** Whether the platform supports native file download */
hasFileDownload: boolean; hasFileDownload: boolean
/** Whether the platform requires special file handling instructions */ /** Whether the platform requires special file handling instructions */
needsFileHandlingInstructions: boolean; needsFileHandlingInstructions: boolean
} }
/** /**
@ -39,7 +39,7 @@ export interface PlatformService {
* Gets the current platform's capabilities * Gets the current platform's capabilities
* @returns Object describing what features are available on this platform * @returns Object describing what features are available on this platform
*/ */
getCapabilities(): PlatformCapabilities; getCapabilities(): PlatformCapabilities
// File system operations // File system operations
/** /**
@ -47,7 +47,7 @@ export interface PlatformService {
* @param path - The path to the file to read * @param path - The path to the file to read
* @returns Promise resolving to the file contents as a string * @returns Promise resolving to the file contents as a string
*/ */
readFile(path: string): Promise<string>; readFile(path: string): Promise<string>
/** /**
* Writes content to a file at the specified path. * Writes content to a file at the specified path.
@ -55,7 +55,7 @@ export interface PlatformService {
* @param content - The content to write to the file * @param content - The content to write to the file
* @returns Promise that resolves when the write is complete * @returns Promise that resolves when the write is complete
*/ */
writeFile(path: string, content: string): Promise<void>; writeFile(path: string, content: string): Promise<void>
/** /**
* Writes content to a file at the specified path and shares it. * Writes content to a file at the specified path and shares it.
@ -63,41 +63,41 @@ export interface PlatformService {
* @param content - The content to write to the file * @param content - The content to write to the file
* @returns Promise that resolves when the write is complete * @returns Promise that resolves when the write is complete
*/ */
writeAndShareFile(fileName: string, content: string): Promise<void>; writeAndShareFile(fileName: string, content: string): Promise<void>
/** /**
* Deletes a file at the specified path. * Deletes a file at the specified path.
* @param path - The path to the file to delete * @param path - The path to the file to delete
* @returns Promise that resolves when the deletion is complete * @returns Promise that resolves when the deletion is complete
*/ */
deleteFile(path: string): Promise<void>; deleteFile(path: string): Promise<void>
/** /**
* Lists all files in the specified directory. * Lists all files in the specified directory.
* @param directory - The directory path to list * @param directory - The directory path to list
* @returns Promise resolving to an array of filenames * @returns Promise resolving to an array of filenames
*/ */
listFiles(directory: string): Promise<string[]>; listFiles(directory: string): Promise<string[]>
// Camera operations // Camera operations
/** /**
* Activates the device camera to take a picture. * Activates the device camera to take a picture.
* @returns Promise resolving to the captured image result * @returns Promise resolving to the captured image result
*/ */
takePicture(): Promise<ImageResult>; takePicture(): Promise<ImageResult>
/** /**
* Opens a file picker to select an existing image. * Opens a file picker to select an existing image.
* @returns Promise resolving to the selected image result * @returns Promise resolving to the selected image result
*/ */
pickImage(): Promise<ImageResult>; pickImage(): Promise<ImageResult>
/** /**
* Handles deep link URLs for the application. * Handles deep link URLs for the application.
* @param url - The deep link URL to handle * @param url - The deep link URL to handle
* @returns Promise that resolves when the deep link has been handled * @returns Promise that resolves when the deep link has been handled
*/ */
handleDeepLink(url: string): Promise<void>; handleDeepLink(url: string): Promise<void>
// Clipboard operations // Clipboard operations
/** /**
@ -105,11 +105,11 @@ export interface PlatformService {
* @param text - The text to write to the clipboard * @param text - The text to write to the clipboard
* @returns Promise that resolves when the write is complete * @returns Promise that resolves when the write is complete
*/ */
writeToClipboard(text: string): Promise<void>; writeToClipboard(text: string): Promise<void>
/** /**
* Reads text from the system clipboard. * Reads text from the system clipboard.
* @returns Promise resolving to the clipboard text * @returns Promise resolving to the clipboard text
*/ */
readFromClipboard(): Promise<string>; readFromClipboard(): Promise<string>
} }

42
src/services/PlatformServiceFactory.ts

@ -1,8 +1,8 @@
import { PlatformService } from "./PlatformService"; import { PlatformService } from './PlatformService'
import { WebPlatformService } from "./platforms/WebPlatformService"; import { WebPlatformService } from './platforms/WebPlatformService'
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService"; import { CapacitorPlatformService } from './platforms/CapacitorPlatformService'
import { ElectronPlatformService } from "./platforms/ElectronPlatformService"; import { ElectronPlatformService } from './platforms/ElectronPlatformService'
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService"; import { PyWebViewPlatformService } from './platforms/PyWebViewPlatformService'
/** /**
* Factory class for creating platform-specific service implementations. * Factory class for creating platform-specific service implementations.
@ -22,7 +22,7 @@ import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService";
* ``` * ```
*/ */
export class PlatformServiceFactory { export class PlatformServiceFactory {
private static instance: PlatformService | null = null; private static instance: PlatformService | null = null
/** /**
* Gets or creates the singleton instance of PlatformService. * Gets or creates the singleton instance of PlatformService.
@ -32,27 +32,27 @@ export class PlatformServiceFactory {
*/ */
public static getInstance(): PlatformService { public static getInstance(): PlatformService {
if (PlatformServiceFactory.instance) { if (PlatformServiceFactory.instance) {
return PlatformServiceFactory.instance; return PlatformServiceFactory.instance
} }
const platform = process.env.VITE_PLATFORM || "web"; const platform = process.env.VITE_PLATFORM || 'web'
switch (platform) { switch (platform) {
case "capacitor": case 'capacitor':
PlatformServiceFactory.instance = new CapacitorPlatformService(); PlatformServiceFactory.instance = new CapacitorPlatformService()
break; break
case "electron": case 'electron':
PlatformServiceFactory.instance = new ElectronPlatformService(); PlatformServiceFactory.instance = new ElectronPlatformService()
break; break
case "pywebview": case 'pywebview':
PlatformServiceFactory.instance = new PyWebViewPlatformService(); PlatformServiceFactory.instance = new PyWebViewPlatformService()
break; break
case "web": case 'web':
default: default:
PlatformServiceFactory.instance = new WebPlatformService(); PlatformServiceFactory.instance = new WebPlatformService()
break; break
} }
return PlatformServiceFactory.instance; return PlatformServiceFactory.instance
} }
} }

118
src/services/QRScanner/CapacitorQRScanner.ts

@ -2,154 +2,154 @@ import {
BarcodeScanner, BarcodeScanner,
BarcodeFormat, BarcodeFormat,
LensFacing, LensFacing,
ScanResult, ScanResult
} from "@capacitor-mlkit/barcode-scanning"; } from '@capacitor-mlkit/barcode-scanning'
import type { QRScannerService, ScanListener } from "./types"; import type { QRScannerService, ScanListener } from './types'
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger'
export class CapacitorQRScanner implements QRScannerService { export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null; private scanListener: ScanListener | null = null
private isScanning = false; private isScanning = false
private listenerHandles: Array<() => Promise<void>> = []; private listenerHandles: Array<() => Promise<void>> = []
async checkPermissions() { async checkPermissions() {
try { try {
const { camera } = await BarcodeScanner.checkPermissions(); const { camera } = await BarcodeScanner.checkPermissions()
return camera === "granted"; return camera === 'granted'
} catch (error) { } catch (error) {
logger.error("Error checking camera permissions:", error); logger.error('Error checking camera permissions:', error)
return false; return false
} }
} }
async requestPermissions() { async requestPermissions() {
try { try {
const { camera } = await BarcodeScanner.requestPermissions(); const { camera } = await BarcodeScanner.requestPermissions()
return camera === "granted"; return camera === 'granted'
} catch (error) { } catch (error) {
logger.error("Error requesting camera permissions:", error); logger.error('Error requesting camera permissions:', error)
return false; return false
} }
} }
async isSupported() { async isSupported() {
try { try {
const { supported } = await BarcodeScanner.isSupported(); const { supported } = await BarcodeScanner.isSupported()
return supported; return supported
} catch (error) { } catch (error) {
logger.error("Error checking barcode scanner support:", error); logger.error('Error checking barcode scanner support:', error)
return false; return false
} }
} }
async startScan() { async startScan() {
if (this.isScanning) { if (this.isScanning) {
logger.warn("Scanner is already active"); logger.warn('Scanner is already active')
return; return
} }
try { try {
// First register listeners before starting scan // First register listeners before starting scan
await this.registerListeners(); await this.registerListeners()
this.isScanning = true; this.isScanning = true
await BarcodeScanner.startScan({ await BarcodeScanner.startScan({
formats: [BarcodeFormat.QrCode], formats: [BarcodeFormat.QrCode],
lensFacing: LensFacing.Back, lensFacing: LensFacing.Back
}); })
} catch (error) { } catch (error) {
// Ensure cleanup on error // Ensure cleanup on error
this.isScanning = false; this.isScanning = false
await this.removeListeners(); await this.removeListeners()
logger.error("Error starting barcode scan:", error); logger.error('Error starting barcode scan:', error)
throw error; throw error
} }
} }
async stopScan() { async stopScan() {
if (!this.isScanning) { if (!this.isScanning) {
return; return
} }
try { try {
// First stop the scan // First stop the scan
await BarcodeScanner.stopScan(); await BarcodeScanner.stopScan()
} catch (error) { } catch (error) {
logger.error("Error stopping barcode scan:", error); logger.error('Error stopping barcode scan:', error)
} finally { } finally {
// Always cleanup state even if stop fails // Always cleanup state even if stop fails
this.isScanning = false; this.isScanning = false
await this.removeListeners(); await this.removeListeners()
} }
} }
private async registerListeners() { private async registerListeners() {
// Clear any existing listeners first // Clear any existing listeners first
await this.removeListeners(); await this.removeListeners()
const scanHandle = await BarcodeScanner.addListener( const scanHandle = await BarcodeScanner.addListener(
"barcodesScanned", 'barcodesScanned',
(result: ScanResult) => { (result: ScanResult) => {
if (result.barcodes.length > 0) { if (result.barcodes.length > 0) {
const barcode = result.barcodes[0]; const barcode = result.barcodes[0]
if (barcode.rawValue && this.scanListener) { if (barcode.rawValue && this.scanListener) {
this.scanListener.onScan(barcode.rawValue); this.scanListener.onScan(barcode.rawValue)
} }
} }
}, }
); )
const errorHandle = await BarcodeScanner.addListener( const errorHandle = await BarcodeScanner.addListener(
"scanError", 'scanError',
(error) => { (error) => {
logger.error("Scan error:", error); logger.error('Scan error:', error)
if (this.scanListener?.onError) { if (this.scanListener?.onError) {
this.scanListener.onError( this.scanListener.onError(
new Error(error.message || "Unknown scan error"), new Error(error.message || 'Unknown scan error')
); )
}
} }
}, )
);
this.listenerHandles.push( this.listenerHandles.push(
async () => await scanHandle.remove(), async () => await scanHandle.remove(),
async () => await errorHandle.remove(), async () => await errorHandle.remove()
); )
} }
private async removeListeners() { private async removeListeners() {
try { try {
// Remove all registered listener handles // Remove all registered listener handles
await Promise.all(this.listenerHandles.map((handle) => handle())); await Promise.all(this.listenerHandles.map((handle) => handle()))
this.listenerHandles = []; this.listenerHandles = []
} catch (error) { } catch (error) {
logger.error("Error removing listeners:", error); logger.error('Error removing listeners:', error)
} }
} }
addListener(listener: ScanListener) { addListener(listener: ScanListener) {
if (this.scanListener) { if (this.scanListener) {
logger.warn("Scanner listener already exists, removing old listener"); logger.warn('Scanner listener already exists, removing old listener')
this.cleanup(); this.cleanup()
} }
this.scanListener = listener; this.scanListener = listener
} }
async cleanup() { async cleanup() {
try { try {
// Stop scan first if active // Stop scan first if active
if (this.isScanning) { if (this.isScanning) {
await this.stopScan(); await this.stopScan()
} }
// Remove listeners // Remove listeners
await this.removeListeners(); await this.removeListeners()
// Clear state // Clear state
this.scanListener = null; this.scanListener = null
this.isScanning = false; this.isScanning = false
} catch (error) { } catch (error) {
logger.error("Error during cleanup:", error); logger.error('Error during cleanup:', error)
} }
} }
} }

72
src/services/QRScanner/NativeQRScanner.ts

@ -1,88 +1,88 @@
import { import {
BarcodeScanner, BarcodeScanner,
BarcodeFormat, BarcodeFormat,
LensFacing, LensFacing
} from "@capacitor-mlkit/barcode-scanning"; } from '@capacitor-mlkit/barcode-scanning'
import type { PluginListenerHandle } from "@capacitor/core"; import type { PluginListenerHandle } from '@capacitor/core'
import { QRScannerService, ScanListener } from "./types"; import { QRScannerService, ScanListener } from './types'
export class NativeQRScanner implements QRScannerService { export class NativeQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null; private scanListener: ScanListener | null = null
private isScanning = false; private isScanning = false
private listenerHandle: PluginListenerHandle | null = null; private listenerHandle: PluginListenerHandle | null = null
async checkPermissions(): Promise<boolean> { async checkPermissions(): Promise<boolean> {
const { camera } = await BarcodeScanner.checkPermissions(); const { camera } = await BarcodeScanner.checkPermissions()
return camera === "granted"; return camera === 'granted'
} }
async requestPermissions(): Promise<boolean> { async requestPermissions(): Promise<boolean> {
const { camera } = await BarcodeScanner.requestPermissions(); const { camera } = await BarcodeScanner.requestPermissions()
return camera === "granted"; return camera === 'granted'
} }
async isSupported(): Promise<boolean> { async isSupported(): Promise<boolean> {
const { supported } = await BarcodeScanner.isSupported(); const { supported } = await BarcodeScanner.isSupported()
return supported; return supported
} }
async startScan(): Promise<void> { async startScan(): Promise<void> {
if (this.isScanning) { if (this.isScanning) {
throw new Error("Scanner is already running"); throw new Error('Scanner is already running')
} }
try { try {
this.isScanning = true; this.isScanning = true
await BarcodeScanner.startScan({ await BarcodeScanner.startScan({
formats: [BarcodeFormat.QrCode], formats: [BarcodeFormat.QrCode],
lensFacing: LensFacing.Back, lensFacing: LensFacing.Back
}); })
this.listenerHandle = await BarcodeScanner.addListener( this.listenerHandle = await BarcodeScanner.addListener(
"barcodesScanned", 'barcodesScanned',
async (result) => { async (result) => {
if (result.barcodes.length > 0 && this.scanListener) { if (result.barcodes.length > 0 && this.scanListener) {
const barcode = result.barcodes[0]; const barcode = result.barcodes[0]
this.scanListener.onScan(barcode.rawValue); this.scanListener.onScan(barcode.rawValue)
await this.stopScan(); await this.stopScan()
} }
}, }
); )
} catch (error) { } catch (error) {
this.isScanning = false; this.isScanning = false
if (this.scanListener?.onError) { if (this.scanListener?.onError) {
this.scanListener.onError(new Error(String(error))); this.scanListener.onError(new Error(String(error)))
} }
throw error; throw error
} }
} }
async stopScan(): Promise<void> { async stopScan(): Promise<void> {
if (!this.isScanning) { if (!this.isScanning) {
return; return
} }
try { try {
await BarcodeScanner.stopScan(); await BarcodeScanner.stopScan()
this.isScanning = false; this.isScanning = false
} catch (error) { } catch (error) {
if (this.scanListener?.onError) { if (this.scanListener?.onError) {
this.scanListener.onError(new Error(String(error))); this.scanListener.onError(new Error(String(error)))
} }
throw error; throw error
} }
} }
addListener(listener: ScanListener): void { addListener(listener: ScanListener): void {
this.scanListener = listener; this.scanListener = listener
} }
async cleanup(): Promise<void> { async cleanup(): Promise<void> {
await this.stopScan(); await this.stopScan()
if (this.listenerHandle) { if (this.listenerHandle) {
await this.listenerHandle.remove(); await this.listenerHandle.remove()
this.listenerHandle = null; this.listenerHandle = null
} }
this.scanListener = null; this.scanListener = null
} }
} }

37
src/services/QRScanner/QRScannerFactory.ts

@ -1,29 +1,38 @@
import { Capacitor } from "@capacitor/core"; import { Capacitor } from '@capacitor/core'
import { CapacitorQRScanner } from "./CapacitorQRScanner"; import { CapacitorQRScanner } from './CapacitorQRScanner'
import { WebQRScanner } from "./WebQRScanner"; import type { QRScannerService } from './types'
import type { QRScannerService } from "./types"; import { logger } from '../../utils/logger'
import { logger } from "../../utils/logger"; import { WebDialogQRScanner } from './WebDialogQRScanner'
// Import platform-specific flags from Vite config
declare const __USE_QR_READER__: boolean
declare const __IS_MOBILE__: boolean
export class QRScannerFactory { export class QRScannerFactory {
private static instance: QRScannerService | null = null; private static instance: QRScannerService | null = null
static getInstance(): QRScannerService { static getInstance(): QRScannerService {
if (!this.instance) { if (!this.instance) {
if (Capacitor.isNativePlatform()) { // Use platform-specific flags for more accurate detection
logger.log("Creating native QR scanner instance"); if (__IS_MOBILE__ || Capacitor.isNativePlatform()) {
this.instance = new CapacitorQRScanner(); logger.log('Creating native QR scanner instance')
this.instance = new CapacitorQRScanner()
} else if (__USE_QR_READER__) {
logger.log('Creating web QR scanner instance')
this.instance = new WebDialogQRScanner()
} else { } else {
logger.log("Creating web QR scanner instance"); throw new Error(
this.instance = new WebQRScanner(); 'No QR scanner implementation available for this platform'
)
} }
} }
return this.instance; return this.instance
} }
static async cleanup() { static async cleanup() {
if (this.instance) { if (this.instance) {
await this.instance.cleanup(); await this.instance.cleanup()
this.instance = null; this.instance = null
} }
} }
} }

90
src/services/QRScanner/WebDialogQRScanner.ts

@ -0,0 +1,90 @@
import type { QRScannerService, ScanListener } from './types'
import QRScannerDialog from '../../components/QRScannerDialog.vue'
import { createApp, type App } from 'vue'
import { logger } from '../../utils/logger'
// Import platform-specific flags from Vite config
declare const __USE_QR_READER__: boolean
export class WebDialogQRScanner implements QRScannerService {
private dialogInstance: App | null = null
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null
private scanListener: ScanListener | null = null
async checkPermissions(): Promise<boolean> {
try {
const permissions = await navigator.permissions.query({
name: 'camera' as PermissionName
})
return permissions.state === 'granted'
} catch (error) {
logger.error('Error checking camera permissions:', error)
return false
}
}
async requestPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
})
stream.getTracks().forEach((track) => track.stop())
return true
} catch (error) {
logger.error('Error requesting camera permissions:', error)
return false
}
}
async isSupported(): Promise<boolean> {
if (!__USE_QR_READER__) {
return false
}
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
}
async startScan(): Promise<void> {
if (!__USE_QR_READER__) {
throw new Error('Web QR scanner is not supported on this platform')
}
if (!this.dialogInstance) {
const div = document.createElement('div')
document.body.appendChild(div)
this.dialogInstance = createApp(QRScannerDialog)
this.dialogComponent = this.dialogInstance.mount(div) as InstanceType<
typeof QRScannerDialog
>
}
if (this.dialogComponent && this.scanListener) {
this.dialogComponent.open((result: string) => {
if (this.scanListener) {
this.scanListener.onScan(result)
}
})
}
}
async stopScan(): Promise<void> {
if (this.dialogComponent) {
this.dialogComponent.close()
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener
}
async cleanup(): Promise<void> {
if (this.dialogComponent) {
this.dialogComponent.close()
}
if (this.dialogInstance) {
this.dialogInstance.unmount()
this.dialogInstance = null
this.dialogComponent = null
}
}
}

142
src/services/QRScanner/WebQRScanner.ts

@ -1,142 +0,0 @@
import jsQR from "jsqr";
import { QRScannerService, ScanListener } from "./types";
import { logger } from "../../utils/logger";
export class WebQRScanner implements QRScannerService {
private video: HTMLVideoElement | null = null;
private canvas: HTMLCanvasElement | null = null;
private context: CanvasRenderingContext2D | null = null;
private animationFrameId: number | null = null;
private scanListener: ScanListener | null = null;
private isScanning = false;
private mediaStream: MediaStream | null = null;
constructor() {
this.video = document.createElement("video");
this.canvas = document.createElement("canvas");
this.context = this.canvas.getContext("2d");
}
async checkPermissions(): Promise<boolean> {
try {
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
return permissions.state === "granted";
} catch (error) {
logger.error("Error checking camera permissions:", error);
return false;
}
}
async requestPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
stream.getTracks().forEach((track) => track.stop());
return true;
} catch (error) {
logger.error("Error requesting camera permissions:", error);
return false;
}
}
async isSupported(): Promise<boolean> {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
async startScan(): Promise<void> {
if (this.isScanning || !this.video || !this.canvas || !this.context) {
throw new Error("Scanner is already running or not properly initialized");
}
try {
this.mediaStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
this.video.srcObject = this.mediaStream;
this.video.setAttribute("playsinline", "true");
await this.video.play();
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
this.isScanning = true;
this.scanFrame();
} catch (error) {
logger.error("Error starting scan:", error);
throw error;
}
}
private scanFrame = () => {
if (
!this.isScanning ||
!this.video ||
!this.canvas ||
!this.context ||
!this.scanListener
) {
return;
}
if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) {
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
this.context.drawImage(
this.video,
0,
0,
this.canvas.width,
this.canvas.height,
);
const imageData = this.context.getImageData(
0,
0,
this.canvas.width,
this.canvas.height,
);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: "dontInvert",
});
if (code) {
this.scanListener.onScan(code.data);
}
}
this.animationFrameId = requestAnimationFrame(this.scanFrame);
};
async stopScan(): Promise<void> {
this.isScanning = false;
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
if (this.video) {
this.video.srcObject = null;
}
}
addListener(listener: ScanListener): void {
this.scanListener = listener;
}
async cleanup(): Promise<void> {
await this.stopScan();
this.scanListener = null;
this.video = null;
this.canvas = null;
this.context = null;
}
}

36
src/services/QRScanner/types.ts

@ -1,29 +1,29 @@
export interface ScanResult { export interface ScanResult {
rawValue: string; rawValue: string
} }
export interface QRScannerState { export interface QRScannerState {
isSupported: boolean; isSupported: boolean
granted: boolean; granted: boolean
denied: boolean; denied: boolean
isProcessing: boolean; isProcessing: boolean
processingStatus: string; processingStatus: string
processingDetails: string; processingDetails: string
error: string; error: string
status: string; status: string
} }
export interface ScanListener { export interface ScanListener {
onScan: (result: string) => void; onScan: (result: string) => void
onError?: (error: Error) => void; onError?: (error: Error) => void
} }
export interface QRScannerService { export interface QRScannerService {
checkPermissions(): Promise<boolean>; checkPermissions(): Promise<boolean>
requestPermissions(): Promise<boolean>; requestPermissions(): Promise<boolean>
isSupported(): Promise<boolean>; isSupported(): Promise<boolean>
startScan(): Promise<void>; startScan(): Promise<void>
stopScan(): Promise<void>; stopScan(): Promise<void>
addListener(listener: ScanListener): void; addListener(listener: ScanListener): void
cleanup(): Promise<void>; cleanup(): Promise<void>
} }

20
src/services/api.ts

@ -5,8 +5,8 @@
* @module api * @module api
*/ */
import { AxiosError } from "axios"; import { AxiosError } from 'axios'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
/** /**
* Handles API errors with platform-specific logging and error processing. * Handles API errors with platform-specific logging and error processing.
@ -36,7 +36,7 @@ import { logger } from "../utils/logger";
* ``` * ```
*/ */
export const handleApiError = (error: AxiosError, endpoint: string) => { export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") { if (process.env.VITE_PLATFORM === 'capacitor') {
logger.error(`[Capacitor API Error] ${endpoint}:`, { logger.error(`[Capacitor API Error] ${endpoint}:`, {
message: error.message, message: error.message,
status: error.response?.status, status: error.response?.status,
@ -44,16 +44,16 @@ export const handleApiError = (error: AxiosError, endpoint: string) => {
config: { config: {
url: error.config?.url, url: error.config?.url,
method: error.config?.method, method: error.config?.method,
headers: error.config?.headers, headers: error.config?.headers
}, }
}); })
} }
// Specific handling for rate limits // Specific handling for rate limits
if (error.response?.status === 400) { if (error.response?.status === 400) {
logger.warn(`[Rate Limit] ${endpoint}`); logger.warn(`[Rate Limit] ${endpoint}`)
return null; return null
} }
throw error; throw error
}; }

136
src/services/deepLinks.ts

@ -45,29 +45,29 @@
* await handler.handleDeepLink("timesafari://claim/123?view=details"); * await handler.handleDeepLink("timesafari://claim/123?view=details");
*/ */
import { Router } from "vue-router"; import { Router } from 'vue-router'
import { import {
deepLinkSchemas, deepLinkSchemas,
baseUrlSchema, baseUrlSchema,
routeSchema, routeSchema,
DeepLinkRoute, DeepLinkRoute
} from "../types/deepLinks"; } from '../types/deepLinks'
import { logConsoleAndDb } from "../db"; import { logConsoleAndDb } from '../db'
import type { DeepLinkError } from "../interfaces/deepLinks"; import type { DeepLinkError } from '../interfaces/deepLinks'
/** /**
* Handles processing and routing of deep links in the application. * Handles processing and routing of deep links in the application.
* Provides validation, error handling, and routing for deep link URLs. * Provides validation, error handling, and routing for deep link URLs.
*/ */
export class DeepLinkHandler { export class DeepLinkHandler {
private router: Router; private router: Router
/** /**
* Creates a new DeepLinkHandler instance. * Creates a new DeepLinkHandler instance.
* @param router - Vue Router instance for navigation * @param router - Vue Router instance for navigation
*/ */
constructor(router: Router) { constructor(router: Router) {
this.router = router; this.router = router
} }
/** /**
@ -79,33 +79,33 @@ export class DeepLinkHandler {
* @returns Parsed URL components (path, params, query) * @returns Parsed URL components (path, params, query)
*/ */
private parseDeepLink(url: string) { private parseDeepLink(url: string) {
const parts = url.split("://"); const parts = url.split('://')
if (parts.length !== 2) { if (parts.length !== 2) {
throw { code: "INVALID_URL", message: "Invalid URL format" }; throw { code: 'INVALID_URL', message: 'Invalid URL format' }
} }
// Validate base URL structure // Validate base URL structure
baseUrlSchema.parse({ baseUrlSchema.parse({
scheme: parts[0], scheme: parts[0],
path: parts[1], path: parts[1],
queryParams: {}, // Will be populated below queryParams: {} // Will be populated below
}); })
const [path, queryString] = parts[1].split("?"); const [path, queryString] = parts[1].split('?')
const [routePath, param] = path.split("/"); const [routePath, param] = path.split('/')
const query: Record<string, string> = {}; const query: Record<string, string> = {}
if (queryString) { if (queryString) {
new URLSearchParams(queryString).forEach((value, key) => { new URLSearchParams(queryString).forEach((value, key) => {
query[key] = value; query[key] = value
}); })
} }
return { return {
path: routePath, path: routePath,
params: param ? { id: param } : {}, params: param ? { id: param } : {},
query, query
}; }
} }
/** /**
@ -117,25 +117,25 @@ export class DeepLinkHandler {
*/ */
async handleDeepLink(url: string): Promise<void> { async handleDeepLink(url: string): Promise<void> {
try { try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false); logConsoleAndDb('[DeepLink] Processing URL: ' + url, false)
const { path, params, query } = this.parseDeepLink(url); const { path, params, query } = this.parseDeepLink(url)
// Ensure params is always a Record<string,string> by converting undefined to empty string // Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries( const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]), Object.entries(params).map(([key, value]) => [key, value ?? ''])
); )
await this.validateAndRoute(path, sanitizedParams, query); await this.validateAndRoute(path, sanitizedParams, query)
} catch (error) { } catch (error) {
const deepLinkError = error as DeepLinkError; const deepLinkError = error as DeepLinkError
logConsoleAndDb( logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`, `[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true, true
); )
throw { throw {
code: deepLinkError.code || "UNKNOWN_ERROR", code: deepLinkError.code || 'UNKNOWN_ERROR',
message: deepLinkError.message, message: deepLinkError.message,
details: deepLinkError.details, details: deepLinkError.details
}; }
} }
} }
@ -151,80 +151,80 @@ export class DeepLinkHandler {
private async validateAndRoute( private async validateAndRoute(
path: string, path: string,
params: Record<string, string>, params: Record<string, string>,
query: Record<string, string>, query: Record<string, string>
): Promise<void> { ): Promise<void> {
const routeMap: Record<string, string> = { const routeMap: Record<string, string> = {
"user-profile": "user-profile", 'user-profile': 'user-profile',
"project-details": "project-details", 'project-details': 'project-details',
"onboard-meeting-setup": "onboard-meeting-setup", 'onboard-meeting-setup': 'onboard-meeting-setup',
"invite-one-accept": "invite-one-accept", 'invite-one-accept': 'invite-one-accept',
"contact-import": "contact-import", 'contact-import': 'contact-import',
"confirm-gift": "confirm-gift", 'confirm-gift': 'confirm-gift',
claim: "claim", claim: 'claim',
"claim-cert": "claim-cert", 'claim-cert': 'claim-cert',
"claim-add-raw": "claim-add-raw", 'claim-add-raw': 'claim-add-raw',
"contact-edit": "contact-edit", 'contact-edit': 'contact-edit',
contacts: "contacts", contacts: 'contacts',
did: "did", did: 'did'
}; }
// First try to validate the route path // First try to validate the route path
let routeName: string; let routeName: string
try { try {
// Validate route exists // Validate route exists
const validRoute = routeSchema.parse(path) as DeepLinkRoute; const validRoute = routeSchema.parse(path) as DeepLinkRoute
routeName = routeMap[validRoute]; routeName = routeMap[validRoute]
} catch (error) { } catch (error) {
// Log the invalid route attempt // Log the invalid route attempt
logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true); logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true)
// Redirect to error page with information about the invalid link // Redirect to error page with information about the invalid link
await this.router.replace({ await this.router.replace({
name: "deep-link-error", name: 'deep-link-error',
query: { query: {
originalPath: path, originalPath: path,
errorCode: "INVALID_ROUTE", errorCode: 'INVALID_ROUTE',
message: `The link you followed (${path}) is not supported`, message: `The link you followed (${path}) is not supported`
}, }
}); })
throw { throw {
code: "INVALID_ROUTE", code: 'INVALID_ROUTE',
message: `Unsupported route: ${path}`, message: `Unsupported route: ${path}`
}; }
} }
// Continue with parameter validation as before... // Continue with parameter validation as before...
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas]; const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas]
try { try {
const validatedParams = await schema.parseAsync({ const validatedParams = await schema.parseAsync({
...params, ...params,
...query, ...query
}); })
await this.router.replace({ await this.router.replace({
name: routeName, name: routeName,
params: validatedParams, params: validatedParams,
query, query
}); })
} catch (error) { } catch (error) {
// For parameter validation errors, provide specific error feedback // For parameter validation errors, provide specific error feedback
await this.router.replace({ await this.router.replace({
name: "deep-link-error", name: 'deep-link-error',
query: { query: {
originalPath: path, originalPath: path,
errorCode: "INVALID_PARAMETERS", errorCode: 'INVALID_PARAMETERS',
message: `The link parameters are invalid: ${(error as Error).message}`, message: `The link parameters are invalid: ${(error as Error).message}`
}, }
}); })
throw { throw {
code: "INVALID_PARAMETERS", code: 'INVALID_PARAMETERS',
message: (error as Error).message, message: (error as Error).message,
details: error, details: error
}; }
} }
} }
} }

68
src/services/plan.ts

@ -5,8 +5,8 @@
* @module plan * @module plan
*/ */
import axios from "axios"; import axios from 'axios'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
/** /**
* Response interface for plan loading operations. * Response interface for plan loading operations.
@ -14,13 +14,13 @@ import { logger } from "../utils/logger";
*/ */
interface PlanResponse { interface PlanResponse {
/** The response data payload */ /** The response data payload */
data?: unknown; data?: unknown
/** HTTP status code of the response */ /** HTTP status code of the response */
status?: number; status?: number
/** Error message in case of failure */ /** Error message in case of failure */
error?: string; error?: string
/** Response headers */ /** Response headers */
headers?: unknown; headers?: unknown
} }
/** /**
@ -52,23 +52,23 @@ interface PlanResponse {
*/ */
export const loadPlanWithRetry = async ( export const loadPlanWithRetry = async (
handle: string, handle: string,
retries = 3, retries = 3
): Promise<PlanResponse> => { ): Promise<PlanResponse> => {
try { try {
logger.log(`[Plan Service] Loading plan ${handle}, attempt 1/${retries}`); logger.log(`[Plan Service] Loading plan ${handle}, attempt 1/${retries}`)
logger.log( logger.log(
`[Plan Service] Context: Deep link handle=${handle}, isClaimFlow=${handle.includes("claim")}`, `[Plan Service] Context: Deep link handle=${handle}, isClaimFlow=${handle.includes('claim')}`
); )
// Different endpoint if this is a claim flow // Different endpoint if this is a claim flow
const response = await loadPlan(handle); const response = await loadPlan(handle)
logger.log(`[Plan Service] Plan ${handle} loaded successfully:`, { logger.log(`[Plan Service] Plan ${handle} loaded successfully:`, {
status: response?.status, status: response?.status,
headers: response?.headers, headers: response?.headers,
data: response?.data, data: response?.data
}); })
return response; return response
} catch (error: unknown) { } catch (error: unknown) {
logger.error(`[Plan Service] Error loading plan ${handle}:`, { logger.error(`[Plan Service] Error loading plan ${handle}:`, {
message: (error as Error).message, message: (error as Error).message,
@ -82,24 +82,24 @@ export const loadPlanWithRetry = async (
url: (error as { config?: { url?: string } })?.config?.url, url: (error as { config?: { url?: string } })?.config?.url,
method: (error as { config?: { method?: string } })?.config?.method, method: (error as { config?: { method?: string } })?.config?.method,
baseURL: (error as { config?: { baseURL?: string } })?.config?.baseURL, baseURL: (error as { config?: { baseURL?: string } })?.config?.baseURL,
headers: (error as { config?: { headers?: unknown } })?.config?.headers, headers: (error as { config?: { headers?: unknown } })?.config?.headers
}, }
}); })
if (retries > 1) { if (retries > 1) {
logger.log( logger.log(
`[Plan Service] Retrying plan ${handle}, ${retries - 1} attempts remaining`, `[Plan Service] Retrying plan ${handle}, ${retries - 1} attempts remaining`
); )
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000))
return loadPlanWithRetry(handle, retries - 1); return loadPlanWithRetry(handle, retries - 1)
} }
return { return {
error: `Failed to load plan ${handle} after ${4 - retries} attempts: ${(error as Error).message}`, error: `Failed to load plan ${handle} after ${4 - retries} attempts: ${(error as Error).message}`,
status: (error as { response?: { status?: number } })?.response?.status, status: (error as { response?: { status?: number } })?.response?.status
}; }
} }
}; }
/** /**
* Makes a single API request to load a plan or claim. * Makes a single API request to load a plan or claim.
@ -118,23 +118,23 @@ export const loadPlanWithRetry = async (
* - Plans: /api/plans/{handle} * - Plans: /api/plans/{handle}
*/ */
export const loadPlan = async (handle: string): Promise<PlanResponse> => { export const loadPlan = async (handle: string): Promise<PlanResponse> => {
logger.log(`[Plan Service] Making API request for plan ${handle}`); logger.log(`[Plan Service] Making API request for plan ${handle}`)
const endpoint = handle.includes("claim") const endpoint = handle.includes('claim')
? `/api/claims/${handle}` ? `/api/claims/${handle}`
: `/api/plans/${handle}`; : `/api/plans/${handle}`
logger.log(`[Plan Service] Using endpoint: ${endpoint}`); logger.log(`[Plan Service] Using endpoint: ${endpoint}`)
try { try {
const response = await axios.get(endpoint); const response = await axios.get(endpoint)
return response; return response
} catch (error: unknown) { } catch (error: unknown) {
logger.error(`[Plan Service] API request failed for ${handle}:`, { logger.error(`[Plan Service] API request failed for ${handle}:`, {
endpoint, endpoint,
error: (error as Error).message, error: (error as Error).message,
response: (error as { response?: { data?: unknown } })?.response?.data, response: (error as { response?: { data?: unknown } })?.response?.data
}); })
throw error; throw error
} }
}; }

348
src/services/platforms/CapacitorPlatformService.ts

@ -1,13 +1,13 @@
import { import {
ImageResult, ImageResult,
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities
} from "../PlatformService"; } from '../PlatformService'
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; import { Filesystem, Directory, Encoding } from '@capacitor/filesystem'
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'
import { Share } from "@capacitor/share"; import { Share } from '@capacitor/share'
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger'
import { Clipboard } from "@capacitor/clipboard"; import { Clipboard } from '@capacitor/clipboard'
/** /**
* Platform service implementation for Capacitor (mobile) platform. * Platform service implementation for Capacitor (mobile) platform.
@ -28,8 +28,8 @@ export class CapacitorPlatformService implements PlatformService {
isMobile: true, isMobile: true,
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: false, hasFileDownload: false,
needsFileHandlingInstructions: true, needsFileHandlingInstructions: true
}; }
} }
/** /**
@ -40,102 +40,102 @@ export class CapacitorPlatformService implements PlatformService {
private async checkStoragePermissions(): Promise<void> { private async checkStoragePermissions(): Promise<void> {
try { try {
const logData = { const logData = {
platform: this.getCapabilities().isIOS ? "iOS" : "Android", platform: this.getCapabilities().isIOS ? 'iOS' : 'Android',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
logger.log( logger.log(
"Checking storage permissions", 'Checking storage permissions',
JSON.stringify(logData, null, 2), JSON.stringify(logData, null, 2)
); )
if (this.getCapabilities().isIOS) { if (this.getCapabilities().isIOS) {
// iOS uses different permission model // iOS uses different permission model
return; return
} }
// Try to access a test directory to check permissions // Try to access a test directory to check permissions
try { try {
await Filesystem.stat({ await Filesystem.stat({
path: "/storage/emulated/0/Download", path: '/storage/emulated/0/Download',
directory: Directory.Documents, directory: Directory.Documents
}); })
logger.log( logger.log(
"Storage permissions already granted", 'Storage permissions already granted',
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), JSON.stringify({ timestamp: new Date().toISOString() }, null, 2)
); )
return; return
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error
const errorLogData = { const errorLogData = {
error: { error: {
message: err.message, message: err.message,
name: err.name, name: err.name,
stack: err.stack, stack: err.stack
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
// "File does not exist" is expected and not a permission error // "File does not exist" is expected and not a permission error
if (err.message === "File does not exist") { if (err.message === 'File does not exist') {
logger.log( logger.log(
"Directory does not exist (expected), proceeding with write", 'Directory does not exist (expected), proceeding with write',
JSON.stringify(errorLogData, null, 2), JSON.stringify(errorLogData, null, 2)
); )
return; return
} }
// Check for actual permission errors // Check for actual permission errors
if ( if (
err.message.includes("permission") || err.message.includes('permission') ||
err.message.includes("access") err.message.includes('access')
) { ) {
logger.log( logger.log(
"Permission check failed, requesting permissions", 'Permission check failed, requesting permissions',
JSON.stringify(errorLogData, null, 2), JSON.stringify(errorLogData, null, 2)
); )
// The Filesystem plugin will automatically request permissions when needed // The Filesystem plugin will automatically request permissions when needed
// We just need to try the operation again // We just need to try the operation again
try { try {
await Filesystem.stat({ await Filesystem.stat({
path: "/storage/emulated/0/Download", path: '/storage/emulated/0/Download',
directory: Directory.Documents, directory: Directory.Documents
}); })
logger.log( logger.log(
"Storage permissions granted after request", 'Storage permissions granted after request',
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), JSON.stringify({ timestamp: new Date().toISOString() }, null, 2)
); )
return; return
} catch (retryError: unknown) { } catch (retryError: unknown) {
const retryErr = retryError as Error; const retryErr = retryError as Error
throw new Error( throw new Error(
`Failed to obtain storage permissions: ${retryErr.message}`, `Failed to obtain storage permissions: ${retryErr.message}`
); )
} }
} }
// For any other error, log it but don't treat as permission error // For any other error, log it but don't treat as permission error
logger.log( logger.log(
"Unexpected error during permission check", 'Unexpected error during permission check',
JSON.stringify(errorLogData, null, 2), JSON.stringify(errorLogData, null, 2)
); )
return; return
} }
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error
const errorLogData = { const errorLogData = {
error: { error: {
message: err.message, message: err.message,
name: err.name, name: err.name,
stack: err.stack, stack: err.stack
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
logger.error( logger.error(
"Error checking/requesting permissions", 'Error checking/requesting permissions',
JSON.stringify(errorLogData, null, 2), JSON.stringify(errorLogData, null, 2)
); )
throw new Error(`Failed to obtain storage permissions: ${err.message}`); throw new Error(`Failed to obtain storage permissions: ${err.message}`)
} }
} }
@ -148,12 +148,12 @@ export class CapacitorPlatformService implements PlatformService {
async readFile(path: string): Promise<string> { async readFile(path: string): Promise<string> {
const file = await Filesystem.readFile({ const file = await Filesystem.readFile({
path, path,
directory: Directory.Data, directory: Directory.Data
}); })
if (file.data instanceof Blob) { if (file.data instanceof Blob) {
return await file.data.text(); return await file.data.text()
} }
return file.data; return file.data
} }
/** /**
@ -189,13 +189,13 @@ export class CapacitorPlatformService implements PlatformService {
const logData = { const logData = {
targetFileName: fileName, targetFileName: fileName,
contentLength: content.length, contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android", platform: this.getCapabilities().isIOS ? 'iOS' : 'Android',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
logger.log( logger.log(
"Starting writeFile operation", 'Starting writeFile operation',
JSON.stringify(logData, null, 2), JSON.stringify(logData, null, 2)
); )
// For Android, we need to handle content URIs differently // For Android, we need to handle content URIs differently
if (this.getCapabilities().isIOS) { if (this.getCapabilities().isIOS) {
@ -204,44 +204,44 @@ export class CapacitorPlatformService implements PlatformService {
path: fileName, path: fileName,
data: content, data: content,
directory: Directory.Data, directory: Directory.Data,
encoding: Encoding.UTF8, encoding: Encoding.UTF8
}); })
const writeSuccessLogData = { const writeSuccessLogData = {
path: writeResult.uri, path: writeResult.uri,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
logger.log( logger.log(
"File write successful", 'File write successful',
JSON.stringify(writeSuccessLogData, null, 2), JSON.stringify(writeSuccessLogData, null, 2)
); )
// Offer to share the file // Offer to share the file
try { try {
await Share.share({ await Share.share({
title: "TimeSafari Backup", title: 'TimeSafari Backup',
text: "Here is your TimeSafari backup file.", text: 'Here is your TimeSafari backup file.',
url: writeResult.uri, url: writeResult.uri,
dialogTitle: "Share your backup", dialogTitle: 'Share your backup'
}); })
logger.log( logger.log(
"Share dialog shown", 'Share dialog shown',
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), JSON.stringify({ timestamp: new Date().toISOString() }, null, 2)
); )
} catch (shareError) { } catch (shareError) {
// Log share error but don't fail the operation // Log share error but don't fail the operation
logger.error( logger.error(
"Share dialog failed", 'Share dialog failed',
JSON.stringify( JSON.stringify(
{ {
error: shareError, error: shareError,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}, },
null, null,
2, 2
), )
); )
} }
} else { } else {
// For Android, first write to app's Documents directory // For Android, first write to app's Documents directory
@ -249,61 +249,61 @@ export class CapacitorPlatformService implements PlatformService {
path: fileName, path: fileName,
data: content, data: content,
directory: Directory.Data, directory: Directory.Data,
encoding: Encoding.UTF8, encoding: Encoding.UTF8
}); })
const writeSuccessLogData = { const writeSuccessLogData = {
path: writeResult.uri, path: writeResult.uri,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
logger.log( logger.log(
"File write successful to app storage", 'File write successful to app storage',
JSON.stringify(writeSuccessLogData, null, 2), JSON.stringify(writeSuccessLogData, null, 2)
); )
// Then share the file to let user choose where to save it // Then share the file to let user choose where to save it
try { try {
await Share.share({ await Share.share({
title: "TimeSafari Backup", title: 'TimeSafari Backup',
text: "Here is your TimeSafari backup file.", text: 'Here is your TimeSafari backup file.',
url: writeResult.uri, url: writeResult.uri,
dialogTitle: "Save your backup", dialogTitle: 'Save your backup'
}); })
logger.log( logger.log(
"Share dialog shown for Android", 'Share dialog shown for Android',
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), JSON.stringify({ timestamp: new Date().toISOString() }, null, 2)
); )
} catch (shareError) { } catch (shareError) {
// Log share error but don't fail the operation // Log share error but don't fail the operation
logger.error( logger.error(
"Share dialog failed for Android", 'Share dialog failed for Android',
JSON.stringify( JSON.stringify(
{ {
error: shareError, error: shareError,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}, },
null, null,
2, 2
), )
); )
} }
} }
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error
const finalErrorLogData = { const finalErrorLogData = {
error: { error: {
message: err.message, message: err.message,
name: err.name, name: err.name,
stack: err.stack, stack: err.stack
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
logger.error( logger.error(
"Error in writeFile operation:", 'Error in writeFile operation:',
JSON.stringify(finalErrorLogData, null, 2), JSON.stringify(finalErrorLogData, null, 2)
); )
throw new Error(`Failed to save file: ${err.message}`); throw new Error(`Failed to save file: ${err.message}`)
} }
} }
@ -317,14 +317,14 @@ export class CapacitorPlatformService implements PlatformService {
* @param content - The content to write to the file * @param content - The content to write to the file
*/ */
async writeAndShareFile(fileName: string, content: string): Promise<void> { async writeAndShareFile(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString()
const logData = { const logData = {
action: "writeAndShareFile", action: 'writeAndShareFile',
fileName, fileName,
contentLength: content.length, contentLength: content.length,
timestamp, timestamp
}; }
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); logger.log('[CapacitorPlatformService]', JSON.stringify(logData, null, 2))
try { try {
const { uri } = await Filesystem.writeFile({ const { uri } = await Filesystem.writeFile({
@ -332,32 +332,32 @@ export class CapacitorPlatformService implements PlatformService {
data: content, data: content,
directory: Directory.Data, directory: Directory.Data,
encoding: Encoding.UTF8, encoding: Encoding.UTF8,
recursive: true, recursive: true
}); })
logger.log("[CapacitorPlatformService] File write successful:", { logger.log('[CapacitorPlatformService] File write successful:', {
uri, uri,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}); })
await Share.share({ await Share.share({
title: "TimeSafari Backup", title: 'TimeSafari Backup',
text: "Here is your backup file.", text: 'Here is your backup file.',
url: uri, url: uri,
dialogTitle: "Share your backup file", dialogTitle: 'Share your backup file'
}); })
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error
const errLog = { const errLog = {
message: err.message, message: err.message,
stack: err.stack, stack: err.stack,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString()
}; }
logger.error( logger.error(
"[CapacitorPlatformService] Error writing or sharing file:", '[CapacitorPlatformService] Error writing or sharing file:',
JSON.stringify(errLog, null, 2), JSON.stringify(errLog, null, 2)
); )
throw new Error(`Failed to write or share file: ${err.message}`); throw new Error(`Failed to write or share file: ${err.message}`)
} }
} }
@ -369,8 +369,8 @@ export class CapacitorPlatformService implements PlatformService {
async deleteFile(path: string): Promise<void> { async deleteFile(path: string): Promise<void> {
await Filesystem.deleteFile({ await Filesystem.deleteFile({
path, path,
directory: Directory.Data, directory: Directory.Data
}); })
} }
/** /**
@ -382,11 +382,11 @@ export class CapacitorPlatformService implements PlatformService {
async listFiles(directory: string): Promise<string[]> { async listFiles(directory: string): Promise<string[]> {
const result = await Filesystem.readdir({ const result = await Filesystem.readdir({
path: directory, path: directory,
directory: Directory.Data, directory: Directory.Data
}); })
return result.files.map((file) => return result.files.map((file) =>
typeof file === "string" ? file : file.name, typeof file === 'string' ? file : file.name
); )
} }
/** /**
@ -401,17 +401,17 @@ export class CapacitorPlatformService implements PlatformService {
quality: 90, quality: 90,
allowEditing: true, allowEditing: true,
resultType: CameraResultType.Base64, resultType: CameraResultType.Base64,
source: CameraSource.Camera, source: CameraSource.Camera
}); })
const blob = await this.processImageData(image.base64String); const blob = await this.processImageData(image.base64String)
return { return {
blob, blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, fileName: `photo_${Date.now()}.${image.format || 'jpg'}`
}; }
} catch (error) { } catch (error) {
logger.error("Error taking picture with Capacitor:", error); logger.error('Error taking picture with Capacitor:', error)
throw new Error("Failed to take picture"); throw new Error('Failed to take picture')
} }
} }
@ -427,17 +427,17 @@ export class CapacitorPlatformService implements PlatformService {
quality: 90, quality: 90,
allowEditing: true, allowEditing: true,
resultType: CameraResultType.Base64, resultType: CameraResultType.Base64,
source: CameraSource.Photos, source: CameraSource.Photos
}); })
const blob = await this.processImageData(image.base64String); const blob = await this.processImageData(image.base64String)
return { return {
blob, blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, fileName: `photo_${Date.now()}.${image.format || 'jpg'}`
}; }
} catch (error) { } catch (error) {
logger.error("Error picking image with Capacitor:", error); logger.error('Error picking image with Capacitor:', error)
throw new Error("Failed to pick image"); throw new Error('Failed to pick image')
} }
} }
@ -449,22 +449,22 @@ export class CapacitorPlatformService implements PlatformService {
*/ */
private async processImageData(base64String?: string): Promise<Blob> { private async processImageData(base64String?: string): Promise<Blob> {
if (!base64String) { if (!base64String) {
throw new Error("No image data received"); throw new Error('No image data received')
} }
// Convert base64 to blob // Convert base64 to blob
const byteCharacters = atob(base64String); const byteCharacters = atob(base64String)
const byteArrays = []; const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) { for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512); const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length); const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) { for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i); byteNumbers[i] = slice.charCodeAt(i)
} }
const byteArray = new Uint8Array(byteNumbers); const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray); byteArrays.push(byteArray)
} }
return new Blob(byteArrays, { type: "image/jpeg" }); return new Blob(byteArrays, { type: 'image/jpeg' })
} }
/** /**
@ -475,7 +475,7 @@ export class CapacitorPlatformService implements PlatformService {
async handleDeepLink(_url: string): Promise<void> { async handleDeepLink(_url: string): Promise<void> {
// Capacitor handles deep links automatically // Capacitor handles deep links automatically
// This is just a placeholder for the interface // This is just a placeholder for the interface
return Promise.resolve(); return Promise.resolve()
} }
/** /**
@ -486,11 +486,11 @@ export class CapacitorPlatformService implements PlatformService {
async writeToClipboard(text: string): Promise<void> { async writeToClipboard(text: string): Promise<void> {
try { try {
await Clipboard.write({ await Clipboard.write({
string: text, string: text
}); })
} catch (error) { } catch (error) {
logger.error("Error writing to clipboard:", error); logger.error('Error writing to clipboard:', error)
throw new Error("Failed to write to clipboard"); throw new Error('Failed to write to clipboard')
} }
} }
@ -500,11 +500,11 @@ export class CapacitorPlatformService implements PlatformService {
*/ */
async readFromClipboard(): Promise<string> { async readFromClipboard(): Promise<string> {
try { try {
const { value } = await Clipboard.read(); const { value } = await Clipboard.read()
return value; return value
} catch (error) { } catch (error) {
logger.error("Error reading from clipboard:", error); logger.error('Error reading from clipboard:', error)
throw new Error("Failed to read from clipboard"); throw new Error('Failed to read from clipboard')
} }
} }
} }

30
src/services/platforms/ElectronPlatformService.ts

@ -1,9 +1,9 @@
import { import {
ImageResult, ImageResult,
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities
} from "../PlatformService"; } from '../PlatformService'
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger'
/** /**
* Platform service implementation for Electron (desktop) platform. * Platform service implementation for Electron (desktop) platform.
@ -29,8 +29,8 @@ export class ElectronPlatformService implements PlatformService {
isMobile: false, isMobile: false,
isIOS: false, isIOS: false,
hasFileDownload: false, // Not implemented yet hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false, needsFileHandlingInstructions: false
}; }
} }
/** /**
@ -41,7 +41,7 @@ export class ElectronPlatformService implements PlatformService {
* @todo Implement file reading using Electron's file system API * @todo Implement file reading using Electron's file system API
*/ */
async readFile(_path: string): Promise<string> { async readFile(_path: string): Promise<string> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -52,7 +52,7 @@ export class ElectronPlatformService implements PlatformService {
* @todo Implement file writing using Electron's file system API * @todo Implement file writing using Electron's file system API
*/ */
async writeFile(_path: string, _content: string): Promise<void> { async writeFile(_path: string, _content: string): Promise<void> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -62,7 +62,7 @@ export class ElectronPlatformService implements PlatformService {
* @todo Implement file deletion using Electron's file system API * @todo Implement file deletion using Electron's file system API
*/ */
async deleteFile(_path: string): Promise<void> { async deleteFile(_path: string): Promise<void> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -73,7 +73,7 @@ export class ElectronPlatformService implements PlatformService {
* @todo Implement directory listing using Electron's file system API * @todo Implement directory listing using Electron's file system API
*/ */
async listFiles(_directory: string): Promise<string[]> { async listFiles(_directory: string): Promise<string[]> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -83,8 +83,8 @@ export class ElectronPlatformService implements PlatformService {
* @todo Implement camera access using Electron's media APIs * @todo Implement camera access using Electron's media APIs
*/ */
async takePicture(): Promise<ImageResult> { async takePicture(): Promise<ImageResult> {
logger.error("takePicture not implemented in Electron platform"); logger.error('takePicture not implemented in Electron platform')
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -94,8 +94,8 @@ export class ElectronPlatformService implements PlatformService {
* @todo Implement file picker using Electron's dialog API * @todo Implement file picker using Electron's dialog API
*/ */
async pickImage(): Promise<ImageResult> { async pickImage(): Promise<ImageResult> {
logger.error("pickImage not implemented in Electron platform"); logger.error('pickImage not implemented in Electron platform')
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -105,7 +105,7 @@ export class ElectronPlatformService implements PlatformService {
* @todo Implement deep link handling using Electron's protocol handler * @todo Implement deep link handling using Electron's protocol handler
*/ */
async handleDeepLink(_url: string): Promise<void> { async handleDeepLink(_url: string): Promise<void> {
logger.error("handleDeepLink not implemented in Electron platform"); logger.error('handleDeepLink not implemented in Electron platform')
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
} }

30
src/services/platforms/PyWebViewPlatformService.ts

@ -1,9 +1,9 @@
import { import {
ImageResult, ImageResult,
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities
} from "../PlatformService"; } from '../PlatformService'
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger'
/** /**
* Platform service implementation for PyWebView platform. * Platform service implementation for PyWebView platform.
@ -30,8 +30,8 @@ export class PyWebViewPlatformService implements PlatformService {
isMobile: false, isMobile: false,
isIOS: false, isIOS: false,
hasFileDownload: false, // Not implemented yet hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false, needsFileHandlingInstructions: false
}; }
} }
/** /**
@ -42,7 +42,7 @@ export class PyWebViewPlatformService implements PlatformService {
* @todo Implement file reading through pywebview's Python-JavaScript bridge * @todo Implement file reading through pywebview's Python-JavaScript bridge
*/ */
async readFile(_path: string): Promise<string> { async readFile(_path: string): Promise<string> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -53,7 +53,7 @@ export class PyWebViewPlatformService implements PlatformService {
* @todo Implement file writing through pywebview's Python-JavaScript bridge * @todo Implement file writing through pywebview's Python-JavaScript bridge
*/ */
async writeFile(_path: string, _content: string): Promise<void> { async writeFile(_path: string, _content: string): Promise<void> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -63,7 +63,7 @@ export class PyWebViewPlatformService implements PlatformService {
* @todo Implement file deletion through pywebview's Python-JavaScript bridge * @todo Implement file deletion through pywebview's Python-JavaScript bridge
*/ */
async deleteFile(_path: string): Promise<void> { async deleteFile(_path: string): Promise<void> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -74,7 +74,7 @@ export class PyWebViewPlatformService implements PlatformService {
* @todo Implement directory listing through pywebview's Python-JavaScript bridge * @todo Implement directory listing through pywebview's Python-JavaScript bridge
*/ */
async listFiles(_directory: string): Promise<string[]> { async listFiles(_directory: string): Promise<string[]> {
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -84,8 +84,8 @@ export class PyWebViewPlatformService implements PlatformService {
* @todo Implement camera access using Python's camera libraries * @todo Implement camera access using Python's camera libraries
*/ */
async takePicture(): Promise<ImageResult> { async takePicture(): Promise<ImageResult> {
logger.error("takePicture not implemented in PyWebView platform"); logger.error('takePicture not implemented in PyWebView platform')
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -95,8 +95,8 @@ export class PyWebViewPlatformService implements PlatformService {
* @todo Implement file picker using pywebview's file dialog API * @todo Implement file picker using pywebview's file dialog API
*/ */
async pickImage(): Promise<ImageResult> { async pickImage(): Promise<ImageResult> {
logger.error("pickImage not implemented in PyWebView platform"); logger.error('pickImage not implemented in PyWebView platform')
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
/** /**
@ -106,7 +106,7 @@ export class PyWebViewPlatformService implements PlatformService {
* @todo Implement deep link handling using Python's URL handling capabilities * @todo Implement deep link handling using Python's URL handling capabilities
*/ */
async handleDeepLink(_url: string): Promise<void> { async handleDeepLink(_url: string): Promise<void> {
logger.error("handleDeepLink not implemented in PyWebView platform"); logger.error('handleDeepLink not implemented in PyWebView platform')
throw new Error("Not implemented"); throw new Error('Not implemented')
} }
} }

116
src/services/platforms/WebPlatformService.ts

@ -1,9 +1,9 @@
import { import {
ImageResult, ImageResult,
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities
} from "../PlatformService"; } from '../PlatformService'
import { logger } from "../../utils/logger"; import { logger } from '../../utils/logger'
/** /**
* Platform service implementation for web browser platform. * Platform service implementation for web browser platform.
@ -30,8 +30,8 @@ export class WebPlatformService implements PlatformService {
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent), isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: true, hasFileDownload: true,
needsFileHandlingInstructions: false, needsFileHandlingInstructions: false
}; }
} }
/** /**
@ -40,7 +40,7 @@ export class WebPlatformService implements PlatformService {
* @throws Error indicating file system access is not available * @throws Error indicating file system access is not available
*/ */
async readFile(_path: string): Promise<string> { async readFile(_path: string): Promise<string> {
throw new Error("File system access not available in web platform"); throw new Error('File system access not available in web platform')
} }
/** /**
@ -50,7 +50,7 @@ export class WebPlatformService implements PlatformService {
* @throws Error indicating file system access is not available * @throws Error indicating file system access is not available
*/ */
async writeFile(_path: string, _content: string): Promise<void> { async writeFile(_path: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform"); throw new Error('File system access not available in web platform')
} }
/** /**
@ -59,7 +59,7 @@ export class WebPlatformService implements PlatformService {
* @throws Error indicating file system access is not available * @throws Error indicating file system access is not available
*/ */
async deleteFile(_path: string): Promise<void> { async deleteFile(_path: string): Promise<void> {
throw new Error("File system access not available in web platform"); throw new Error('File system access not available in web platform')
} }
/** /**
@ -68,7 +68,7 @@ export class WebPlatformService implements PlatformService {
* @throws Error indicating file system access is not available * @throws Error indicating file system access is not available
*/ */
async listFiles(_directory: string): Promise<string[]> { async listFiles(_directory: string): Promise<string[]> {
throw new Error("File system access not available in web platform"); throw new Error('File system access not available in web platform')
} }
/** /**
@ -85,31 +85,31 @@ export class WebPlatformService implements PlatformService {
*/ */
async takePicture(): Promise<ImageResult> { async takePicture(): Promise<ImageResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const input = document.createElement("input"); const input = document.createElement('input')
input.type = "file"; input.type = 'file'
input.accept = "image/*"; input.accept = 'image/*'
input.capture = "environment"; input.capture = 'environment'
input.onchange = async (e) => { input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0]
if (file) { if (file) {
try { try {
const blob = await this.processImageFile(file); const blob = await this.processImageFile(file)
resolve({ resolve({
blob, blob,
fileName: file.name || "photo.jpg", fileName: file.name || 'photo.jpg'
}); })
} catch (error) { } catch (error) {
logger.error("Error processing camera image:", error); logger.error('Error processing camera image:', error)
reject(new Error("Failed to process camera image")); reject(new Error('Failed to process camera image'))
} }
} else { } else {
reject(new Error("No image captured")); reject(new Error('No image captured'))
}
} }
};
input.click(); input.click()
}); })
} }
/** /**
@ -125,30 +125,30 @@ export class WebPlatformService implements PlatformService {
*/ */
async pickImage(): Promise<ImageResult> { async pickImage(): Promise<ImageResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const input = document.createElement("input"); const input = document.createElement('input')
input.type = "file"; input.type = 'file'
input.accept = "image/*"; input.accept = 'image/*'
input.onchange = async (e) => { input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0]
if (file) { if (file) {
try { try {
const blob = await this.processImageFile(file); const blob = await this.processImageFile(file)
resolve({ resolve({
blob, blob,
fileName: file.name || "photo.jpg", fileName: file.name || 'photo.jpg'
}); })
} catch (error) { } catch (error) {
logger.error("Error processing picked image:", error); logger.error('Error processing picked image:', error)
reject(new Error("Failed to process picked image")); reject(new Error('Failed to process picked image'))
} }
} else { } else {
reject(new Error("No image selected")); reject(new Error('No image selected'))
}
} }
};
input.click(); input.click()
}); })
} }
/** /**
@ -165,24 +165,24 @@ export class WebPlatformService implements PlatformService {
*/ */
private async processImageFile(file: File): Promise<Blob> { private async processImageFile(file: File): Promise<Blob> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader()
reader.onload = (event) => { reader.onload = (event) => {
const dataUrl = event.target?.result as string; const dataUrl = event.target?.result as string
// Convert to blob to ensure consistent format // Convert to blob to ensure consistent format
fetch(dataUrl) fetch(dataUrl)
.then((res) => res.blob()) .then((res) => res.blob())
.then((blob) => resolve(blob)) .then((blob) => resolve(blob))
.catch((error) => { .catch((error) => {
logger.error("Error converting data URL to blob:", error); logger.error('Error converting data URL to blob:', error)
reject(error); reject(error)
}); })
}; }
reader.onerror = (error) => { reader.onerror = (error) => {
logger.error("Error reading file:", error); logger.error('Error reading file:', error)
reject(error); reject(error)
}; }
reader.readAsDataURL(file); reader.readAsDataURL(file)
}); })
} }
/** /**
@ -190,7 +190,7 @@ export class WebPlatformService implements PlatformService {
* @returns false, as this is not Capacitor * @returns false, as this is not Capacitor
*/ */
isCapacitor(): boolean { isCapacitor(): boolean {
return false; return false
} }
/** /**
@ -198,7 +198,7 @@ export class WebPlatformService implements PlatformService {
* @returns false, as this is not Electron * @returns false, as this is not Electron
*/ */
isElectron(): boolean { isElectron(): boolean {
return false; return false
} }
/** /**
@ -206,7 +206,7 @@ export class WebPlatformService implements PlatformService {
* @returns false, as this is not PyWebView * @returns false, as this is not PyWebView
*/ */
isPyWebView(): boolean { isPyWebView(): boolean {
return false; return false
} }
/** /**
@ -214,7 +214,7 @@ export class WebPlatformService implements PlatformService {
* @returns true, as this is the web implementation * @returns true, as this is the web implementation
*/ */
isWeb(): boolean { isWeb(): boolean {
return true; return true
} }
/** /**
@ -226,7 +226,7 @@ export class WebPlatformService implements PlatformService {
*/ */
async handleDeepLink(_url: string): Promise<void> { async handleDeepLink(_url: string): Promise<void> {
// Web platform can handle deep links through URL parameters // Web platform can handle deep links through URL parameters
return Promise.resolve(); return Promise.resolve()
} }
/** /**
@ -236,10 +236,10 @@ export class WebPlatformService implements PlatformService {
*/ */
async writeToClipboard(text: string): Promise<void> { async writeToClipboard(text: string): Promise<void> {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text)
} catch (error) { } catch (error) {
logger.error("Error writing to clipboard:", error); logger.error('Error writing to clipboard:', error)
throw new Error("Failed to write to clipboard"); throw new Error('Failed to write to clipboard')
} }
} }
@ -249,10 +249,10 @@ export class WebPlatformService implements PlatformService {
*/ */
async readFromClipboard(): Promise<string> { async readFromClipboard(): Promise<string> {
try { try {
return await navigator.clipboard.readText(); return await navigator.clipboard.readText()
} catch (error) { } catch (error) {
logger.error("Error reading from clipboard:", error); logger.error('Error reading from clipboard:', error)
throw new Error("Failed to read from clipboard"); throw new Error('Failed to read from clipboard')
} }
} }
} }

64
src/test/index.ts

@ -1,62 +1,62 @@
import axios from "axios"; import axios from 'axios'
import * as didJwt from "did-jwt"; import * as didJwt from 'did-jwt'
import { AppString } from "../constants/app"; import { AppString } from '../constants/app'
import { retrieveSettingsForActiveAccount } from "../db"; import { retrieveSettingsForActiveAccount } from '../db'
import { SERVICE_ID } from "../libs/endorserServer"; import { SERVICE_ID } from '../libs/endorserServer'
import { deriveAddress, newIdentifier } from "../libs/crypto"; import { deriveAddress, newIdentifier } from '../libs/crypto'
import { logger } from "../utils/logger"; import { logger } from '../utils/logger'
/** /**
* Get User #0 to sign & submit a RegisterAction for the user's activeDid. * Get User #0 to sign & submit a RegisterAction for the user's activeDid.
*/ */
export async function testServerRegisterUser() { export async function testServerRegisterUser() {
const testUser0Mnem = const testUser0Mnem =
"seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control"; 'seminar accuse mystery assist delay law thing deal image undo guard initial shallow wrestle list fragile borrow velvet tomorrow awake explain test offer control'
const [addr, privateHex, publicHex, deriPath] = deriveAddress(testUser0Mnem); const [addr, privateHex, publicHex, deriPath] = deriveAddress(testUser0Mnem)
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath); const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath)
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount()
// Make a claim // Make a claim
const vcClaim = { const vcClaim = {
"@context": "https://schema.org", '@context': 'https://schema.org',
"@type": "RegisterAction", '@type': 'RegisterAction',
agent: { did: identity0.did }, agent: { did: identity0.did },
object: SERVICE_ID, object: SERVICE_ID,
participant: { did: settings.activeDid }, participant: { did: settings.activeDid }
}; }
// Make a payload for the claim // Make a payload for the claim
const vcPayload = { const vcPayload = {
sub: "RegisterAction", sub: 'RegisterAction',
vc: { vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"], '@context': ['https://www.w3.org/2018/credentials/v1'],
type: ["VerifiableCredential"], type: ['VerifiableCredential'],
credentialSubject: vcClaim, credentialSubject: vcClaim
}, }
}; }
// create a signature using private key of identity // create a signature using private key of identity
// eslint-disable-next-line // eslint-disable-next-line
const privateKeyHex: string = identity0.keys[0].privateKeyHex!; const privateKeyHex: string = identity0.keys[0].privateKeyHex!;
const signer = await didJwt.SimpleSigner(privateKeyHex); const signer = await didJwt.SimpleSigner(privateKeyHex)
const alg = undefined; const alg = undefined
// create a JWT for the request // create a JWT for the request
const vcJwt: string = await didJwt.createJWT(vcPayload, { const vcJwt: string = await didJwt.createJWT(vcPayload, {
alg: alg, alg: alg,
issuer: identity0.did, issuer: identity0.did,
signer: signer, signer: signer
}); })
// Make the xhr request payload // Make the xhr request payload
const payload = JSON.stringify({ jwtEncoded: vcJwt }); const payload = JSON.stringify({ jwtEncoded: vcJwt })
const endorserApiServer = const endorserApiServer =
settings.apiServer || AppString.TEST_ENDORSER_API_SERVER; settings.apiServer || AppString.TEST_ENDORSER_API_SERVER
const url = endorserApiServer + "/api/claim"; const url = endorserApiServer + '/api/claim'
const headers = { const headers = {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}; }
const resp = await axios.post(url, payload, { headers }); const resp = await axios.post(url, payload, { headers })
logger.log("User registration result:", resp); logger.log('User registration result:', resp)
} }

88
src/types/deepLinks.ts

@ -25,79 +25,79 @@
* // TypeScript knows params.id exists and params.view is optional * // TypeScript knows params.id exists and params.view is optional
* } * }
*/ */
import { z } from "zod"; import { z } from 'zod'
// Add a union type of all valid route paths // Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [ export const VALID_DEEP_LINK_ROUTES = [
"user-profile", 'user-profile',
"project-details", 'project-details',
"onboard-meeting-setup", 'onboard-meeting-setup',
"invite-one-accept", 'invite-one-accept',
"contact-import", 'contact-import',
"confirm-gift", 'confirm-gift',
"claim", 'claim',
"claim-cert", 'claim-cert',
"claim-add-raw", 'claim-add-raw',
"contact-edit", 'contact-edit',
"contacts", 'contacts',
"did", 'did'
] as const; ] as const
// Create a type from the array // Create a type from the array
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number]; export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number]
// Update your schema definitions to use this type // Update your schema definitions to use this type
export const baseUrlSchema = z.object({ export const baseUrlSchema = z.object({
scheme: z.literal("timesafari"), scheme: z.literal('timesafari'),
path: z.string(), path: z.string(),
queryParams: z.record(z.string()).optional(), queryParams: z.record(z.string()).optional()
}); })
// Use the type to ensure route validation // Use the type to ensure route validation
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES); export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES)
// Parameter validation schemas for each route type // Parameter validation schemas for each route type
export const deepLinkSchemas = { export const deepLinkSchemas = {
"user-profile": z.object({ 'user-profile': z.object({
id: z.string(), id: z.string()
}), }),
"project-details": z.object({ 'project-details': z.object({
id: z.string(), id: z.string()
}), }),
"onboard-meeting-setup": z.object({ 'onboard-meeting-setup': z.object({
id: z.string(), id: z.string()
}), }),
"invite-one-accept": z.object({ 'invite-one-accept': z.object({
id: z.string(), id: z.string()
}), }),
"contact-import": z.object({ 'contact-import': z.object({
jwt: z.string(), jwt: z.string()
}), }),
"confirm-gift": z.object({ 'confirm-gift': z.object({
id: z.string(), id: z.string()
}), }),
claim: z.object({ claim: z.object({
id: z.string(), id: z.string()
}), }),
"claim-cert": z.object({ 'claim-cert': z.object({
id: z.string(), id: z.string()
}), }),
"claim-add-raw": z.object({ 'claim-add-raw': z.object({
id: z.string(), id: z.string(),
claim: z.string().optional(), claim: z.string().optional(),
claimJwtId: z.string().optional(), claimJwtId: z.string().optional()
}), }),
"contact-edit": z.object({ 'contact-edit': z.object({
did: z.string(), did: z.string()
}), }),
contacts: z.object({ contacts: z.object({
contacts: z.string(), // JSON string of contacts array contacts: z.string() // JSON string of contacts array
}), }),
did: z.object({ did: z.object({
id: z.string(), id: z.string()
}), })
}; }
export type DeepLinkParams = { export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>; [K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>
}; }

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save