Compare commits
55 Commits
deep_linki
...
db-backup-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a6e5289ff | ||
|
|
6620311b7d | ||
|
|
6e2bdc69e9 | ||
|
|
b8c3517072 | ||
|
|
a0cf9ea721 | ||
|
|
42d706b1fb | ||
|
|
da6a5ee83e | ||
|
|
7af39d322f | ||
|
|
bab802160f | ||
|
|
01d7bc9e27 | ||
|
|
fa20360d87 | ||
|
|
0709d0c726 | ||
|
|
d943983bf8 | ||
| be9465e9f8 | |||
| 5606f2a18a | |||
|
|
06e9950e53 | ||
|
|
5143c65337 | ||
|
|
09ee94d5a3 | ||
| 071792b97c | |||
| bf2f23021f | |||
| 829870b16c | |||
|
|
44ffeebabe | ||
|
|
bed3bfa387 | ||
| b1056fc8dd | |||
| 189bfabcf8 | |||
|
|
aed1a9fea8 | ||
|
|
f71c76fcd3 | ||
|
|
d024db2258 | ||
|
|
c760385dcf | ||
|
|
8be8de5f1f | ||
|
|
b40604f8a6 | ||
|
|
2660b91995 | ||
|
|
474999dc9c | ||
| e825950e6e | |||
| a73d0a85e2 | |||
| fc01e81af7 | |||
|
|
436f40813c | ||
|
|
77b296b606 | ||
|
|
683e85f5be | ||
| e3ac5fe9fe | |||
|
|
5dbd66e51b | ||
|
|
312b4aaaa3 | ||
|
|
3a6a24d923 | ||
|
|
d7afb80a07 | ||
|
|
751df09fe5 | ||
|
|
8858495f73 | ||
|
|
ecb088bee2 | ||
|
|
8f7d794962 | ||
|
|
fa7d6317b9 | ||
|
|
4a75cdf20e | ||
|
|
79fdb9e570 | ||
|
|
aa09827317 | ||
|
|
cc1780bd01 | ||
|
|
e5d9c25ad4 | ||
|
|
ef8c2e6093 |
1
.env.electron
Normal file
1
.env.electron
Normal file
@@ -0,0 +1 @@
|
||||
PLATFORM=electron
|
||||
5
.env.mobile
Normal file
5
.env.mobile
Normal file
@@ -0,0 +1,5 @@
|
||||
PLATFORM=capacitor
|
||||
VITE_ENDORSER_API_URL=https://test-api.endorser.ch/api/v2/claim
|
||||
VITE_PARTNER_API_URL=https://test-api.partner.ch/api/v2
|
||||
VITE_IMAGE_API_URL=https://test-api.images.ch/api/v2
|
||||
VITE_PUSH_SERVER_URL=https://test-api.push.ch/api/v2
|
||||
@@ -26,6 +26,10 @@ module.exports = {
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off"
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
}]
|
||||
},
|
||||
};
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,7 +38,6 @@ pnpm-debug.log*
|
||||
/dist-capacitor/
|
||||
/test-playwright-results/
|
||||
playwright-tests
|
||||
test-playwright
|
||||
dist-electron-packages
|
||||
ios
|
||||
.ruby-version
|
||||
|
||||
76
BUILDING.md
76
BUILDING.md
@@ -4,6 +4,8 @@ This guide explains how to build TimeSafari for different platforms.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
For a quick dev environment setup, use [pkgx](https://pkgx.dev).
|
||||
|
||||
- Node.js (LTS version recommended)
|
||||
- npm (comes with Node.js)
|
||||
- Git
|
||||
@@ -11,6 +13,17 @@ This guide explains how to build TimeSafari for different platforms.
|
||||
- For Android builds: Android Studio with SDK installed
|
||||
- For desktop builds: Additional build tools based on your OS
|
||||
|
||||
## Forks
|
||||
|
||||
If you have forked this to make your own app, you'll want to customize the iOS & Android files. You can either edit existing ones, or you can remove the `ios` and `android` directories and regenerate them before the `npx cap sync` step in each setup.
|
||||
|
||||
```bash
|
||||
npx cap add android
|
||||
npx cap add ios
|
||||
```
|
||||
|
||||
You'll also want to edit the deep link configuration.
|
||||
|
||||
## Initial Setup
|
||||
|
||||
1. Clone the repository:
|
||||
@@ -114,6 +127,13 @@ Prerequisites: macOS with Xcode installed
|
||||
npx cap sync ios
|
||||
```
|
||||
|
||||
3. Copy the assets:
|
||||
|
||||
```bash
|
||||
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
3. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
@@ -122,16 +142,6 @@ Prerequisites: macOS with Xcode installed
|
||||
|
||||
4. Use Xcode to build and run on simulator or device.
|
||||
|
||||
If you have forked this to make your own app, you'll want to customize the ios files:
|
||||
|
||||
```bash
|
||||
rm -rf ios
|
||||
npx cap add ios
|
||||
```
|
||||
|
||||
... and then repeat the steps above.
|
||||
|
||||
|
||||
### Android Build
|
||||
|
||||
Prerequisites: Android Studio with SDK installed
|
||||
@@ -150,22 +160,19 @@ Prerequisites: Android Studio with SDK installed
|
||||
npx cap sync android
|
||||
```
|
||||
|
||||
3. Open the project in Android Studio:
|
||||
3. Copy the assets
|
||||
|
||||
```bash
|
||||
npx capacitor-assets generate --android
|
||||
```
|
||||
|
||||
4. Open the project in Android Studio:
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
3. Use Android Studio to build and run on emulator or device.
|
||||
|
||||
If you have forked this to make your own app, you'll want to customize the android files:
|
||||
|
||||
```bash
|
||||
rm -rf android
|
||||
npx cap add android
|
||||
```
|
||||
|
||||
... and then: repeat the steps above, and look below for the deep link configuration.
|
||||
5. Use Android Studio to build and run on emulator or device.
|
||||
|
||||
## Building Android from the console
|
||||
|
||||
@@ -177,6 +184,13 @@ If you have forked this to make your own app, you'll want to customize the andro
|
||||
npx cap run android
|
||||
```
|
||||
|
||||
... or, to create the `aab` file, `bundle` instead of `build`:
|
||||
|
||||
```bash
|
||||
./gradlew bundle -Dlint.baselines.continue=true
|
||||
```
|
||||
|
||||
|
||||
## Configuring Android for deep links
|
||||
|
||||
You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file:
|
||||
@@ -329,10 +343,10 @@ The packaged application will be in `dist/TimeSafari`
|
||||
|
||||
## Testing
|
||||
|
||||
Run local tests:
|
||||
Run all tests (requires XCode and Android Studio/device):
|
||||
|
||||
```bash
|
||||
npm run test-local
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
See [TESTING.md](test-playwright/TESTING.md) for more details.
|
||||
@@ -422,3 +436,19 @@ mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist
|
||||
- For iOS: Xcode command line tools must be installed
|
||||
- For Android: Correct SDK version must be installed
|
||||
- Check Capacitor configuration in capacitor.config.ts
|
||||
|
||||
|
||||
# List all installed packages
|
||||
adb shell pm list packages | grep timesafari
|
||||
|
||||
# Force stop the app (if it's running)
|
||||
adb shell am force-stop app.timesafari
|
||||
|
||||
# Clear app data (if you don't want to fully uninstall)
|
||||
adb shell pm clear app.timesafari
|
||||
|
||||
# Uninstall for all users
|
||||
adb shell pm uninstall -k --user 0 app.timesafari
|
||||
|
||||
# Check if app is installed
|
||||
adb shell pm path app.timesafari
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +1,2 @@
|
||||
#Tue Mar 11 10:01:05 UTC 2025
|
||||
gradle.version=8.10.2
|
||||
#Thu Apr 03 10:21:42 UTC 2025
|
||||
gradle.version=8.11.1
|
||||
|
||||
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
1
android/app/.gitignore
vendored
1
android/app/.gitignore
vendored
@@ -1,3 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
src/main/assets/public/assets/
|
||||
@@ -1,7 +1,7 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace "app.timesafari"
|
||||
namespace 'app.timesafari'
|
||||
compileSdk rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "app.timesafari"
|
||||
@@ -22,17 +22,6 @@ android {
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
lintOptions {
|
||||
disable 'UnsanitizedFilenameFromContentProvider'
|
||||
abortOnError false
|
||||
baseline file("lint-baseline.xml")
|
||||
|
||||
// Ignore Capacitor module issues
|
||||
ignore 'DefaultLocale'
|
||||
ignore 'UnsanitizedFilenameFromContentProvider'
|
||||
ignore 'LintBaseline'
|
||||
ignore 'LintBaselineFixed'
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
|
||||
@@ -10,6 +10,8 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-filesystem')
|
||||
implementation project(':capacitor-share')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.1.0" type="baseline" client="gradle" dependencies="true" name="AGP (8.1.0)" variant="all" version="8.1.0">
|
||||
|
||||
<issue
|
||||
id="UnknownIssueId"
|
||||
message="Unknown issue id "UnsanitizedFilenameFromContentProvider""
|
||||
errorLine1=" disable 'UnsanitizedFilenameFromContentProvider'"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="26"
|
||||
column="18"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnknownIssueId"
|
||||
message="Unknown issue id "UnsanitizedFilenameFromContentProvider""
|
||||
errorLine1=" disable 'UnsanitizedFilenameFromContentProvider'"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="26"
|
||||
column="18"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnknownIssueId"
|
||||
message="Unknown issue id "LintBaselineFixed""
|
||||
errorLine1=" ignore 'LintBaselineFixed'"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="34"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnknownIssueId"
|
||||
message="Unknown issue id "LintBaselineFixed""
|
||||
errorLine1=" ignore 'LintBaselineFixed'"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="34"
|
||||
column="17"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DefaultLocale"
|
||||
message="Implicitly using the default locale is a common source of bugs: Use `String.format(Locale, ...)` instead"
|
||||
errorLine1=" String msg = String.format("
|
||||
errorLine2=" ^">
|
||||
<location
|
||||
file="src/main/java/com/getcapacitor/BridgeWebChromeClient.java"
|
||||
line="467"
|
||||
column="26"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DefaultLocale"
|
||||
message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
|
||||
errorLine1=" return mask.toUpperCase().equals(string.toUpperCase());"
|
||||
errorLine2=" ~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/getcapacitor/util/HostMask.java"
|
||||
line="110"
|
||||
column="29"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DefaultLocale"
|
||||
message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
|
||||
errorLine1=" return mask.toUpperCase().equals(string.toUpperCase());"
|
||||
errorLine2=" ~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/getcapacitor/util/HostMask.java"
|
||||
line="110"
|
||||
column="57"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="DefaultLocale"
|
||||
message="Implicitly using the default locale is a common source of bugs: Use `toLowerCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`."
|
||||
errorLine1=" if (header.getKey().equalsIgnoreCase("Accept") && header.getValue().toLowerCase().contains("text/html")) {"
|
||||
errorLine2=" ~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/getcapacitor/WebViewLocalServer.java"
|
||||
line="474"
|
||||
column="93"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="SimpleDateFormat"
|
||||
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
|
||||
errorLine1=" String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/getcapacitor/BridgeWebChromeClient.java"
|
||||
line="504"
|
||||
column="28"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="SimpleDateFormat"
|
||||
message="To get local formatting use `getDateInstance()`, `getDateTimeInstance()`, or `getTimeInstance()`, or use `new SimpleDateFormat(String template, Locale locale)` with for example `Locale.US` for ASCII dates."
|
||||
errorLine1=" DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'");"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/getcapacitor/PluginResult.java"
|
||||
line="44"
|
||||
column="25"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `usesCleartextTraffic` is only used in API level 23 and higher (current min is 22)"
|
||||
errorLine1="<application android:usesCleartextTraffic="true">"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="4"
|
||||
column="15"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedAttribute"
|
||||
message="Attribute `autoVerify` is only used in API level 23 and higher (current min is 22)"
|
||||
errorLine1=" <intent-filter android:autoVerify="true">"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="25"
|
||||
column="28"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="ManifestOrder"
|
||||
message="`<uses-permission>` tag appears after `<application>` tag"
|
||||
errorLine1=" <uses-permission android:name="android.permission.INTERNET" />"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/AndroidManifest.xml"
|
||||
line="47"
|
||||
column="6"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="AndroidGradlePluginVersion"
|
||||
message="A newer version of com.android.tools.build:gradle than 8.2.1 is available: 8.9.0. (There is also a newer version of 8.2.𝑥 available, if upgrading to 8.9.0 is difficult: 8.2.2)"
|
||||
errorLine1=" classpath 'com.android.tools.build:gradle:8.2.1'"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="12"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="AndroidGradlePluginVersion"
|
||||
message="A newer version of com.android.tools.build:gradle than 8.2.1 is available: 8.9.0. (There is also a newer version of 8.2.𝑥 available, if upgrading to 8.9.0 is difficult: 8.2.2)"
|
||||
errorLine1=" classpath 'com.android.tools.build:gradle:8.2.1'"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="18"
|
||||
column="9"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.appcompat:appcompat than 1.6.1 is available: 1.7.0"
|
||||
errorLine1=" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="46"
|
||||
column="20"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.appcompat:appcompat than 1.6.1 is available: 1.7.0"
|
||||
errorLine1=" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="46"
|
||||
column="20"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.coordinatorlayout:coordinatorlayout than 1.2.0 is available: 1.3.0"
|
||||
errorLine1=" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="47"
|
||||
column="20"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.test.ext:junit than 1.1.5 is available: 1.2.1"
|
||||
errorLine1=" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="51"
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.test.espresso:espresso-core than 3.5.1 is available: 3.6.1"
|
||||
errorLine1=" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="52"
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.appcompat:appcompat than 1.6.1 is available: 1.7.0"
|
||||
errorLine1=" implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="75"
|
||||
column="20"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.test.ext:junit than 1.1.5 is available: 1.2.1"
|
||||
errorLine1=" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="77"
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="GradleDependency"
|
||||
message="A newer version of androidx.test.espresso:espresso-core than 3.5.1 is available: 3.6.1"
|
||||
errorLine1=" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="build.gradle"
|
||||
line="78"
|
||||
column="31"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Recycle"
|
||||
message="This `TypedArray` should be recycled after use with `#recycle()`"
|
||||
errorLine1=" TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.bridge_fragment);"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/java/com/getcapacitor/BridgeFragment.java"
|
||||
line="99"
|
||||
column="32"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="Overdraw"
|
||||
message="Possible overdraw: Root element paints background `#F0FF1414` with a theme that also paints a background (inferred theme is `@android:style/Theme.Holo`)"
|
||||
errorLine1=" android:background="#F0FF1414""
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/layout/fragment_bridge.xml"
|
||||
line="5"
|
||||
column="5"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.layout.activity_main` appears to be unused"
|
||||
errorLine1="<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/layout/activity_main.xml"
|
||||
line="2"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.xml.config` appears to be unused"
|
||||
errorLine1="<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">"
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/xml/config.xml"
|
||||
line="2"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.ic_launcher_background` appears to be unused"
|
||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable/ic_launcher_background.xml"
|
||||
line="2"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.drawable.ic_launcher_foreground` appears to be unused"
|
||||
errorLine1="<vector xmlns:android="http://schemas.android.com/apk/res/android""
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/drawable-v24/ic_launcher_foreground.xml"
|
||||
line="1"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.package_name` appears to be unused"
|
||||
errorLine1=" <string name="package_name">app.timesafari.app</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="5"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="UnusedResources"
|
||||
message="The resource `R.string.custom_url_scheme` appears to be unused"
|
||||
errorLine1=" <string name="custom_url_scheme">app.timesafari.app</string>"
|
||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||
<location
|
||||
file="src/main/res/values/strings.xml"
|
||||
line="6"
|
||||
column="13"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="MonochromeLauncherIcon"
|
||||
message="The application adaptive icon is missing a monochrome tag"
|
||||
errorLine1="<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">"
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/mipmap-anydpi-v26/ic_launcher.xml"
|
||||
line="2"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="MonochromeLauncherIcon"
|
||||
message="The application adaptive roundIcon is missing a monochrome tag"
|
||||
errorLine1="<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">"
|
||||
errorLine2="^">
|
||||
<location
|
||||
file="src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml"
|
||||
line="2"
|
||||
column="1"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IconDipSize"
|
||||
message="The image `splash.png` varies significantly in its density-independent (dip) size across the various density versions: drawable-land-hdpi/splash.png: 533x320 dp (800x480 px), drawable-land-mdpi/splash.png: 480x320 dp (480x320 px), drawable-land-xhdpi/splash.png: 640x360 dp (1280x720 px), drawable-land-xxhdpi/splash.png: 533x320 dp (1600x960 px), drawable-land-xxxhdpi/splash.png: 480x320 dp (1920x1280 px)">
|
||||
<location
|
||||
file="src/main/res/drawable-land-mdpi/splash.png"/>
|
||||
<location
|
||||
file="src/main/res/drawable-land-xxxhdpi/splash.png"/>
|
||||
<location
|
||||
file="src/main/res/drawable-land-hdpi/splash.png"/>
|
||||
<location
|
||||
file="src/main/res/drawable-land-xxhdpi/splash.png"/>
|
||||
<location
|
||||
file="src/main/res/drawable-land-xhdpi/splash.png"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IconDuplicatesConfig"
|
||||
message="The `splash.png` icon has identical contents in the following configuration folders: drawable-land-mdpi, drawable">
|
||||
<location
|
||||
file="src/main/res/drawable/splash.png"/>
|
||||
<location
|
||||
file="src/main/res/drawable-land-mdpi/splash.png"/>
|
||||
</issue>
|
||||
|
||||
<issue
|
||||
id="IconLocation"
|
||||
message="Found bitmap drawable `res/drawable/splash.png` in densityless folder">
|
||||
<location
|
||||
file="src/main/res/drawable/splash.png"/>
|
||||
</issue>
|
||||
|
||||
</issues>
|
||||
@@ -1,20 +1,26 @@
|
||||
package app.timesafari.app;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("app.timesafari", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,19 @@
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appId": "app.timesafari.app",
|
||||
"appId": "app.timesafari",
|
||||
"appName": "TimeSafari",
|
||||
"webDir": "dist",
|
||||
"bundledWebRuntime": false,
|
||||
|
||||
@@ -2,5 +2,13 @@
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/filesystem",
|
||||
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/share",
|
||||
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<title>TimeSafari</title>
|
||||
<script type="module" crossorigin src="/assets/index-CI0bMoT0.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-KPivi3wg.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package app.timesafari;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {
|
||||
// ... existing code ...
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package app.timesafari.app;
|
||||
package timesafari.app;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
<resources>
|
||||
<string name="app_name">TimeSafari</string>
|
||||
<string name="title_activity_main">TimeSafari</string>
|
||||
<string name="package_name">app.timesafari.app</string>
|
||||
<string name="custom_url_scheme">app.timesafari.app</string>
|
||||
<string name="package_name">timesafari.app</string>
|
||||
<string name="custom_url_scheme">timesafari.app</string>
|
||||
</resources>
|
||||
|
||||
@@ -7,9 +7,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
classpath 'com.android.tools.build:gradle:8.9.1'
|
||||
classpath 'com.google.gms:google-services:4.4.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
@@ -28,10 +27,3 @@ allprojects {
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
|
||||
force 'org.jetbrains.kotlin:kotlin-stdlib-common:1.8.0'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
android {
|
||||
lintOptions {
|
||||
disable 'UnsanitizedFilenameFromContentProvider'
|
||||
abortOnError false
|
||||
baseline file("lint-baseline.xml")
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-filesystem'
|
||||
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
json_key_file("") # Path to the json secret file - Follow https://docs.fastlane.tools/actions/supply/#setup to get one
|
||||
package_name("app.timesafari.app") # e.g. com.krausefx.app
|
||||
@@ -1,38 +0,0 @@
|
||||
# This file contains the fastlane.tools configuration
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
#
|
||||
# For a list of all available actions, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/actions
|
||||
#
|
||||
# For a list of all available plugins, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/plugins/available-plugins
|
||||
#
|
||||
|
||||
# Uncomment the line if you want fastlane to automatically update itself
|
||||
# update_fastlane
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
platform :android do
|
||||
desc "Build and deploy Android app"
|
||||
lane :beta do
|
||||
gradle(
|
||||
task: "clean assembleRelease"
|
||||
)
|
||||
upload_to_play_store(
|
||||
track: 'beta',
|
||||
aab: '../app/build/outputs/bundle/release/app-release.aab'
|
||||
)
|
||||
end
|
||||
|
||||
lane :release do
|
||||
gradle(
|
||||
task: "clean assembleRelease"
|
||||
)
|
||||
upload_to_play_store(
|
||||
aab: '../app/build/outputs/bundle/release/app-release.aab'
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,40 +0,0 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## Android
|
||||
|
||||
### android beta
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android beta
|
||||
```
|
||||
|
||||
Build and deploy Android app
|
||||
|
||||
### android release
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane android release
|
||||
```
|
||||
|
||||
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
@@ -21,5 +21,3 @@ org.gradle.jvmargs=-Xmx1536m
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
android.suppressUnsupportedCompileSdk=34
|
||||
android.suppressUnsupportedCompileSdk=34
|
||||
android.suppressUnsupportedCompileSdk=34
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
## This file must *NOT* be checked into Version Control Systems,
|
||||
# as it contains information specific to your local configuration.
|
||||
#
|
||||
# Location of the SDK. This is only used by Gradle.
|
||||
# For customization when using a Version Control System, please read the
|
||||
# header note.
|
||||
#Sun Mar 09 06:14:41 UTC 2025
|
||||
sdk.dir=/opt/android-sdk
|
||||
BIN
assets/icon-only.png
Normal file
BIN
assets/icon-only.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
@@ -1,7 +1,7 @@
|
||||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'app.timesafari.app',
|
||||
appId: 'app.timesafari',
|
||||
appName: 'TimeSafari',
|
||||
webDir: 'dist',
|
||||
bundledWebRuntime: false,
|
||||
|
||||
5339
package-lock.json
generated
5339
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -12,8 +12,7 @@
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js",
|
||||
"test-local": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
"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:web": "npx playwright test -c playwright.config-local.ts --trace on",
|
||||
"test:mobile": "npm run build:capacitor && npm run test:android && npm run test:ios",
|
||||
@@ -46,10 +45,13 @@
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
"@capacitor/cli": "^6.2.0",
|
||||
"@capacitor/core": "^6.2.0",
|
||||
"@capacitor/core": "^6.2.1",
|
||||
"@capacitor/filesystem": "^6.0.3",
|
||||
"@capacitor/ios": "^6.2.0",
|
||||
"@capacitor/share": "^6.0.3",
|
||||
"@dicebear/collection": "^5.4.1",
|
||||
"@dicebear/core": "^5.4.1",
|
||||
"@electron/remote": "^2.1.2",
|
||||
"@ethersproject/hdnode": "^5.7.0",
|
||||
"@ethersproject/wallet": "^5.8.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
@@ -99,13 +101,13 @@
|
||||
"pinia-plugin-persistedstate": "^3.2.1",
|
||||
"qr-code-generator-vue3": "^1.4.21",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"ramda": "^0.29.1",
|
||||
"readable-stream": "^4.5.2",
|
||||
"reflect-metadata": "^0.1.14",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"simple-vue-camera": "^1.1.3",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"three": "^0.156.1",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"vue": "^3.5.13",
|
||||
@@ -118,12 +120,13 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/assets": "^3.0.5",
|
||||
"@playwright/test": "^1.45.2",
|
||||
"@types/dom-webcodecs": "^0.1.7",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^20.14.11",
|
||||
"@types/node": "^20.17.30",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/ramda": "^0.29.11",
|
||||
"@types/sqlite3": "^3.1.11",
|
||||
@@ -133,8 +136,12 @@
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-typescript": "^11.0.3",
|
||||
"assert": "^2.1.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"browserify-zlib": "^0.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -142,14 +149,21 @@
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"markdownlint": "^0.37.4",
|
||||
"markdownlint-cli": "^0.44.0",
|
||||
"npm-check-updates": "^17.1.13",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"rimraf": "^6.0.1",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"stream-http": "^3.2.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tty-browserify": "^0.0.1",
|
||||
"typescript": "~5.2.2",
|
||||
"url": "^0.11.4",
|
||||
"util": "^0.12.5",
|
||||
"vite": "^5.2.0",
|
||||
"vite-plugin-pwa": "^0.19.8"
|
||||
},
|
||||
|
||||
@@ -46,21 +46,21 @@ export default defineConfig({
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium-serial',
|
||||
testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
permissions: ["clipboard-read"],
|
||||
},
|
||||
workers: 1, // Force serial execution for problematic tests
|
||||
},
|
||||
{
|
||||
name: 'firefox-serial',
|
||||
testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
workers: 1,
|
||||
},
|
||||
// {
|
||||
// name: 'chromium-serial',
|
||||
// testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
// use: {
|
||||
// ...devices['Desktop Chrome'],
|
||||
// permissions: ["clipboard-read"],
|
||||
// },
|
||||
// workers: 1, // Force serial execution for problematic tests
|
||||
// },
|
||||
// {
|
||||
// name: 'firefox-serial',
|
||||
// testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// workers: 1,
|
||||
// },
|
||||
{
|
||||
name: 'chromium',
|
||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
||||
@@ -69,39 +69,33 @@ export default defineConfig({
|
||||
permissions: ["clipboard-read"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
|
||||
{
|
||||
name: "Mobile Chrome",
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
{
|
||||
name: "Mobile Safari",
|
||||
use: { ...devices["iPhone 12"] },
|
||||
},
|
||||
// {
|
||||
// name: "Mobile Chrome",
|
||||
// use: { ...devices["Pixel 5"] },
|
||||
// },
|
||||
// {
|
||||
// name: "Mobile Safari",
|
||||
// use: { ...devices["iPhone 12"] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
{
|
||||
name: "Google Chrome",
|
||||
use: { ...devices["Desktop Chrome"], channel: "chrome" },
|
||||
},
|
||||
],
|
||||
|
||||
/* Configure global timeout; default is 30000 milliseconds */
|
||||
// the image upload will often not succeed in 5 seconds
|
||||
// 33-record-gift-x10.spec.ts:90:5 > Record 9 new gifts will often not succeed in 30 seconds
|
||||
timeout: 35000, // various tests fail at various times with 25000
|
||||
timeout: 45000, // various tests fail at various times with 25000
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
/**
|
||||
|
||||
@@ -40,10 +40,10 @@ export default defineConfig({
|
||||
permissions: ["clipboard-read"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: 'webkit',
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*
|
||||
* @requires child_process
|
||||
* @requires path
|
||||
* @requires readline
|
||||
*
|
||||
* @author TimeSafari Team
|
||||
* @license MIT
|
||||
@@ -37,7 +38,14 @@
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { join } = require('path');
|
||||
const { existsSync, mkdirSync, appendFileSync, readFileSync } = require('fs');
|
||||
const { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } = require('fs');
|
||||
const readline = require('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
|
||||
|
||||
// Format date as YYYY-MM-DD-HHMMSS
|
||||
const getLogFileName = () => {
|
||||
@@ -88,11 +96,57 @@ const verifyJavaInstallation = (log) => {
|
||||
// Generate test data using generate_data.ts
|
||||
const generateTestData = async (log) => {
|
||||
log('🔄 Generating test data...');
|
||||
|
||||
// Create .generated directory if it doesn't exist
|
||||
if (!existsSync('.generated')) {
|
||||
mkdirSync('.generated', { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('npx ts-node test-scripts/generate_data.ts', { stdio: 'inherit' });
|
||||
// Generate test data
|
||||
const testData = {
|
||||
CONTACT1_DID: "did:ethr:0x1943754837A09684Fd6380C1D80aa53E3F20E338",
|
||||
CLAIM_ID: "01JPVVX7FH0EKQWTQY9HTXZQDZ"
|
||||
};
|
||||
|
||||
const claimDetails = {
|
||||
claim_id: "01JPVVX7FH0EKQWTQY9HTXZQDZ",
|
||||
issuedAt: "2025-03-21T08:07:57ZZ",
|
||||
issuer: "did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F"
|
||||
};
|
||||
|
||||
const contacts = [
|
||||
{
|
||||
did: "did:ethr:0x1943754837A09684Fd6380C1D80aa53E3F20E338",
|
||||
name: "Test Contact"
|
||||
}
|
||||
];
|
||||
|
||||
// Write files
|
||||
log('📝 Writing test data files...');
|
||||
writeFileSync('.generated/test-env.json', JSON.stringify(testData, null, 2));
|
||||
writeFileSync('.generated/claim_details.json', JSON.stringify(claimDetails, null, 2));
|
||||
writeFileSync('.generated/contacts.json', JSON.stringify(contacts, null, 2));
|
||||
|
||||
// Verify files were written
|
||||
log('✅ Verifying test data files...');
|
||||
const files = [
|
||||
'.generated/test-env.json',
|
||||
'.generated/claim_details.json',
|
||||
'.generated/contacts.json'
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
if (!existsSync(file)) {
|
||||
throw new Error(`Failed to create ${file}`);
|
||||
}
|
||||
log(`✅ Created ${file}`);
|
||||
}
|
||||
|
||||
log('✅ Test data generated successfully');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate test data: ${error.message}`);
|
||||
log(`❌ Failed to generate test data: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,14 +170,24 @@ const executeDeeplink = async (url, description, log) => {
|
||||
|
||||
try {
|
||||
// Stop the app before executing the deep link
|
||||
execSync('adb shell am force-stop app.timesafari.app');
|
||||
execSync('adb shell am force-stop app.timesafari');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
|
||||
|
||||
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);
|
||||
log(`✅ Successfully executed: ${description}`);
|
||||
|
||||
// Wait between deeplink tests
|
||||
await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5s
|
||||
// Wait for app to load content
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// Wait for user confirmation before continuing
|
||||
await question('\n⏎ Press Enter to continue to next test (or Ctrl+C to quit)...');
|
||||
|
||||
// Press Back button to ensure app is in consistent state
|
||||
log(`📱 Sending keystroke (BACK) to device...`);
|
||||
execSync('adb shell input keyevent KEYCODE_BACK');
|
||||
|
||||
// Small delay after keystroke
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
} catch (error) {
|
||||
log(`❌ Failed to execute deeplink: ${description}`);
|
||||
log(`Error: ${error.message}`);
|
||||
@@ -137,11 +201,11 @@ const runDeeplinkTests = async (log) => {
|
||||
|
||||
try {
|
||||
// Load test data
|
||||
const testEnv = parseEnvFile('.generated/test-env.sh');
|
||||
const testEnv = JSON.parse(readFileSync('.generated/test-env.json', 'utf8'));
|
||||
const claimDetails = JSON.parse(readFileSync('.generated/claim_details.json', 'utf8'));
|
||||
const contacts = JSON.parse(readFileSync('.generated/contacts.json', 'utf8'));
|
||||
|
||||
// Test each deeplink
|
||||
// Test URLs
|
||||
const deeplinkTests = [
|
||||
{
|
||||
url: `timesafari://claim/${claimDetails.claim_id}`,
|
||||
@@ -173,26 +237,44 @@ const runDeeplinkTests = async (log) => {
|
||||
}
|
||||
];
|
||||
|
||||
// Execute each test
|
||||
for (const test of deeplinkTests) {
|
||||
await executeDeeplink(test.url, test.description, log);
|
||||
}
|
||||
// Show test plan
|
||||
log('\n📋 Test Plan:');
|
||||
deeplinkTests.forEach((test, i) => {
|
||||
log(`${i + 1}. ${test.description}`);
|
||||
});
|
||||
|
||||
let succeeded = true;
|
||||
try {
|
||||
await executeDeeplink('timesafari://contactJunk', 'Non-existent deeplink', log);
|
||||
} catch (error) {
|
||||
log('✅ Non-existent deeplink failed as expected');
|
||||
succeeded = false;
|
||||
} finally {
|
||||
if (succeeded) {
|
||||
throw new Error('Non-existent deeplink should have failed');
|
||||
// Execute each test
|
||||
let testsCompleted = 0;
|
||||
for (const test of deeplinkTests) {
|
||||
// Show progress
|
||||
log(`\n📊 Progress: ${testsCompleted}/${deeplinkTests.length} tests completed`);
|
||||
|
||||
// Show upcoming test info
|
||||
log('\n📱 NEXT TEST:');
|
||||
log('------------------------');
|
||||
log(`Description: ${test.description}`);
|
||||
log(`URL: ${test.url}`);
|
||||
log('------------------------');
|
||||
|
||||
await executeDeeplink(test.url, test.description, log);
|
||||
testsCompleted++;
|
||||
|
||||
// If there are more tests, show the next one
|
||||
if (testsCompleted < deeplinkTests.length) {
|
||||
const nextTest = deeplinkTests[testsCompleted];
|
||||
log('\n⏭️ NEXT UP:');
|
||||
log('------------------------');
|
||||
log(`Next test will be: ${nextTest.description}`);
|
||||
log(`URL: ${nextTest.url}`);
|
||||
log('------------------------');
|
||||
}
|
||||
}
|
||||
|
||||
log('✅ All deeplink tests completed successfully');
|
||||
log('\n🎉 All deeplink tests completed successfully!');
|
||||
rl.close(); // Close readline interface when done
|
||||
} catch (error) {
|
||||
log('❌ Deeplink tests failed');
|
||||
rl.close(); // Close readline interface on error
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -214,24 +296,88 @@ const configureAndroidProject = async (log) => {
|
||||
|
||||
log('⚙️ Configuring Gradle properties...');
|
||||
const gradleProps = 'android/gradle.properties';
|
||||
if (!existsSync(gradleProps) || !execSync(`grep -q "android.suppressUnsupportedCompileSdk=34" ${gradleProps}`)) {
|
||||
|
||||
// Create file if it doesn't exist
|
||||
if (!existsSync(gradleProps)) {
|
||||
execSync('touch android/gradle.properties');
|
||||
}
|
||||
|
||||
// Check if line exists without using grep
|
||||
const gradleContent = readFileSync(gradleProps, 'utf8');
|
||||
if (!gradleContent.includes('android.suppressUnsupportedCompileSdk=34')) {
|
||||
execSync('echo "android.suppressUnsupportedCompileSdk=34" >> android/gradle.properties');
|
||||
log('✅ Added SDK suppression to gradle.properties');
|
||||
} else {
|
||||
log('✅ SDK suppression already configured in gradle.properties');
|
||||
}
|
||||
};
|
||||
|
||||
// Build and test Android project
|
||||
const buildAndTestAndroid = async (log, env) => {
|
||||
log('🏗️ Building Android project...');
|
||||
|
||||
// Kill and restart ADB server first
|
||||
try {
|
||||
log('🔄 Restarting ADB server...');
|
||||
execSync('adb kill-server', { stdio: 'inherit' });
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
|
||||
execSync('adb start-server', { stdio: 'inherit' });
|
||||
await new Promise(resolve => setTimeout(resolve, 3000)); // Wait 3s
|
||||
|
||||
// Verify device connection
|
||||
const devices = execSync('adb devices').toString();
|
||||
if (!devices.includes('\tdevice')) {
|
||||
throw new Error('No devices connected after ADB restart');
|
||||
}
|
||||
log('✅ ADB server restarted successfully');
|
||||
} catch (error) {
|
||||
log(`⚠️ ADB restart failed: ${error.message}`);
|
||||
log('Continuing with build process...');
|
||||
}
|
||||
|
||||
// Clean build
|
||||
log('🧹 Cleaning project...');
|
||||
execSync('cd android && ./gradlew clean', { stdio: 'inherit', env });
|
||||
log('✅ Gradle clean completed');
|
||||
|
||||
// Build
|
||||
log('🏗️ Building project...');
|
||||
execSync('cd android && ./gradlew build', { stdio: 'inherit', env });
|
||||
log('✅ Gradle build completed');
|
||||
|
||||
// Run tests with retry
|
||||
log('🧪 Running Android tests...');
|
||||
execSync('cd android && ./gradlew connectedAndroidTest', { stdio: 'inherit', env });
|
||||
log('✅ Android tests completed');
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
// Verify ADB connection before tests
|
||||
execSync('adb devices', { stdio: 'inherit' });
|
||||
|
||||
// Run the tests
|
||||
execSync('cd android && ./gradlew connectedAndroidTest', {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
timeout: 60000 // 1 minute timeout
|
||||
});
|
||||
log('✅ Android tests completed');
|
||||
return;
|
||||
} catch (error) {
|
||||
retryCount++;
|
||||
log(`⚠️ Test attempt ${retryCount} failed: ${error.message}`);
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
log('🔄 Restarting ADB and retrying...');
|
||||
execSync('adb kill-server', { stdio: 'inherit' });
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
execSync('adb start-server', { stdio: 'inherit' });
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
} else {
|
||||
throw new Error(`Android tests failed after ${maxRetries} attempts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run the app
|
||||
@@ -296,4 +442,10 @@ async function runAndroidTests() {
|
||||
}
|
||||
|
||||
// Execute the test suite
|
||||
runAndroidTests();
|
||||
runAndroidTests();
|
||||
|
||||
// Add cleanup handler for SIGINT
|
||||
process.on('SIGINT', () => {
|
||||
rl.close();
|
||||
process.exit();
|
||||
});
|
||||
@@ -6,9 +6,11 @@
|
||||
* web build and runs the test suite on a specified iOS simulator.
|
||||
*
|
||||
* Process flow:
|
||||
* 1. Sync Capacitor project with latest web build
|
||||
* 2. Build app for iOS simulator
|
||||
* 3. Run XCTest suite
|
||||
* 1. Clean and reset iOS platform (if needed)
|
||||
* 2. Check prerequisites (Xcode, CocoaPods, Capacitor setup)
|
||||
* 3. Sync Capacitor project with latest web build
|
||||
* 4. Build app for iOS simulator
|
||||
* 5. Run XCTest suite
|
||||
*
|
||||
* Prerequisites:
|
||||
* - macOS operating system
|
||||
@@ -38,7 +40,21 @@
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { join } = require('path');
|
||||
const { existsSync, mkdirSync, appendFileSync, readFileSync } = require('fs');
|
||||
const { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, readdirSync, statSync, accessSync } = require('fs');
|
||||
const readline = require('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
const { constants } = require('fs');
|
||||
|
||||
const question = (prompt) => new Promise((resolve) => rl.question(prompt, resolve));
|
||||
|
||||
// Make sure to close readline at the end
|
||||
process.on('SIGINT', () => {
|
||||
rl.close();
|
||||
process.exit();
|
||||
});
|
||||
|
||||
// Format date as YYYY-MM-DD-HHMMSS
|
||||
const getLogFileName = () => {
|
||||
@@ -58,6 +74,169 @@ const createLogger = (logFile) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up and reset iOS platform
|
||||
* This function completely removes and recreates the iOS platform to ensure a fresh setup
|
||||
* @param {function} log - Logging function
|
||||
* @returns {boolean} - Success status
|
||||
*/
|
||||
const cleanIosPlatform = async (log) => {
|
||||
log('🧹 Cleaning iOS platform (complete reset)...');
|
||||
|
||||
// Check for package.json and capacitor.config.ts/js
|
||||
if (!existsSync('package.json')) {
|
||||
log('⚠️ package.json not found. Are you in the correct directory?');
|
||||
throw new Error('package.json not found. Cannot continue without project configuration.');
|
||||
}
|
||||
log('✅ package.json exists');
|
||||
|
||||
const capacitorConfigExists =
|
||||
existsSync('capacitor.config.ts') ||
|
||||
existsSync('capacitor.config.js') ||
|
||||
existsSync('capacitor.config.json');
|
||||
|
||||
if (!capacitorConfigExists) {
|
||||
log('⚠️ Capacitor config file not found');
|
||||
log('Creating minimal capacitor.config.ts...');
|
||||
|
||||
try {
|
||||
// Get app name from package.json
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
const appName = packageJson.name || 'App';
|
||||
const appId = packageJson.capacitor?.appId || 'io.ionic.starter';
|
||||
|
||||
// Create a minimal capacitor config
|
||||
const capacitorConfig = `
|
||||
import { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: '${appId}',
|
||||
appName: '${appName}',
|
||||
webDir: 'dist',
|
||||
bundledWebRuntime: false
|
||||
};
|
||||
|
||||
export default config;
|
||||
`.trim();
|
||||
|
||||
writeFileSync('capacitor.config.ts', capacitorConfig);
|
||||
log('✅ Created capacitor.config.ts');
|
||||
} catch (configError) {
|
||||
log('⚠️ Failed to create Capacitor config file');
|
||||
log('Please create a capacitor.config.ts file manually');
|
||||
throw new Error('Capacitor configuration missing. Please configure manually.');
|
||||
}
|
||||
} else {
|
||||
log('✅ Capacitor config exists');
|
||||
}
|
||||
|
||||
// Check if the platform exists first
|
||||
if (existsSync('ios')) {
|
||||
log('🗑️ Removing existing iOS platform directory...');
|
||||
try {
|
||||
execSync('rm -rf ios', { stdio: 'inherit' });
|
||||
log('✅ Existing iOS platform removed');
|
||||
} catch (error) {
|
||||
log(`⚠️ Error removing iOS platform: ${error.message}`);
|
||||
log('⚠️ You may need to manually remove the ios directory');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild web assets first to ensure they're available
|
||||
log('🔄 Building web assets before adding iOS platform...');
|
||||
try {
|
||||
execSync('rm -rf dist', { stdio: 'inherit' });
|
||||
execSync('npm run build:web', { stdio: 'inherit' });
|
||||
execSync('npm run build:capacitor', { stdio: 'inherit' });
|
||||
log('✅ Web assets built successfully');
|
||||
} catch (error) {
|
||||
log(`⚠️ Error building web assets: ${error.message}`);
|
||||
log('⚠️ Continuing with platform addition, but it may fail if web assets are required');
|
||||
}
|
||||
|
||||
// Add the platform back
|
||||
log('➕ Adding iOS platform...');
|
||||
try {
|
||||
execSync('npx cap add ios', { stdio: 'inherit' });
|
||||
log('✅ iOS platform added successfully');
|
||||
|
||||
// Verify critical files were created
|
||||
if (!existsSync('ios/App/Podfile')) {
|
||||
log('⚠️ Podfile was not created - something is wrong with the Capacitor setup');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!existsSync('ios/App/App/Info.plist')) {
|
||||
log('⚠️ Info.plist was not created - something is wrong with the Capacitor setup');
|
||||
return false;
|
||||
}
|
||||
|
||||
log('✅ iOS platform setup verified - critical files exist');
|
||||
return true;
|
||||
} catch (error) {
|
||||
log(`⚠️ Error adding iOS platform: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check all prerequisites for iOS testing
|
||||
* Verifies and attempts to install/initialize all required components
|
||||
*/
|
||||
const checkPrerequisites = async (log) => {
|
||||
log('🔍 Checking prerequisites for iOS testing...');
|
||||
|
||||
// Check for macOS
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('iOS testing is only supported on macOS');
|
||||
}
|
||||
log('✅ Running on macOS');
|
||||
|
||||
// Verify Xcode installation
|
||||
try {
|
||||
const xcodeOutput = execSync('xcode-select -p').toString().trim();
|
||||
log(`✅ Xcode command line tools found at: ${xcodeOutput}`);
|
||||
} catch (error) {
|
||||
log('⚠️ Xcode command line tools not found');
|
||||
log('Please install Xcode from the App Store and run:');
|
||||
log('xcode-select --install');
|
||||
throw new Error('Xcode command line tools not found. Please install Xcode first.');
|
||||
}
|
||||
|
||||
// Check Xcode version
|
||||
try {
|
||||
const xcodeVersionOutput = execSync('xcodebuild -version').toString().trim();
|
||||
log(`✅ Xcode version: ${xcodeVersionOutput.split('\n')[0]}`);
|
||||
} catch (error) {
|
||||
log('⚠️ Unable to determine Xcode version');
|
||||
}
|
||||
|
||||
// Check for CocoaPods
|
||||
try {
|
||||
const podVersionOutput = execSync('pod --version').toString().trim();
|
||||
log(`✅ CocoaPods version: ${podVersionOutput}`);
|
||||
} catch (error) {
|
||||
log('⚠️ CocoaPods not found');
|
||||
log('Attempting to install CocoaPods...');
|
||||
|
||||
try {
|
||||
log('🔄 Installing CocoaPods via gem...');
|
||||
execSync('gem install cocoapods', { stdio: 'inherit' });
|
||||
log('✅ CocoaPods installed successfully');
|
||||
} catch (gemError) {
|
||||
log('⚠️ Failed to install CocoaPods via gem');
|
||||
log('Please install CocoaPods manually:');
|
||||
log('1. sudo gem install cocoapods');
|
||||
log('2. brew install cocoapods');
|
||||
throw new Error('CocoaPods installation failed. Please install manually.');
|
||||
}
|
||||
}
|
||||
|
||||
log('✅ All prerequisites for iOS testing are met');
|
||||
return true;
|
||||
};
|
||||
|
||||
// Check for iOS simulator
|
||||
const checkSimulator = async (log) => {
|
||||
log('🔍 Checking for iOS simulator...');
|
||||
@@ -151,122 +330,84 @@ const verifyXcodeInstallation = (log) => {
|
||||
|
||||
// Generate test data using generate_data.ts
|
||||
const generateTestData = async (log) => {
|
||||
log('🔄 Generating test data...');
|
||||
log('\n🔍 DEBUG: Starting test data generation...');
|
||||
|
||||
// Check if test-scripts directory exists
|
||||
if (!existsSync('test-scripts')) {
|
||||
log('⚠️ test-scripts directory not found');
|
||||
log('⚠️ Current directory: ' + process.cwd());
|
||||
|
||||
// List directories to help debug
|
||||
const { readdirSync } = require('fs');
|
||||
log('📂 Directories in current path:');
|
||||
try {
|
||||
const files = readdirSync('.');
|
||||
files.forEach(file => {
|
||||
const isDir = existsSync(file) && require('fs').statSync(file).isDirectory();
|
||||
log(`${isDir ? '📁' : '📄'} ${file}`);
|
||||
});
|
||||
} catch (err) {
|
||||
log(`⚠️ Error listing directory: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
log('✅ Found test-scripts directory');
|
||||
|
||||
// Check if generate_data.ts exists
|
||||
if (existsSync('test-scripts/generate_data.ts')) {
|
||||
log('✅ Found generate_data.ts');
|
||||
} else {
|
||||
log('⚠️ generate_data.ts not found in test-scripts directory');
|
||||
|
||||
// List files in test-scripts to help debug
|
||||
const { readdirSync } = require('fs');
|
||||
log('📂 Files in test-scripts:');
|
||||
try {
|
||||
const files = readdirSync('test-scripts');
|
||||
files.forEach(file => {
|
||||
log(`📄 ${file}`);
|
||||
});
|
||||
} catch (err) {
|
||||
log(`⚠️ Error listing test-scripts: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check directory structure
|
||||
log('📁 Current directory:', process.cwd());
|
||||
log('📁 Directory contents:', require('fs').readdirSync('.'));
|
||||
|
||||
// Create .generated directory if it doesn't exist
|
||||
if (!existsSync('.generated')) {
|
||||
log('📁 Creating .generated directory');
|
||||
mkdirSync('.generated', { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Try to generate test data using the script
|
||||
log('🔄 Running test data generation script...');
|
||||
log('🔄 Attempting to run generate_data.ts...');
|
||||
execSync('npx ts-node test-scripts/generate_data.ts', { stdio: 'inherit' });
|
||||
log('✅ Test data generation script completed');
|
||||
log('✅ Test data generation completed');
|
||||
|
||||
// Verify the generated files exist
|
||||
// Verify and log generated files content
|
||||
const requiredFiles = [
|
||||
'.generated/test-env.json',
|
||||
'.generated/claim_details.json',
|
||||
'.generated/contacts.json'
|
||||
];
|
||||
|
||||
log('🔍 Verifying generated files:');
|
||||
log('\n📝 Verifying generated files:');
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) {
|
||||
log(`⚠️ Required file ${file} was not generated`);
|
||||
throw new Error(`Required file ${file} was not generated`);
|
||||
log(`❌ Missing file: ${file}`);
|
||||
} else {
|
||||
log(`✅ ${file} exists`);
|
||||
const content = readFileSync(file, 'utf8');
|
||||
log(`\n📄 Content of ${file}:`);
|
||||
log(content);
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (file.includes('test-env.json')) {
|
||||
log('🔑 CONTACT1_DID in test-env:', parsed.CONTACT1_DID);
|
||||
}
|
||||
if (file.includes('contacts.json')) {
|
||||
log('👥 First contact DID:', parsed[0]?.did);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`❌ Error parsing ${file}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`⚠️ Failed to generate test data: ${error.message}`);
|
||||
log(`\n⚠️ Test data generation failed: ${error.message}`);
|
||||
log('⚠️ Creating fallback test data...');
|
||||
|
||||
// Create minimal fallback test data
|
||||
// Create fallback data with detailed logging
|
||||
const fallbackTestEnv = {
|
||||
"CONTACT1_DID": "did:example:123456789",
|
||||
"CONTACT1_DID": "did:ethr:0x35A71Ac3fA0A4D5a4903f10F0f7A3ac4034FaB5B",
|
||||
"APP_URL": "https://app.timesafari.example"
|
||||
};
|
||||
|
||||
const fallbackClaimDetails = {
|
||||
"claim_id": "claim_12345",
|
||||
"title": "Test Claim",
|
||||
"description": "This is a test claim"
|
||||
};
|
||||
|
||||
const fallbackContacts = [
|
||||
{
|
||||
"id": "contact1",
|
||||
"name": "Test Contact",
|
||||
"did": "did:example:123456789"
|
||||
"did": "did:ethr:0x35A71Ac3fA0A4D5a4903f10F0f7A3ac4034FaB5B"
|
||||
}
|
||||
];
|
||||
|
||||
// Use writeFileSync to overwrite any existing files
|
||||
const { writeFileSync } = require('fs');
|
||||
log('\n📝 Writing fallback data:');
|
||||
log('TestEnv:', JSON.stringify(fallbackTestEnv, null, 2));
|
||||
log('Contacts:', JSON.stringify(fallbackContacts, null, 2));
|
||||
|
||||
writeFileSync('.generated/test-env.json', JSON.stringify(fallbackTestEnv, null, 2));
|
||||
writeFileSync('.generated/claim_details.json', JSON.stringify(fallbackClaimDetails, null, 2));
|
||||
writeFileSync('.generated/contacts.json', JSON.stringify(fallbackContacts, null, 2));
|
||||
|
||||
log('✅ Fallback test data created');
|
||||
|
||||
// Verify files were created
|
||||
const requiredFiles = [
|
||||
'.generated/test-env.json',
|
||||
'.generated/claim_details.json',
|
||||
'.generated/contacts.json'
|
||||
];
|
||||
|
||||
log('🔍 Verifying fallback files:');
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) {
|
||||
log(`⚠️ Failed to create ${file}`);
|
||||
} else {
|
||||
log(`✅ Created ${file}`);
|
||||
}
|
||||
// Verify fallback data was written
|
||||
log('\n🔍 Verifying fallback data:');
|
||||
try {
|
||||
const writtenTestEnv = JSON.parse(readFileSync('.generated/test-env.json', 'utf8'));
|
||||
const writtenContacts = JSON.parse(readFileSync('.generated/contacts.json', 'utf8'));
|
||||
log('Written TestEnv:', writtenTestEnv);
|
||||
log('Written Contacts:', writtenContacts);
|
||||
} catch (e) {
|
||||
log('❌ Error verifying fallback data:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -282,40 +423,45 @@ const buildWebAssets = async (log) => {
|
||||
|
||||
// Configure iOS project
|
||||
const configureIosProject = async (log) => {
|
||||
log('📱 Syncing Capacitor project...');
|
||||
try {
|
||||
execSync('npx cap sync ios', { stdio: 'inherit' });
|
||||
log('✅ Capacitor sync completed');
|
||||
} catch (error) {
|
||||
log('⚠️ Capacitor sync encountered issues. Attempting to continue...');
|
||||
}
|
||||
log('📱 Configuring iOS project...');
|
||||
|
||||
// Skip cap sync since we just did a clean platform add
|
||||
log('✅ Using freshly created iOS platform');
|
||||
|
||||
// Register URL scheme for deeplink tests
|
||||
log('🔗 Configuring URL scheme for deeplink tests...');
|
||||
if (checkAndRegisterUrlScheme(log)) {
|
||||
log('✅ URL scheme configuration completed');
|
||||
} else {
|
||||
log('⚠️ URL scheme could not be registered automatically');
|
||||
log('⚠️ Deeplink tests may not work correctly');
|
||||
}
|
||||
|
||||
log('⚙️ Installing CocoaPods dependencies...');
|
||||
try {
|
||||
// Try to run pod install normally first
|
||||
log('🔄 Running "pod install" in ios/App directory...');
|
||||
execSync('cd ios/App && pod install', { stdio: 'inherit' });
|
||||
log('✅ CocoaPods installation completed');
|
||||
} catch (error) {
|
||||
// If that fails, try using sudo (requires password)
|
||||
log('⚠️ CocoaPods installation failed. Trying with sudo...');
|
||||
try {
|
||||
execSync('cd ios/App && sudo pod install', { stdio: 'inherit' });
|
||||
} catch (sudoError) {
|
||||
// If both methods fail, alert the user
|
||||
log('❌ CocoaPods installation failed.');
|
||||
log('Please run one of the following commands manually:');
|
||||
log('1. cd ios/App && pod install');
|
||||
log('2. cd ios/App && sudo pod install');
|
||||
log('3. Install CocoaPods through Homebrew: brew install cocoapods');
|
||||
throw new Error('CocoaPods installation failed. See log for details.');
|
||||
}
|
||||
// If that fails, provide detailed instructions
|
||||
log(`⚠️ CocoaPods installation failed: ${error.message}`);
|
||||
log('⚠️ Please ensure CocoaPods is installed correctly:');
|
||||
log('1. If using system Ruby: "sudo gem install cocoapods"');
|
||||
log('2. If using Homebrew Ruby: "brew install cocoapods"');
|
||||
log('3. Then run: "cd ios/App && pod install"');
|
||||
|
||||
// Try to continue despite the error
|
||||
log('⚠️ Attempting to continue with the build process...');
|
||||
}
|
||||
log('✅ CocoaPods installation completed');
|
||||
|
||||
// Add information about iOS security dialogs
|
||||
log('\n📱 iOS Security Dialog Information:');
|
||||
log('⚠️ iOS will display security confirmation dialogs when testing deeplinks');
|
||||
log('⚠️ This is a security feature of iOS and cannot be bypassed in normal testing');
|
||||
log('⚠️ You will need to manually approve each deeplink test by clicking "Open" in the dialog');
|
||||
log('⚠️ The app must be running in the foreground for deeplinks to work properly');
|
||||
log('⚠️ If tests appear to hang, check if a security dialog is waiting for your confirmation');
|
||||
};
|
||||
|
||||
// Build and test iOS project
|
||||
@@ -365,6 +511,96 @@ const runIosApp = async (log, simulator) => {
|
||||
log('✅ App launched successfully');
|
||||
};
|
||||
|
||||
const validateTestData = (log) => {
|
||||
log('\n=== VALIDATING TEST DATA ===');
|
||||
|
||||
const generateFreshTestData = () => {
|
||||
log('\n🔄 Generating fresh test data...');
|
||||
try {
|
||||
// Ensure .generated directory exists
|
||||
if (!existsSync('.generated')) {
|
||||
mkdirSync('.generated', { recursive: true });
|
||||
}
|
||||
|
||||
// Execute the generate_data.ts script synchronously
|
||||
log('Running generate_data.ts...');
|
||||
execSync('npx ts-node test-scripts/generate_data.ts', {
|
||||
stdio: 'inherit',
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
// Read and validate the generated files
|
||||
const testEnvPath = '.generated/test-env.json';
|
||||
const contactsPath = '.generated/contacts.json';
|
||||
|
||||
if (!existsSync(testEnvPath) || !existsSync(contactsPath)) {
|
||||
throw new Error('Generated files not found after running generate_data.ts');
|
||||
}
|
||||
|
||||
const testEnv = JSON.parse(readFileSync(testEnvPath, 'utf8'));
|
||||
const contacts = JSON.parse(readFileSync(contactsPath, 'utf8'));
|
||||
|
||||
// Validate required fields
|
||||
if (!testEnv.CONTACT1_DID) {
|
||||
throw new Error('CONTACT1_DID missing from generated test data');
|
||||
}
|
||||
|
||||
log('Generated test data:', {
|
||||
testEnv: testEnv,
|
||||
contacts: contacts
|
||||
});
|
||||
|
||||
return { testEnv, contacts };
|
||||
} catch (error) {
|
||||
log('❌ Test data generation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Try to read existing data or generate fresh data
|
||||
const testEnvPath = '.generated/test-env.json';
|
||||
const contactsPath = '.generated/contacts.json';
|
||||
|
||||
let testData;
|
||||
|
||||
// If either file is missing or invalid, generate fresh data
|
||||
if (!existsSync(testEnvPath) || !existsSync(contactsPath)) {
|
||||
testData = generateFreshTestData();
|
||||
} else {
|
||||
try {
|
||||
const testEnv = JSON.parse(readFileSync(testEnvPath, 'utf8'));
|
||||
const contacts = JSON.parse(readFileSync(contactsPath, 'utf8'));
|
||||
|
||||
// Validate required fields
|
||||
if (!testEnv.CLAIM_ID || !testEnv.CONTACT1_DID) {
|
||||
log('⚠️ Existing test data missing required fields, regenerating...');
|
||||
testData = generateFreshTestData();
|
||||
} else {
|
||||
testData = { testEnv, contacts };
|
||||
}
|
||||
} catch (error) {
|
||||
log('⚠️ Error reading existing test data, regenerating...');
|
||||
testData = generateFreshTestData();
|
||||
}
|
||||
}
|
||||
|
||||
// Final validation of data
|
||||
if (!testData.testEnv.CLAIM_ID || !testData.testEnv.CONTACT1_DID) {
|
||||
throw new Error('Test data validation failed even after generation');
|
||||
}
|
||||
|
||||
log('✅ Test data validated successfully');
|
||||
log('📄 Test Environment:', JSON.stringify(testData.testEnv, null, 2));
|
||||
|
||||
return testData;
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ Test data validation failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run deeplink tests
|
||||
* Optionally tests deeplinks if the test data is available
|
||||
@@ -373,122 +609,115 @@ const runIosApp = async (log, simulator) => {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const runDeeplinkTests = async (log) => {
|
||||
log('🔗 Starting deeplink tests...');
|
||||
log('\n=== Starting Deeplink Tests ===');
|
||||
|
||||
// Register URL scheme if needed
|
||||
checkAndRegisterUrlScheme(log);
|
||||
|
||||
// Check if test data files exist first
|
||||
const requiredFiles = [
|
||||
'.generated/test-env.json',
|
||||
'.generated/claim_details.json',
|
||||
'.generated/contacts.json'
|
||||
];
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
if (!existsSync(file)) {
|
||||
log(`⚠️ Required file ${file} does not exist`);
|
||||
log('⚠️ Skipping deeplink tests');
|
||||
return;
|
||||
}
|
||||
// Validate test data before proceeding
|
||||
let testEnv, contacts;
|
||||
try {
|
||||
({ testEnv, contacts } = validateTestData(log));
|
||||
} catch (error) {
|
||||
log('❌ Cannot proceed with tests due to invalid test data');
|
||||
log(`Error: ${error.message}`);
|
||||
log('Please ensure test data is properly generated before running tests');
|
||||
process.exit(1); // Exit with error code
|
||||
}
|
||||
|
||||
// Now we can safely create the deeplink tests knowing we have valid data
|
||||
const deeplinkTests = [
|
||||
{
|
||||
url: `timesafari://claim/${testEnv.CLAIM_ID}`,
|
||||
description: 'Claim view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-cert/${testEnv.CERT_ID || testEnv.CLAIM_ID}`,
|
||||
description: 'Claim certificate view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-add-raw/${testEnv.RAW_CLAIM_ID || testEnv.CLAIM_ID}`,
|
||||
description: 'Raw claim addition'
|
||||
},
|
||||
{
|
||||
url: 'timesafari://did/test',
|
||||
description: 'DID view with test identifier'
|
||||
},
|
||||
{
|
||||
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
|
||||
description: 'DID view with contact DID'
|
||||
},
|
||||
{
|
||||
url: (() => {
|
||||
if (!testEnv?.CONTACT1_DID) {
|
||||
throw new Error('Cannot construct contact-edit URL: CONTACT1_DID is missing');
|
||||
}
|
||||
const url = `timesafari://contact-edit/${testEnv.CONTACT1_DID}`;
|
||||
log('Created contact-edit URL:', url);
|
||||
return url;
|
||||
})(),
|
||||
description: 'Contact editing'
|
||||
},
|
||||
{
|
||||
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
|
||||
description: 'Contacts import'
|
||||
}
|
||||
];
|
||||
|
||||
// Log the final test configuration
|
||||
log('\n5. Final Test Configuration:');
|
||||
deeplinkTests.forEach((test, i) => {
|
||||
log(`\nTest ${i + 1}:`);
|
||||
log(`Description: ${test.description}`);
|
||||
log(`URL: ${test.url}`);
|
||||
});
|
||||
|
||||
// Show instructions for iOS security dialogs
|
||||
log('\n📱 IMPORTANT: iOS Security Dialog Instructions:');
|
||||
log('1. Each deeplink test will trigger a security confirmation dialog');
|
||||
log('2. You MUST click "Open" on each dialog to continue testing');
|
||||
log('3. The app must be running in the FOREGROUND');
|
||||
log('4. You will need to press Enter in this terminal after handling each dialog');
|
||||
log('5. You can abort the testing process by pressing Ctrl+C\n');
|
||||
|
||||
// Ensure app is in foreground
|
||||
log('⚠️ IMPORTANT: Please make sure the app is in the FOREGROUND now');
|
||||
await question('Press Enter when the app is visible and in the foreground...');
|
||||
|
||||
try {
|
||||
// Load test data
|
||||
log('📂 Loading test data from .generated directory');
|
||||
let testEnv, claimDetails, contacts;
|
||||
|
||||
try {
|
||||
const testEnvContent = readFileSync('.generated/test-env.json', 'utf8');
|
||||
testEnv = JSON.parse(testEnvContent);
|
||||
log('✅ Loaded test-env.json');
|
||||
} catch (error) {
|
||||
log(`⚠️ Failed to load test-env.json: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const claimDetailsContent = readFileSync('.generated/claim_details.json', 'utf8');
|
||||
claimDetails = JSON.parse(claimDetailsContent);
|
||||
log('✅ Loaded claim_details.json');
|
||||
} catch (error) {
|
||||
log(`⚠️ Failed to load claim_details.json: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const contactsContent = readFileSync('.generated/contacts.json', 'utf8');
|
||||
contacts = JSON.parse(contactsContent);
|
||||
log('✅ Loaded contacts.json');
|
||||
} catch (error) {
|
||||
log(`⚠️ Failed to load contacts.json: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the app URL scheme is registered in the simulator
|
||||
log('🔍 Checking if URL scheme is registered in simulator...');
|
||||
try {
|
||||
// Attempt to open a simple URL with the scheme
|
||||
execSync(`xcrun simctl openurl booted "timesafari://test"`, { stdio: 'pipe' });
|
||||
log('✅ URL scheme is registered and working');
|
||||
} catch (error) {
|
||||
const errorMessage = error.message || '';
|
||||
|
||||
// Check for the specific error code that indicates an unregistered URL scheme
|
||||
if (errorMessage.includes('OSStatus error -10814') || errorMessage.includes('NSOSStatusErrorDomain, code=-10814')) {
|
||||
log('⚠️ URL scheme "timesafari://" is not registered in the app or app is not running');
|
||||
log('⚠️ The scheme was added to Info.plist but the app may need to be rebuilt');
|
||||
log('⚠️ Trying to continue with tests, but they may fail');
|
||||
}
|
||||
}
|
||||
|
||||
// Test URLs
|
||||
const deeplinkTests = [
|
||||
{
|
||||
url: `timesafari://claim/${claimDetails.claim_id}`,
|
||||
description: 'Claim view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-cert/${claimDetails.claim_id}`,
|
||||
description: 'Claim certificate view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-add-raw/${claimDetails.claim_id}`,
|
||||
description: 'Raw claim addition'
|
||||
},
|
||||
{
|
||||
url: 'timesafari://did/test',
|
||||
description: 'DID view with test identifier'
|
||||
},
|
||||
{
|
||||
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
|
||||
description: 'DID view with contact DID'
|
||||
},
|
||||
{
|
||||
url: `timesafari://contact-edit/${testEnv.CONTACT1_DID}`,
|
||||
description: 'Contact editing'
|
||||
},
|
||||
{
|
||||
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
|
||||
description: 'Contacts import'
|
||||
}
|
||||
];
|
||||
|
||||
// Execute each test
|
||||
let testsCompleted = 0;
|
||||
let testsSkipped = 0;
|
||||
|
||||
for (const test of deeplinkTests) {
|
||||
// Show upcoming test info before execution
|
||||
log('\n📱 NEXT TEST:');
|
||||
log('------------------------');
|
||||
log(`Description: ${test.description}`);
|
||||
log(`URL to test: ${test.url}`);
|
||||
log('------------------------');
|
||||
|
||||
// Clear prompt for user action
|
||||
await question('\n⏎ Press Enter to execute this test (or Ctrl+C to quit)...');
|
||||
|
||||
try {
|
||||
log(`\n🔗 Testing deeplink: ${test.description}`);
|
||||
log(`URL: ${test.url}`);
|
||||
log('🚀 Executing deeplink test...');
|
||||
log('⚠️ iOS SECURITY DIALOG WILL APPEAR - Click "Open" to continue');
|
||||
|
||||
execSync(`xcrun simctl openurl booted "${test.url}"`, { stdio: 'pipe' });
|
||||
log(`✅ Successfully executed: ${test.description}`);
|
||||
testsCompleted++;
|
||||
|
||||
// Wait between tests
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
// Show progress
|
||||
log(`\n📊 Progress: ${testsCompleted}/${deeplinkTests.length} tests completed`);
|
||||
|
||||
// If there are more tests, show the next one
|
||||
if (testsCompleted < deeplinkTests.length) {
|
||||
const nextTest = deeplinkTests[testsCompleted];
|
||||
log('\n⏭️ NEXT UP:');
|
||||
log('------------------------');
|
||||
log(`Next test will be: ${nextTest.description}`);
|
||||
log(`URL: ${nextTest.url}`);
|
||||
log('------------------------');
|
||||
await question('\n⏎ Press Enter when ready for the next test...');
|
||||
}
|
||||
} catch (deeplinkError) {
|
||||
const errorMessage = deeplinkError.message || '';
|
||||
|
||||
@@ -501,22 +730,35 @@ const runDeeplinkTests = async (log) => {
|
||||
log(`⚠️ Error: ${errorMessage}`);
|
||||
}
|
||||
log('⚠️ Continuing with next test...');
|
||||
|
||||
// Show next test info after error handling
|
||||
if (testsCompleted + testsSkipped < deeplinkTests.length) {
|
||||
const nextTest = deeplinkTests[testsCompleted + testsSkipped];
|
||||
log('\n⏭️ NEXT UP:');
|
||||
log('------------------------');
|
||||
log(`Next test will be: ${nextTest.description}`);
|
||||
log(`URL: ${nextTest.url}`);
|
||||
log('------------------------');
|
||||
await question('\n⏎ Press Enter when ready for the next test...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(`✅ Deeplink tests completed: ${testsCompleted} successful, ${testsSkipped} skipped`);
|
||||
log('\n🎉 All deeplink tests completed!');
|
||||
log(`✅ Successful: ${testsCompleted}`);
|
||||
log(`⚠️ Skipped: ${testsSkipped}`);
|
||||
|
||||
if (testsSkipped > 0) {
|
||||
log('\n📝 Note about skipped tests:');
|
||||
log('1. The app needs to have the URL scheme registered in Info.plist');
|
||||
log('2. The app needs to be rebuilt after registering the URL scheme');
|
||||
log('3. The app must be running in the foreground for deeplink tests to work');
|
||||
log('4. If these conditions are met and tests still fail, check URL handling in the app code');
|
||||
log('4. iOS security dialogs must be manually approved for each deeplink test');
|
||||
log('5. If these conditions are met and tests still fail, check URL handling in the app code');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ Deeplink tests setup failed: ${error.message}`);
|
||||
log('⚠️ Deeplink tests might be unavailable or test data is missing');
|
||||
// Don't rethrow the error to prevent halting the process
|
||||
}
|
||||
};
|
||||
|
||||
@@ -558,7 +800,7 @@ const checkAndRegisterUrlScheme = (log) => {
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.timesafari.app</string>
|
||||
<string>app.timesafari</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timesafari</string>
|
||||
@@ -585,14 +827,51 @@ const checkAndRegisterUrlScheme = (log) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get the app identifier from package.json or capacitor config
|
||||
const getAppIdentifier = () => {
|
||||
try {
|
||||
// Try to read from capacitor.config.ts/js/json
|
||||
if (existsSync('capacitor.config.json')) {
|
||||
const config = JSON.parse(readFileSync('capacitor.config.json', 'utf8'));
|
||||
return config.appId;
|
||||
}
|
||||
if (existsSync('capacitor.config.js')) {
|
||||
// We can't directly require the file, but we can try to extract the appId
|
||||
const content = readFileSync('capacitor.config.js', 'utf8');
|
||||
const match = content.match(/appId:\s*['"]([^'"]+)['"]/);
|
||||
if (match && match[1]) return match[1];
|
||||
}
|
||||
if (existsSync('capacitor.config.ts')) {
|
||||
// Similar approach for TypeScript
|
||||
const content = readFileSync('capacitor.config.ts', 'utf8');
|
||||
const match = content.match(/appId:\s*['"]([^'"]+)['"]/);
|
||||
if (match && match[1]) return match[1];
|
||||
}
|
||||
|
||||
// Fall back to package.json
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
||||
if (packageJson.capacitor && packageJson.capacitor.appId) {
|
||||
return packageJson.capacitor.appId;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'app.timesafari';
|
||||
} catch (error) {
|
||||
console.error('Error getting app identifier:', error);
|
||||
return 'app.timesafari'; // Default fallback
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the complete iOS test suite including build and testing
|
||||
*
|
||||
* The function performs the following steps:
|
||||
* 1. Syncs the Capacitor project with latest web build
|
||||
* 2. Builds the app using xcodebuild
|
||||
* 3. Optionally runs tests if configured
|
||||
* 4. Launches the app in the simulator
|
||||
* 1. Cleans and resets the iOS platform
|
||||
* 2. Verifies prerequisites and project setup
|
||||
* 3. Syncs the Capacitor project with latest web build
|
||||
* 4. Builds the app using xcodebuild
|
||||
* 5. Optionally runs tests if configured
|
||||
* 6. Launches the app in the simulator
|
||||
*
|
||||
* If no simulator is running, it automatically selects and boots one.
|
||||
*
|
||||
@@ -617,7 +896,16 @@ async function runIosTests() {
|
||||
try {
|
||||
log('🚀 Starting iOS build and test process...');
|
||||
|
||||
// Generate test data first
|
||||
// Clean and reset iOS platform first
|
||||
const cleanSuccess = await cleanIosPlatform(log);
|
||||
if (!cleanSuccess) {
|
||||
throw new Error('Failed to clean and reset iOS platform. Please check the logs for details.');
|
||||
}
|
||||
|
||||
// Check prerequisites
|
||||
await checkPrerequisites(log);
|
||||
|
||||
// Generate test data
|
||||
await generateTestData(log);
|
||||
|
||||
// Verify Xcode installation
|
||||
@@ -626,8 +914,7 @@ async function runIosTests() {
|
||||
// Check for simulator or boot one if needed
|
||||
const simulator = await checkSimulator(log);
|
||||
|
||||
// Build web assets and configure iOS project
|
||||
await buildWebAssets(log);
|
||||
// Configure iOS project
|
||||
await configureIosProject(log);
|
||||
|
||||
// Build and test using the selected simulator
|
||||
|
||||
264
src/components/ActivityListItem.vue
Normal file
264
src/components/ActivityListItem.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<li>
|
||||
<!-- Last viewed separator -->
|
||||
<div
|
||||
v-if="record.jwtId == lastViewedClaimId"
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-6 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
You've already seen all the following
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="record.issuerDid">
|
||||
<EntityIcon
|
||||
:entity-id="record.issuerDid"
|
||||
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[2rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold">
|
||||
{{ record.issuer.known ? record.issuer.displayName : "" }}
|
||||
</h3>
|
||||
<p class="ms-auto text-xs text-slate-500 italic">
|
||||
{{ friendlyDate }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
|
||||
<font-awesome icon="circle-info" class="fa-fw text-slate-500" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-100 rounded-b-md border border-slate-300 p-3 sm:p-4">
|
||||
<!-- Record Image -->
|
||||
<div
|
||||
v-if="record.image"
|
||||
class="bg-cover mb-6 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
|
||||
:style="`background-image: url(${record.image});`"
|
||||
>
|
||||
<a
|
||||
class="block bg-slate-100/50 backdrop-blur-md px-6 py-4 cursor-pointer"
|
||||
@click="$emit('viewImage', record.image)"
|
||||
>
|
||||
<img
|
||||
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
|
||||
:src="record.image"
|
||||
alt="Activity image"
|
||||
@load="$emit('cacheImage', record.image)"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="relative flex justify-between gap-4 max-w-lg mx-auto mb-5">
|
||||
<!-- Source -->
|
||||
<div
|
||||
class="w-28 sm:w-40 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
>
|
||||
<div class="relative w-fit mx-auto">
|
||||
<div>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.providerPlanName">
|
||||
<ProjectIcon
|
||||
:entity-id="record.providerPlanName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.agentDid">
|
||||
<EntityIcon
|
||||
:entity-id="record.agentDid"
|
||||
:profile-image-url="record.issuer.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unknown Person -->
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="record.providerPlanName || record.giver.known"
|
||||
class="text-xs mt-2 line-clamp-3 sm:line-clamp-2"
|
||||
>
|
||||
<font-awesome
|
||||
:icon="record.providerPlanName ? 'users' : 'user'"
|
||||
class="fa-fw text-slate-400"
|
||||
/>
|
||||
{{ record.providerPlanName || record.giver.displayName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div
|
||||
class="absolute inset-x-28 sm:inset-x-40 mx-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<div class="text-sm text-center leading-none font-semibold">
|
||||
{{ fetchAmount }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<hr
|
||||
class="grow border-t-[18px] sm:border-t-[24px] border-slate-300"
|
||||
/>
|
||||
|
||||
<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"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Destination -->
|
||||
<div
|
||||
class="w-28 sm:w-40 text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
|
||||
>
|
||||
<div class="relative w-fit mx-auto">
|
||||
<div>
|
||||
<!-- Project Icon -->
|
||||
<div v-if="record.recipientProjectName">
|
||||
<ProjectIcon
|
||||
:entity-id="record.recipientProjectName"
|
||||
:icon-size="48"
|
||||
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- Identicon for DIDs -->
|
||||
<div v-else-if="record.recipientDid">
|
||||
<EntityIcon
|
||||
:entity-id="record.recipientDid"
|
||||
:profile-image-url="record.receiver.profileImageUrl"
|
||||
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unknown Person -->
|
||||
<div v-else>
|
||||
<font-awesome
|
||||
icon="person-circle-question"
|
||||
class="text-slate-300 text-[3rem] sm:text-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="record.recipientProjectName || record.receiver.known"
|
||||
class="text-xs mt-2 line-clamp-3 sm:line-clamp-2"
|
||||
>
|
||||
<font-awesome
|
||||
:icon="record.recipientProjectName ? 'users' : 'user'"
|
||||
class="fa-fw text-slate-400"
|
||||
/>
|
||||
{{ record.recipientProjectName || record.receiver.displayName }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="font-medium">
|
||||
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
|
||||
{{ description }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { GiveRecordWithContactInfo } from "../types";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
||||
import { containsHiddenDid } from "../libs/endorserServer";
|
||||
import ProjectIcon from "./ProjectIcon.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
ProjectIcon,
|
||||
},
|
||||
emits: ["loadClaim", "viewImage", "cacheImage", "confirmClaim"],
|
||||
})
|
||||
export default class ActivityListItem extends Vue {
|
||||
@Prop() record!: GiveRecordWithContactInfo;
|
||||
@Prop() lastViewedClaimId?: string;
|
||||
@Prop() isRegistered!: boolean;
|
||||
@Prop() activeDid!: string;
|
||||
@Prop() confirmerIdList?: string[];
|
||||
|
||||
get fetchAmount(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
||||
|
||||
const amount = claim.object?.amountOfThisGood
|
||||
? this.displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
|
||||
: "";
|
||||
|
||||
return amount;
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
||||
|
||||
return `${claim.description}`;
|
||||
}
|
||||
|
||||
private displayAmount(code: string, amt: number) {
|
||||
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
|
||||
}
|
||||
|
||||
private currencyShortWordForCode(unitCode: string, single: boolean) {
|
||||
return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
|
||||
}
|
||||
|
||||
get canConfirm(): boolean {
|
||||
if (!this.isRegistered) return false;
|
||||
if (!isGiveClaimType(this.record.fullClaim?.["@type"])) return false;
|
||||
if (this.confirmerIdList?.includes(this.activeDid)) return false;
|
||||
if (this.record.issuerDid === this.activeDid) return false;
|
||||
if (containsHiddenDid(this.record.fullClaim)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
handleConfirmClick() {
|
||||
if (!this.canConfirm) {
|
||||
notifyWhyCannotConfirm(
|
||||
this.$notify,
|
||||
this.isRegistered,
|
||||
this.record.fullClaim?.["@type"],
|
||||
this.record,
|
||||
this.activeDid,
|
||||
this.confirmerIdList,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit("confirmClaim", this.record);
|
||||
}
|
||||
|
||||
get friendlyDate(): string {
|
||||
const date = new Date(this.record.issuedAt);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,42 +1,101 @@
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="w-fit" v-html="generateIcon()"></div>
|
||||
<div class="w-fit">
|
||||
<img
|
||||
v-if="hasImage"
|
||||
:src="imageUrl"
|
||||
class="rounded cursor-pointer"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
@click="handleClick"
|
||||
/>
|
||||
<div v-else class="cursor-pointer" @click="handleClick">
|
||||
<img
|
||||
v-if="!identifier"
|
||||
:src="blankSquareUrl"
|
||||
class="rounded"
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
:width="iconSize"
|
||||
:height="iconSize"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g v-for="(path, index) in avatarPaths" :key="index">
|
||||
<path :d="path" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { createAvatar, StyleOptions } from "@dicebear/core";
|
||||
import { avataaars } from "@dicebear/collection";
|
||||
import { Vue, Component, Prop } from "vue-facing-decorator";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component
|
||||
export default class EntityIcon extends Vue {
|
||||
@Prop contact: Contact;
|
||||
@Prop({ required: false }) contact?: Contact;
|
||||
@Prop entityId = ""; // overridden by contact.did or profileImageUrl
|
||||
@Prop iconSize = 0;
|
||||
@Prop profileImageUrl = ""; // overridden by contact.profileImageUrl
|
||||
|
||||
generateIcon() {
|
||||
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
if (imageUrl) {
|
||||
return `<img src="${imageUrl}" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||
} else {
|
||||
const identifier = this.contact?.did || this.entityId;
|
||||
if (!identifier) {
|
||||
return `<img src="../src/assets/blank-square.svg" class="rounded" width="${this.iconSize}" height="${this.iconSize}" />`;
|
||||
}
|
||||
// https://api.dicebear.com/8.x/avataaars/svg?seed=
|
||||
// ... does not render things with the same seed as this library.
|
||||
// "did:ethr:0x222BB77E6Ff3774d34c751f3c1260866357B677b" yields a girl with flowers in her hair and a lightning earring
|
||||
// ... which looks similar to '' at the dicebear site but which is different.
|
||||
const options: StyleOptions<object> = {
|
||||
seed: (identifier as string) || "",
|
||||
size: this.iconSize,
|
||||
};
|
||||
const avatar = createAvatar(avataaars, options);
|
||||
const svgString = avatar.toString();
|
||||
return svgString;
|
||||
private avatarPaths: string[] = [];
|
||||
private blankSquareUrl =
|
||||
import.meta.env.VITE_BASE_URL + "assets/blank-square.svg";
|
||||
|
||||
get imageUrl(): string {
|
||||
return this.contact?.profileImageUrl || this.profileImageUrl;
|
||||
}
|
||||
|
||||
get hasImage(): boolean {
|
||||
return !!this.imageUrl;
|
||||
}
|
||||
|
||||
get identifier(): string | undefined {
|
||||
return this.contact?.did || this.entityId;
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
try {
|
||||
// Emit a simple event without passing the event object
|
||||
this.$emit("click");
|
||||
} catch (error) {
|
||||
logger.error("Error handling click event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
generateAvatarPaths(): string[] {
|
||||
if (!this.identifier) return [];
|
||||
|
||||
const options: StyleOptions<object> = {
|
||||
seed: this.identifier,
|
||||
size: this.iconSize,
|
||||
};
|
||||
const avatar = createAvatar(avataaars, options);
|
||||
const svgString = avatar.toString();
|
||||
|
||||
// Extract paths from SVG string
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgString, "image/svg+xml");
|
||||
const paths = Array.from(doc.querySelectorAll("path")).map(
|
||||
(path) => path.getAttribute("d") || "",
|
||||
);
|
||||
return paths;
|
||||
}
|
||||
|
||||
mounted() {
|
||||
this.avatarPaths = this.generateAvatarPaths();
|
||||
logger.log("EntityIcon mounted, profileImageUrl:", this.profileImageUrl);
|
||||
logger.log("EntityIcon mounted, entityId:", this.entityId);
|
||||
logger.log("EntityIcon mounted, iconSize:", this.iconSize);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
:src="imageUrl"
|
||||
class="max-h-[calc(100vh-5rem)] w-full h-full object-contain"
|
||||
alt="expanded shared content"
|
||||
@click.stop
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/** * @file InfiniteScroll.vue * @description A Vue component that implements
|
||||
infinite scrolling functionality using the Intersection Observer API. * This
|
||||
component emits a 'reached-bottom' event when the user scrolls near the bottom
|
||||
of the content. * It includes debouncing to prevent multiple rapid triggers and
|
||||
loading state management. * * @author Matthew Raymer * @version 1.0.0 */
|
||||
|
||||
<template>
|
||||
<div ref="scrollContainer">
|
||||
<slot />
|
||||
@@ -8,13 +14,51 @@
|
||||
<script lang="ts">
|
||||
import { Component, Emit, Prop, Vue } from "vue-facing-decorator";
|
||||
|
||||
/**
|
||||
* InfiniteScroll Component
|
||||
*
|
||||
* This component implements infinite scrolling functionality by observing when a user
|
||||
* scrolls near the bottom of the content. It uses the Intersection Observer API for
|
||||
* efficient scroll detection and includes debouncing to prevent multiple rapid triggers.
|
||||
*
|
||||
* Usage in template:
|
||||
* ```vue
|
||||
* <InfiniteScroll @reached-bottom="loadMore">
|
||||
* <div>Content goes here</div>
|
||||
* </InfiniteScroll>
|
||||
* ```
|
||||
*
|
||||
* Props:
|
||||
* - distance: number (default: 200) - Distance in pixels from the bottom at which to trigger the event
|
||||
*
|
||||
* Events:
|
||||
* - reached-bottom: Emitted when the user scrolls near the bottom of the content
|
||||
*/
|
||||
@Component
|
||||
export default class InfiniteScroll extends Vue {
|
||||
/** Distance in pixels from the bottom at which to trigger the reached-bottom event */
|
||||
@Prop({ default: 200 })
|
||||
readonly distance!: number;
|
||||
|
||||
/** Intersection Observer instance for detecting scroll position */
|
||||
private observer!: IntersectionObserver;
|
||||
|
||||
/** Flag to track initial render state */
|
||||
private isInitialRender = true;
|
||||
|
||||
/** Flag to prevent multiple simultaneous loading states */
|
||||
private isLoading = false;
|
||||
|
||||
/** Timeout ID for debouncing scroll events */
|
||||
private debounceTimeout: number | null = null;
|
||||
|
||||
/**
|
||||
* Vue lifecycle hook that runs after component updates.
|
||||
* Initializes the Intersection Observer if not already set up.
|
||||
*
|
||||
* @internal
|
||||
* Used internally by Vue's lifecycle system
|
||||
*/
|
||||
updated() {
|
||||
if (!this.observer) {
|
||||
const options = {
|
||||
@@ -30,18 +74,50 @@ export default class InfiniteScroll extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
// 'beforeUnmount' hook runs before unmounting the component
|
||||
/**
|
||||
* Vue lifecycle hook that runs before component unmounting.
|
||||
* Cleans up the Intersection Observer and any pending timeouts.
|
||||
*
|
||||
* @internal
|
||||
* Used internally by Vue's lifecycle system
|
||||
*/
|
||||
beforeUnmount() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
if (this.debounceTimeout) {
|
||||
window.clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles intersection observer callbacks when the sentinel element becomes visible.
|
||||
* Implements debouncing to prevent multiple rapid triggers and manages loading state.
|
||||
*
|
||||
* @param entries - Array of IntersectionObserverEntry objects
|
||||
* @returns false (required by @Emit decorator)
|
||||
*
|
||||
* @internal
|
||||
* Used internally by the Intersection Observer
|
||||
* @emits reached-bottom - Emitted when the user scrolls near the bottom
|
||||
*/
|
||||
@Emit("reached-bottom")
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
return true;
|
||||
if (entry.isIntersecting && !this.isLoading) {
|
||||
// Debounce the intersection event
|
||||
if (this.debounceTimeout) {
|
||||
window.clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
|
||||
this.debounceTimeout = window.setTimeout(() => {
|
||||
this.isLoading = true;
|
||||
this.$emit("reached-bottom", true);
|
||||
// Reset loading state after a short delay
|
||||
setTimeout(() => {
|
||||
this.isLoading = false;
|
||||
}, 1000);
|
||||
}, 300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
</p>
|
||||
|
||||
<p class="mt-4">
|
||||
Search for a topic, or search around your neighborhod under "Nearby".
|
||||
Search for a topic, or search around your neighborhood under "Nearby".
|
||||
</p>
|
||||
|
||||
<p class="mt-4">
|
||||
|
||||
257
src/components/ProfileSection.vue
Normal file
257
src/components/ProfileSection.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
/** * @file ProfileSection.vue * @description Component for managing user
|
||||
profile information * @author Matthew Raymer * @version 1.0.0 */
|
||||
|
||||
<template>
|
||||
<div class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8">
|
||||
<div v-if="loading" class="text-center mb-2">
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin text-slate-400"
|
||||
></font-awesome>
|
||||
Loading profile...
|
||||
</div>
|
||||
<div v-else class="flex items-center mb-2">
|
||||
<span class="font-bold">Public Profile</span>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="text-slate-400 fa-fw ml-2 cursor-pointer"
|
||||
@click="showProfileInfo"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="profileDesc"
|
||||
class="w-full h-32 p-2 border border-slate-300 rounded-md"
|
||||
placeholder="Write something about yourself for the public..."
|
||||
:readonly="loading || saving"
|
||||
:class="{ 'bg-slate-100': loading || saving }"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center mb-4" @click="toggleLocation">
|
||||
<input v-model="includeLocation" type="checkbox" class="mr-2" />
|
||||
<label for="includeLocation">Include Location</label>
|
||||
</div>
|
||||
<div v-if="includeLocation" class="mb-4 aspect-video">
|
||||
<p class="text-sm mb-2 text-slate-500">
|
||||
For your security, choose a location nearby but not exactly at your
|
||||
place.
|
||||
</p>
|
||||
|
||||
<l-map
|
||||
ref="profileMap"
|
||||
class="!z-40 rounded-md"
|
||||
@click="handleMapClick"
|
||||
@ready="onMapReady"
|
||||
>
|
||||
<l-tile-layer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
layer-type="base"
|
||||
name="OpenStreetMap"
|
||||
/>
|
||||
<l-marker
|
||||
v-if="latitude && longitude"
|
||||
:lat-lng="[latitude, longitude]"
|
||||
@click="confirmEraseLocation"
|
||||
/>
|
||||
</l-map>
|
||||
</div>
|
||||
<div v-if="!loading && !saving">
|
||||
<div class="flex justify-between items-center">
|
||||
<button
|
||||
class="mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||||
:disabled="loading || saving"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': loading || saving,
|
||||
}"
|
||||
@click="saveProfile"
|
||||
>
|
||||
Save Profile
|
||||
</button>
|
||||
<button
|
||||
class="mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||||
:disabled="loading || saving"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed':
|
||||
loading || saving || (!profileDesc && !includeLocation),
|
||||
}"
|
||||
@click="confirmDeleteProfile"
|
||||
>
|
||||
Delete Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loading">Loading...</div>
|
||||
<div v-else>Saving...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop, Emit } from "vue-facing-decorator";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
import { ProfileService } from "../services/ProfileService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LMap,
|
||||
LMarker,
|
||||
LTileLayer,
|
||||
},
|
||||
})
|
||||
export default class ProfileSection extends Vue {
|
||||
@Prop({ required: true }) activeDid!: string;
|
||||
@Prop({ required: true }) partnerApiServer!: string;
|
||||
|
||||
@Emit("profile-updated") profileUpdated() {}
|
||||
|
||||
loading = true;
|
||||
saving = false;
|
||||
profileDesc = "";
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
includeLocation = false;
|
||||
zoom = 2;
|
||||
|
||||
async mounted() {
|
||||
await this.loadProfile();
|
||||
}
|
||||
|
||||
async loadProfile() {
|
||||
try {
|
||||
const profile = await ProfileService.loadProfile(
|
||||
this.activeDid,
|
||||
this.partnerApiServer,
|
||||
);
|
||||
if (profile) {
|
||||
this.profileDesc = profile.description || "";
|
||||
this.latitude = profile.location?.lat || 0;
|
||||
this.longitude = profile.location?.lng || 0;
|
||||
this.includeLocation = !!(this.latitude && this.longitude);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error loading profile:", error);
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Loading Profile",
|
||||
text: "Your server profile is not available.",
|
||||
});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveProfile() {
|
||||
this.saving = true;
|
||||
try {
|
||||
await ProfileService.saveProfile(this.activeDid, this.partnerApiServer, {
|
||||
description: this.profileDesc,
|
||||
location: this.includeLocation
|
||||
? {
|
||||
lat: this.latitude,
|
||||
lng: this.longitude,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Profile Saved",
|
||||
text: "Your profile has been updated successfully.",
|
||||
});
|
||||
this.profileUpdated();
|
||||
} catch (error) {
|
||||
logger.error("Error saving profile:", error);
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Saving Profile",
|
||||
text: "There was an error saving your profile.",
|
||||
});
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
toggleLocation() {
|
||||
this.includeLocation = !this.includeLocation;
|
||||
if (!this.includeLocation) {
|
||||
this.latitude = 0;
|
||||
this.longitude = 0;
|
||||
this.zoom = 2;
|
||||
}
|
||||
}
|
||||
|
||||
handleMapClick(event: { latlng: { lat: number; lng: number } }) {
|
||||
this.latitude = event.latlng.lat;
|
||||
this.longitude = event.latlng.lng;
|
||||
}
|
||||
|
||||
onMapReady(map: L.Map) {
|
||||
const zoom = this.latitude && this.longitude ? 12 : 2;
|
||||
map.setView([this.latitude, this.longitude], zoom);
|
||||
}
|
||||
|
||||
confirmEraseLocation() {
|
||||
this.$notify({
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Erase Marker",
|
||||
text: "Are you sure you don't want to mark a location? This will erase the current location.",
|
||||
onYes: () => {
|
||||
this.latitude = 0;
|
||||
this.longitude = 0;
|
||||
this.zoom = 2;
|
||||
this.includeLocation = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async confirmDeleteProfile() {
|
||||
this.$notify({
|
||||
group: "modal",
|
||||
type: "confirm",
|
||||
title: "Delete Profile",
|
||||
text: "Are you sure you want to delete your public profile? This will remove your description and location from the server, and it cannot be undone.",
|
||||
onYes: this.deleteProfile,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProfile() {
|
||||
this.saving = true;
|
||||
try {
|
||||
await ProfileService.deleteProfile(this.activeDid, this.partnerApiServer);
|
||||
this.profileDesc = "";
|
||||
this.latitude = 0;
|
||||
this.longitude = 0;
|
||||
this.includeLocation = false;
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Profile Deleted",
|
||||
text: "Your profile has been deleted successfully.",
|
||||
});
|
||||
this.profileUpdated();
|
||||
} catch (error) {
|
||||
logger.error("Error deleting profile:", error);
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error Deleting Profile",
|
||||
text: "There was an error deleting your profile.",
|
||||
});
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
showProfileInfo() {
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "info",
|
||||
title: "Public Profile Information",
|
||||
text: "This data will be published for all to see, so be careful what your write. Your ID will only be shared with people who you allow to see your activity.",
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<ul class="flex text-2xl p-2 gap-2 max-w-3xl mx-auto">
|
||||
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
|
||||
<!-- Home Feed -->
|
||||
<li
|
||||
:class="{
|
||||
@@ -52,7 +52,7 @@
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<font-awesome icon="hand" class="fa-fw" />
|
||||
<span class="text-xs mt-1">your work</span>
|
||||
<span class="text-xs mt-1">yours</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import BaseDexie, { Table } from "dexie";
|
||||
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
|
||||
import { exportDB, ExportOptions } from "dexie-export-import";
|
||||
import * as R from "ramda";
|
||||
import Dexie from "dexie";
|
||||
|
||||
import { Account, AccountsSchema } from "./tables/accounts";
|
||||
import { Contact, ContactSchema } from "./tables/contacts";
|
||||
@@ -26,19 +28,26 @@ type NonsensitiveTables = {
|
||||
};
|
||||
|
||||
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
|
||||
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
|
||||
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
|
||||
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
|
||||
BaseDexie & T;
|
||||
export type SecretDexie<T extends Record<string, Dexie.Table> = SecretTable> =
|
||||
BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
|
||||
export type SensitiveDexie<
|
||||
T extends Record<string, Dexie.Table> = SensitiveTables,
|
||||
> = BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
|
||||
export type NonsensitiveDexie<
|
||||
T extends Record<string, Dexie.Table> = NonsensitiveTables,
|
||||
> = BaseDexie & T & { export: (options?: ExportOptions) => Promise<Blob> };
|
||||
|
||||
//// Initialize the DBs, starting with the sensitive ones.
|
||||
|
||||
// Initialize Dexie database for secret, which is then used to encrypt accountsDB
|
||||
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
|
||||
secretDB.version(1).stores(SecretSchema);
|
||||
secretDB.export = (options) => exportDB(secretDB, options);
|
||||
|
||||
// Initialize Dexie database for accounts
|
||||
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;
|
||||
accountsDexie.version(1).stores(AccountsSchema);
|
||||
accountsDexie.export = (options) => exportDB(accountsDexie, options);
|
||||
|
||||
// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
|
||||
// so that it's clear whether the usage needs the private key inside.
|
||||
@@ -54,8 +63,15 @@ export const accountsDBPromise = useSecretAndInitializeAccountsDB(
|
||||
|
||||
//// Now initialize the other DB.
|
||||
|
||||
// Initialize Dexie databases for non-sensitive data
|
||||
// Initialize Dexie database for non-sensitive data
|
||||
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;
|
||||
db.version(1).stores({
|
||||
contacts: ContactSchema.contacts,
|
||||
logs: LogSchema.logs,
|
||||
settings: SettingsSchema.settings,
|
||||
temp: TempSchema.temp,
|
||||
});
|
||||
db.export = (options) => exportDB(db, options);
|
||||
|
||||
// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning
|
||||
|
||||
@@ -112,7 +128,6 @@ db.on("populate", async () => {
|
||||
// ended up throwing lots of errors to the user... and they'd end up in a state
|
||||
// where they couldn't take action because they couldn't unlock that identity.)
|
||||
|
||||
// check for the secret in storage
|
||||
async function useSecretAndInitializeAccountsDB(
|
||||
secretDB: SecretDexie,
|
||||
accountsDB: SensitiveDexie,
|
||||
@@ -214,6 +229,22 @@ export async function updateAccountSettings(
|
||||
}
|
||||
}
|
||||
|
||||
export async function logToDb(message: string): Promise<void> {
|
||||
await db.open();
|
||||
const todayKey = new Date().toDateString();
|
||||
// only keep one day's worth of logs
|
||||
const previous = await db.logs.get(todayKey);
|
||||
if (!previous) {
|
||||
// when this is today's first log, clear out everything previous
|
||||
// to avoid the log table getting too large
|
||||
// (let's limit a different way someday)
|
||||
await db.logs.clear();
|
||||
}
|
||||
const prevMessages = (previous && previous.message) || "";
|
||||
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
|
||||
await db.logs.update(todayKey, { message: fullMessage });
|
||||
}
|
||||
|
||||
// similar method is in the sw_scripts/additional-scripts.js file
|
||||
export async function logConsoleAndDb(
|
||||
message: string,
|
||||
@@ -224,16 +255,5 @@ export async function logConsoleAndDb(
|
||||
} else {
|
||||
logger.log(`${new Date().toISOString()} ${message}`);
|
||||
}
|
||||
|
||||
await db.open();
|
||||
const todayKey = new Date().toDateString();
|
||||
// only keep one day's worth of logs
|
||||
const previous = await db.logs.get(todayKey);
|
||||
if (!previous) {
|
||||
// when this is today's first log, clear out everything previous
|
||||
await db.logs.clear();
|
||||
}
|
||||
const prevMessages = (previous && previous.message) || "";
|
||||
const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
|
||||
await db.logs.update(todayKey, { message: fullMessage });
|
||||
await logToDb(message);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
export interface GenericVerifiableCredential {
|
||||
"@context"?: string;
|
||||
"@type": string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
agent?: { identifier: string } | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
34
src/interfaces/identifier.ts
Normal file
34
src/interfaces/identifier.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Interface for a Decentralized Identifier (DID)
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
|
||||
import { KeyMeta } from "../libs/crypto/vc";
|
||||
|
||||
export interface IKey {
|
||||
id: string;
|
||||
type: string;
|
||||
controller: string;
|
||||
ethereumAddress: string;
|
||||
publicKeyHex: string;
|
||||
privateKeyHex: string;
|
||||
meta?: KeyMeta;
|
||||
}
|
||||
|
||||
export interface IService {
|
||||
id: string;
|
||||
type: string;
|
||||
serviceEndpoint: string;
|
||||
description?: string;
|
||||
metadata?: {
|
||||
version?: string;
|
||||
capabilities?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IIdentifier {
|
||||
did: string;
|
||||
keys: IKey[];
|
||||
services: IService[];
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from "./limits";
|
||||
export * from "./records";
|
||||
export * from "./user";
|
||||
export * from "./deepLinks";
|
||||
export * from "./identifier";
|
||||
|
||||
288
src/interfaces/service.ts
Normal file
288
src/interfaces/service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @file service.ts
|
||||
* @description Service interfaces for Decentralized Identifiers (DIDs)
|
||||
*
|
||||
* This module defines the service interfaces used in the TimeSafari application.
|
||||
* Services are associated with DIDs to provide additional functionality and endpoints.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Base IService interface defines common service properties
|
||||
* 2. Specialized interfaces extend IService for specific service types
|
||||
* 3. Services are stored in IIdentifier.services array
|
||||
* 4. Services are loaded and managed by PlatformServiceFactory
|
||||
*
|
||||
* Service Types:
|
||||
* - EndorserService: Handles claims and endorsements
|
||||
* - PushNotificationService: Manages web push notifications
|
||||
* - ProfileService: Handles user profiles and settings
|
||||
* - BackupService: Manages data backup and restore
|
||||
*
|
||||
* @see IIdentifier
|
||||
* @see PlatformServiceFactory
|
||||
* @see DatabaseBackupService
|
||||
*/
|
||||
|
||||
/**
|
||||
* Base interface for all DID services
|
||||
*
|
||||
* This interface defines the core properties that all services must implement.
|
||||
* It follows the W3C DID specification for service endpoints.
|
||||
*
|
||||
* @example
|
||||
* const service: IService = {
|
||||
* id: 'endorser-service',
|
||||
* type: 'EndorserService',
|
||||
* serviceEndpoint: 'https://api.endorser.ch',
|
||||
* description: 'Endorser service for claims and endorsements',
|
||||
* metadata: {
|
||||
* version: '1.0.0',
|
||||
* capabilities: ['claims', 'endorsements'],
|
||||
* config: { apiServer: 'https://api.endorser.ch' }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export interface IService {
|
||||
/**
|
||||
* Unique identifier for the service
|
||||
* @example 'endorser-service'
|
||||
* @example 'push-notification-service'
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Type of service
|
||||
* @example 'EndorserService'
|
||||
* @example 'PushNotificationService'
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Endpoint URL for the service
|
||||
* @example 'https://api.endorser.ch'
|
||||
* @example 'https://push.timesafari.app'
|
||||
*/
|
||||
serviceEndpoint: string;
|
||||
|
||||
/**
|
||||
* Optional human-readable description of the service
|
||||
* @example 'Service for handling claims and endorsements'
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Optional metadata for service configuration
|
||||
*/
|
||||
metadata?: {
|
||||
/**
|
||||
* Service version in semantic versioning format
|
||||
* @example '1.0.0'
|
||||
*/
|
||||
version?: string;
|
||||
|
||||
/**
|
||||
* Array of service capabilities
|
||||
* @example ['claims', 'endorsements']
|
||||
* @example ['notifications', 'alerts']
|
||||
*/
|
||||
capabilities?: string[];
|
||||
|
||||
/**
|
||||
* Service-specific configuration
|
||||
* @example { apiServer: 'https://api.endorser.ch' }
|
||||
*/
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for handling claims and endorsements
|
||||
*
|
||||
* This service provides endpoints for:
|
||||
* - Submitting claims
|
||||
* - Managing endorsements
|
||||
* - Checking rate limits
|
||||
*
|
||||
* @example
|
||||
* const endorserService: IEndorserService = {
|
||||
* id: 'endorser-service',
|
||||
* type: 'EndorserService',
|
||||
* serviceEndpoint: 'https://api.endorser.ch',
|
||||
* metadata: {
|
||||
* version: '1.0.0',
|
||||
* capabilities: ['claims', 'endorsements'],
|
||||
* config: {
|
||||
* apiServer: 'https://api.endorser.ch',
|
||||
* rateLimits: {
|
||||
* claimsPerDay: 100,
|
||||
* endorsementsPerDay: 1000
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export interface IEndorserService extends IService {
|
||||
/** @override */
|
||||
type: "EndorserService";
|
||||
|
||||
/** @override */
|
||||
metadata: {
|
||||
version: string;
|
||||
capabilities: ["claims", "endorsements"];
|
||||
config: {
|
||||
/**
|
||||
* API server URL
|
||||
* @example 'https://api.endorser.ch'
|
||||
*/
|
||||
apiServer: string;
|
||||
|
||||
/**
|
||||
* Optional rate limits
|
||||
*/
|
||||
rateLimits?: {
|
||||
/**
|
||||
* Maximum claims per day
|
||||
* @default 100
|
||||
*/
|
||||
claimsPerDay: number;
|
||||
|
||||
/**
|
||||
* Maximum endorsements per day
|
||||
* @default 1000
|
||||
*/
|
||||
endorsementsPerDay: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing web push notifications
|
||||
*
|
||||
* This service provides endpoints for:
|
||||
* - Registering push subscriptions
|
||||
* - Sending push notifications
|
||||
* - Managing notification preferences
|
||||
*
|
||||
* @example
|
||||
* const pushService: IPushNotificationService = {
|
||||
* id: 'push-service',
|
||||
* type: 'PushNotificationService',
|
||||
* serviceEndpoint: 'https://push.timesafari.app',
|
||||
* metadata: {
|
||||
* version: '1.0.0',
|
||||
* capabilities: ['notifications'],
|
||||
* config: {
|
||||
* pushServer: 'https://push.timesafari.app',
|
||||
* vapidPublicKey: '...'
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export interface IPushNotificationService extends IService {
|
||||
/** @override */
|
||||
type: "PushNotificationService";
|
||||
|
||||
/** @override */
|
||||
metadata: {
|
||||
version: string;
|
||||
capabilities: ["notifications"];
|
||||
config: {
|
||||
/**
|
||||
* Push server URL
|
||||
* @example 'https://push.timesafari.app'
|
||||
*/
|
||||
pushServer: string;
|
||||
|
||||
/**
|
||||
* Optional VAPID public key for push notifications
|
||||
*/
|
||||
vapidPublicKey?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing user profiles and settings
|
||||
*
|
||||
* This service provides endpoints for:
|
||||
* - Managing user profiles
|
||||
* - Updating user settings
|
||||
* - Retrieving user preferences
|
||||
*
|
||||
* @example
|
||||
* const profileService: IProfileService = {
|
||||
* id: 'profile-service',
|
||||
* type: 'ProfileService',
|
||||
* serviceEndpoint: 'https://partner-api.endorser.ch',
|
||||
* metadata: {
|
||||
* version: '1.0.0',
|
||||
* capabilities: ['profile', 'settings'],
|
||||
* config: {
|
||||
* partnerApiServer: 'https://partner-api.endorser.ch'
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export interface IProfileService extends IService {
|
||||
/** @override */
|
||||
type: "ProfileService";
|
||||
|
||||
/** @override */
|
||||
metadata: {
|
||||
version: string;
|
||||
capabilities: ["profile", "settings"];
|
||||
config: {
|
||||
/**
|
||||
* Partner API server URL
|
||||
* @example 'https://partner-api.endorser.ch'
|
||||
*/
|
||||
partnerApiServer: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing data backup and restore operations
|
||||
*
|
||||
* This service provides endpoints for:
|
||||
* - Creating backups
|
||||
* - Restoring from backups
|
||||
* - Managing backup storage
|
||||
*
|
||||
* @example
|
||||
* const backupService: IBackupService = {
|
||||
* id: 'backup-service',
|
||||
* type: 'BackupService',
|
||||
* serviceEndpoint: 'https://backup.timesafari.app',
|
||||
* metadata: {
|
||||
* version: '1.0.0',
|
||||
* capabilities: ['backup', 'restore'],
|
||||
* config: {
|
||||
* storageType: 'cloud',
|
||||
* encryptionKey: '...'
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export interface IBackupService extends IService {
|
||||
/** @override */
|
||||
type: "BackupService";
|
||||
|
||||
/** @override */
|
||||
metadata: {
|
||||
version: string;
|
||||
capabilities: ["backup", "restore"];
|
||||
config: {
|
||||
/**
|
||||
* Storage type for backups
|
||||
* @default 'local'
|
||||
*/
|
||||
storageType: "local" | "cloud";
|
||||
|
||||
/**
|
||||
* Optional encryption key for backups
|
||||
*/
|
||||
encryptionKey?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,10 @@ export interface KeyMeta {
|
||||
* The Webauthn credential ID in hex, if this is from a passkey
|
||||
*/
|
||||
passkeyCredIdHex?: string;
|
||||
/**
|
||||
* The derivation path for the key
|
||||
*/
|
||||
derivationPath?: string;
|
||||
}
|
||||
|
||||
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver });
|
||||
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
} from "../interfaces";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export type { GenericVerifiableCredential, GenericCredWrapper };
|
||||
|
||||
/**
|
||||
* Standard context for schema.org data
|
||||
* @constant {string}
|
||||
|
||||
@@ -99,7 +99,6 @@ export function numberOrZero(str: string): number {
|
||||
return isNumeric(str) ? +str : 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* from https://tools.ietf.org/html/rfc3986#section-3
|
||||
* also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition
|
||||
|
||||
231
src/main.ts
231
src/main.ts
@@ -7,7 +7,169 @@ import axios from "axios";
|
||||
import VueAxios from "vue-axios";
|
||||
import Notifications from "notiwind";
|
||||
import "./assets/styles/tailwind.css";
|
||||
import { FontAwesomeIcon } from "./lib/fontawesome";
|
||||
|
||||
import { library } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEnvelopeOpenText,
|
||||
faEraser,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileContract,
|
||||
faFileLines,
|
||||
faFilter,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faMinus,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQuestion,
|
||||
faQrcode,
|
||||
faRightFromBracket,
|
||||
faRotate,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
library.add(
|
||||
faArrowDown,
|
||||
faArrowLeft,
|
||||
faArrowRight,
|
||||
faArrowRotateBackward,
|
||||
faArrowUpRightFromSquare,
|
||||
faArrowUp,
|
||||
faBan,
|
||||
faBitcoinSign,
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
faChevronDown,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faChevronUp,
|
||||
faCircle,
|
||||
faCircleCheck,
|
||||
faCircleInfo,
|
||||
faCircleQuestion,
|
||||
faCircleUser,
|
||||
faClock,
|
||||
faCoins,
|
||||
faComment,
|
||||
faCopy,
|
||||
faDollar,
|
||||
faEllipsis,
|
||||
faEllipsisVertical,
|
||||
faEnvelopeOpenText,
|
||||
faEraser,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileContract,
|
||||
faFileLines,
|
||||
faFilter,
|
||||
faFloppyDisk,
|
||||
faFolderOpen,
|
||||
faForward,
|
||||
faGift,
|
||||
faGlobe,
|
||||
faHammer,
|
||||
faHand,
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
faLink,
|
||||
faLocationDot,
|
||||
faLongArrowAltLeft,
|
||||
faLongArrowAltRight,
|
||||
faMagnifyingGlass,
|
||||
faMessage,
|
||||
faMinus,
|
||||
faPen,
|
||||
faPersonCircleCheck,
|
||||
faPersonCircleQuestion,
|
||||
faPlus,
|
||||
faQrcode,
|
||||
faQuestion,
|
||||
faRotate,
|
||||
faRightFromBracket,
|
||||
faShareNodes,
|
||||
faSpinner,
|
||||
faSquare,
|
||||
faSquareCaretDown,
|
||||
faSquareCaretUp,
|
||||
faSquarePlus,
|
||||
faTrashCan,
|
||||
faTriangleExclamation,
|
||||
faUser,
|
||||
faUsers,
|
||||
faXmark,
|
||||
);
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
@@ -38,14 +200,63 @@ function setupGlobalErrorHandler(app: VueApp) {
|
||||
};
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
.component("fa", FontAwesomeIcon)
|
||||
.component("camera", Camera)
|
||||
.use(createPinia())
|
||||
.use(VueAxios, axios)
|
||||
.use(router)
|
||||
.use(Notifications);
|
||||
const app = createApp(App);
|
||||
|
||||
setupGlobalErrorHandler(app);
|
||||
// Add global error handler for component registration
|
||||
app.config.errorHandler = (err, vm, info) => {
|
||||
logger.error("Vue global error:", {
|
||||
error:
|
||||
err instanceof Error
|
||||
? {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}
|
||||
: err,
|
||||
componentName: vm?.$options?.name || "unknown",
|
||||
info,
|
||||
componentData: vm
|
||||
? {
|
||||
hasRouter: !!vm.$router,
|
||||
hasNotify: !!vm.$notify,
|
||||
hasAxios: !!vm.axios,
|
||||
}
|
||||
: "no vm data",
|
||||
});
|
||||
};
|
||||
|
||||
app.mount("#app");
|
||||
// Register components and plugins with error handling
|
||||
try {
|
||||
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera);
|
||||
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
logger.log("Pinia store initialized");
|
||||
|
||||
app.use(VueAxios, axios);
|
||||
logger.log("Axios initialized");
|
||||
|
||||
app.use(router);
|
||||
logger.log("Router initialized");
|
||||
|
||||
app.use(Notifications);
|
||||
logger.log("Notifications initialized");
|
||||
|
||||
setupGlobalErrorHandler(app);
|
||||
logger.log("Global error handler setup");
|
||||
|
||||
// Mount the app
|
||||
app.mount("#app");
|
||||
logger.log("App mounted successfully");
|
||||
} catch (error) {
|
||||
logger.error("Critical error during app initialization:", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error,
|
||||
});
|
||||
}
|
||||
|
||||
82
src/platforms/capacitor/DatabaseBackupService.ts
Normal file
82
src/platforms/capacitor/DatabaseBackupService.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file DatabaseBackupService.ts
|
||||
* @description Capacitor-specific implementation of database backup service
|
||||
*
|
||||
* This service handles database backup operations on Capacitor platforms (Android/iOS)
|
||||
* using the Filesystem and Share plugins. It creates a temporary backup file,
|
||||
* writes the backup data to it, and shares the file using the platform's share sheet.
|
||||
*/
|
||||
|
||||
import { Filesystem, Directory } from "@capacitor/filesystem";
|
||||
import { Share } from "@capacitor/share";
|
||||
import { DatabaseBackupService as BaseDatabaseBackupService } from "../../services/DatabaseBackupService";
|
||||
import { log, error } from "../../utils/logger";
|
||||
|
||||
export class DatabaseBackupService extends BaseDatabaseBackupService {
|
||||
/**
|
||||
* Handles the backup process for Capacitor platforms
|
||||
*
|
||||
* @param base64Data - Backup data in base64 format
|
||||
* @param arrayBuffer - Backup data as ArrayBuffer
|
||||
* @param blob - Backup data as Blob
|
||||
*/
|
||||
protected async handleBackup(
|
||||
base64Data: string,
|
||||
_arrayBuffer: ArrayBuffer,
|
||||
_blob: Blob,
|
||||
): Promise<void> {
|
||||
try {
|
||||
log("Starting backup process for Capacitor platform");
|
||||
|
||||
// Create a timestamped backup file name
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupFileName = `timesafari-backup-${timestamp}.json`;
|
||||
const backupFilePath = `backups/${backupFileName}`;
|
||||
|
||||
log("Creating backup file:", {
|
||||
fileName: backupFileName,
|
||||
path: backupFilePath,
|
||||
});
|
||||
|
||||
// Write the backup file
|
||||
const writeResult = (await Filesystem.writeFile({
|
||||
path: backupFilePath,
|
||||
data: base64Data,
|
||||
directory: Directory.Cache,
|
||||
recursive: true,
|
||||
})) as unknown as { uri: string };
|
||||
|
||||
if (!writeResult.uri) {
|
||||
throw new Error("Failed to write backup file: No URI returned");
|
||||
}
|
||||
|
||||
log("Backup file written successfully:", { uri: writeResult.uri });
|
||||
|
||||
// Share the backup file
|
||||
log("Sharing backup file");
|
||||
await Share.share({
|
||||
title: "TimeSafari Backup",
|
||||
text: "Your TimeSafari backup file",
|
||||
url: writeResult.uri,
|
||||
dialogTitle: "Share TimeSafari Backup",
|
||||
});
|
||||
|
||||
log("Backup shared successfully");
|
||||
|
||||
// Clean up the temporary file
|
||||
try {
|
||||
await Filesystem.deleteFile({
|
||||
path: backupFilePath,
|
||||
directory: Directory.Cache,
|
||||
});
|
||||
log("Temporary backup file cleaned up");
|
||||
} catch (cleanupError) {
|
||||
error("Failed to clean up temporary backup file:", cleanupError);
|
||||
// Don't throw here as the backup was successful
|
||||
}
|
||||
} catch (err) {
|
||||
error("Error during backup process:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,13 @@ import {
|
||||
RouteLocationNormalized,
|
||||
RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { accountsDBPromise } from "../db/index";
|
||||
import {
|
||||
accountsDBPromise,
|
||||
retrieveSettingsForActiveAccount,
|
||||
} from "../db/index";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Component as VueComponent } from "vue-facing-decorator";
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -35,7 +40,79 @@ const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: "/account",
|
||||
name: "account",
|
||||
component: () => import("../views/AccountViewView.vue"),
|
||||
component: () => {
|
||||
logger.log("Starting lazy load of AccountViewView");
|
||||
return new Promise((resolve) => {
|
||||
import("../views/AccountViewView.vue")
|
||||
.then((module) => {
|
||||
if (!module?.default) {
|
||||
logger.error(
|
||||
"AccountViewView module loaded but default export is missing",
|
||||
{
|
||||
module: {
|
||||
hasDefault: !!module?.default,
|
||||
keys: Object.keys(module || {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
resolve(createErrorComponent());
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the component has the required dependencies
|
||||
const component = module.default;
|
||||
logger.log("AccountViewView loaded, checking dependencies...", {
|
||||
componentName: component.name,
|
||||
hasVueComponent: component instanceof VueComponent,
|
||||
hasClass: typeof component === "function",
|
||||
type: typeof component,
|
||||
});
|
||||
|
||||
resolve(component);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to load AccountViewView:", {
|
||||
error:
|
||||
err instanceof Error
|
||||
? {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}
|
||||
: err,
|
||||
type: typeof err,
|
||||
});
|
||||
|
||||
resolve(createErrorComponent());
|
||||
});
|
||||
});
|
||||
},
|
||||
beforeEnter: async (to, from, next) => {
|
||||
try {
|
||||
logger.log("Account route beforeEnter guard starting");
|
||||
|
||||
// Check if required dependencies are available
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
logger.log("Account route: settings loaded", {
|
||||
hasActiveDid: !!settings.activeDid,
|
||||
isRegistered: !!settings.isRegistered,
|
||||
});
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error("Error in account route beforeEnter:", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error,
|
||||
});
|
||||
next({ name: "home" });
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/claim/:id?",
|
||||
@@ -157,6 +234,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "InviteOneAcceptView",
|
||||
component: () => import("../views/InviteOneAcceptView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/logs",
|
||||
name: "logs",
|
||||
component: () => import("../views/LogView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/new-activity",
|
||||
name: "new-activity",
|
||||
@@ -281,6 +363,15 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "user-profile",
|
||||
component: () => import("../views/UserProfileView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/deep-link-error",
|
||||
name: "deep-link-error",
|
||||
component: () => import("../views/DeepLinkErrorView.vue"),
|
||||
meta: {
|
||||
title: "Invalid Deep Link",
|
||||
requiresAuth: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const isElectron = window.location.protocol === "file:";
|
||||
@@ -301,25 +392,271 @@ const router = createRouter({
|
||||
// Replace initial URL to start at `/` if necessary
|
||||
router.replace(initialPath || "/");
|
||||
|
||||
const errorHandler = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any,
|
||||
to: RouteLocationNormalized,
|
||||
from: RouteLocationNormalized,
|
||||
) => {
|
||||
// Handle the error here
|
||||
logger.error("Caught in top level error handler:", error, to, from);
|
||||
alert("Something is very wrong. Try reloading or restarting the app.");
|
||||
// Add global error handler
|
||||
router.onError((error, to, from) => {
|
||||
logger.error("Router error:", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error,
|
||||
to: {
|
||||
name: to.name,
|
||||
path: to.path,
|
||||
},
|
||||
from: {
|
||||
name: from.name,
|
||||
path: from.path,
|
||||
},
|
||||
});
|
||||
|
||||
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
||||
};
|
||||
// If it's a reference error during account view import, try to handle it gracefully
|
||||
if (error instanceof ReferenceError && to.name === "account") {
|
||||
logger.error("Account view import error:", {
|
||||
error:
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
: error,
|
||||
});
|
||||
// Instead of redirecting, let the component's error handling take over
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
router.onError(errorHandler); // Assign the error handler to the router instance
|
||||
// Add navigation guard for debugging
|
||||
router.beforeEach((to, from, next) => {
|
||||
logger.log("Navigation debug:", {
|
||||
to: {
|
||||
fullPath: to.fullPath,
|
||||
path: to.path,
|
||||
name: to.name,
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
},
|
||||
from: {
|
||||
fullPath: from.fullPath,
|
||||
path: from.path,
|
||||
name: from.name,
|
||||
params: from.params,
|
||||
query: from.query,
|
||||
},
|
||||
});
|
||||
|
||||
// router.beforeEach((to, from, next) => {
|
||||
// console.log("Navigating to view:", to.name);
|
||||
// console.log("From view:", from.name);
|
||||
// next();
|
||||
// });
|
||||
// For account route, try to preload the component
|
||||
if (to.name === "account") {
|
||||
logger.log("Preloading account component...");
|
||||
|
||||
// Wrap in try-catch and use Promise
|
||||
new Promise((resolve) => {
|
||||
logger.log("Starting dynamic import of AccountViewView");
|
||||
|
||||
// Add immediate try-catch to get more context
|
||||
try {
|
||||
const importPromise = import("../views/AccountViewView.vue");
|
||||
logger.log("Import initiated successfully");
|
||||
|
||||
importPromise
|
||||
.then((module) => {
|
||||
try {
|
||||
logger.log("Import completed, analyzing module:", {
|
||||
moduleExists: !!module,
|
||||
moduleType: typeof module,
|
||||
moduleKeys: Object.keys(module || {}),
|
||||
hasDefault: !!module?.default,
|
||||
defaultType: module?.default
|
||||
? typeof module.default
|
||||
: "undefined",
|
||||
defaultConstructor: module?.default?.constructor?.name,
|
||||
moduleContent: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(module).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === "function"
|
||||
? "function"
|
||||
: typeof value === "object"
|
||||
? Object.keys(value || {})
|
||||
: typeof value,
|
||||
]),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
if (!module?.default) {
|
||||
logger.error(
|
||||
"AccountViewView preload: module loaded but default export is missing",
|
||||
{
|
||||
module: {
|
||||
hasDefault: !!module?.default,
|
||||
keys: Object.keys(module || {}),
|
||||
moduleType: typeof module,
|
||||
exports: Object.keys(module || {}).map((key) => ({
|
||||
key,
|
||||
type: typeof (module as any)[key],
|
||||
value:
|
||||
typeof (module as any)[key] === "function"
|
||||
? "function"
|
||||
: typeof (module as any)[key] === "object"
|
||||
? Object.keys((module as any)[key] || {})
|
||||
: (module as any)[key],
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const component = module.default;
|
||||
|
||||
// Try to safely inspect the component
|
||||
const componentDetails = {
|
||||
componentName: component.name,
|
||||
hasVueComponent: component instanceof VueComponent,
|
||||
hasClass: typeof component === "function",
|
||||
type: typeof component,
|
||||
properties: Object.keys(component),
|
||||
decorators: Object.getOwnPropertyDescriptor(
|
||||
component,
|
||||
"__decorators",
|
||||
),
|
||||
vueOptions:
|
||||
(component as any).__vccOpts ||
|
||||
(component as any).options ||
|
||||
null,
|
||||
setup: typeof (component as any).setup === "function",
|
||||
render: typeof (component as any).render === "function",
|
||||
components: (component as any).components
|
||||
? Object.keys((component as any).components)
|
||||
: null,
|
||||
imports: Object.keys(module).filter((key) => key !== "default"),
|
||||
};
|
||||
|
||||
logger.log("Successfully analyzed component:", componentDetails);
|
||||
resolve(component);
|
||||
} catch (analysisError) {
|
||||
logger.error("Error during component analysis:", {
|
||||
error:
|
||||
analysisError instanceof Error
|
||||
? {
|
||||
name: analysisError.name,
|
||||
message: analysisError.message,
|
||||
stack: analysisError.stack,
|
||||
keys: Object.keys(analysisError),
|
||||
properties: Object.getOwnPropertyNames(analysisError),
|
||||
}
|
||||
: analysisError,
|
||||
type: typeof analysisError,
|
||||
phase: "analysis",
|
||||
});
|
||||
resolve(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("Failed to preload account component:", {
|
||||
error:
|
||||
err instanceof Error
|
||||
? {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
keys: Object.keys(err),
|
||||
properties: Object.getOwnPropertyNames(err),
|
||||
}
|
||||
: err,
|
||||
type: typeof err,
|
||||
context: {
|
||||
routeName: to.name,
|
||||
routePath: to.path,
|
||||
fromRoute: from.name,
|
||||
},
|
||||
phase: "module-load",
|
||||
});
|
||||
resolve(null);
|
||||
});
|
||||
} catch (immediateError) {
|
||||
logger.error("Immediate error during import initiation:", {
|
||||
error:
|
||||
immediateError instanceof Error
|
||||
? {
|
||||
name: immediateError.name,
|
||||
message: immediateError.message,
|
||||
stack: immediateError.stack,
|
||||
keys: Object.keys(immediateError),
|
||||
properties: Object.getOwnPropertyNames(immediateError),
|
||||
}
|
||||
: immediateError,
|
||||
type: typeof immediateError,
|
||||
context: {
|
||||
routeName: to.name,
|
||||
routePath: to.path,
|
||||
fromRoute: from.name,
|
||||
importPath: "../views/AccountViewView.vue",
|
||||
},
|
||||
phase: "import",
|
||||
});
|
||||
resolve(null);
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.error("Critical error in account component preload:", {
|
||||
error:
|
||||
err instanceof Error
|
||||
? {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
}
|
||||
: err,
|
||||
context: {
|
||||
routeName: to.name,
|
||||
routePath: to.path,
|
||||
fromRoute: from.name,
|
||||
},
|
||||
phase: "wrapper",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Always call next() to continue navigation
|
||||
next();
|
||||
});
|
||||
|
||||
function createErrorComponent() {
|
||||
return defineComponent({
|
||||
name: "AccountViewError",
|
||||
components: {
|
||||
// Add any required components here
|
||||
},
|
||||
setup() {
|
||||
const goHome = () => {
|
||||
router.push({ name: "home" });
|
||||
};
|
||||
|
||||
return {
|
||||
goHome,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<section class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<h1 class="text-4xl text-center font-light mb-8">Error Loading Account View</h1>
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<strong class="font-bold">Failed to load account view.</strong>
|
||||
<span class="block sm:inline"> Please try refreshing the page.</span>
|
||||
</div>
|
||||
<div class="mt-4 text-center">
|
||||
<button @click="goHome" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||
Return to Home
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
95
src/services/DatabaseBackupService.ts
Normal file
95
src/services/DatabaseBackupService.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file DatabaseBackupService.ts
|
||||
* @description Base service class for handling database backup operations
|
||||
*
|
||||
* This service implements the Template Method pattern to provide a common interface
|
||||
* for database backup operations across different platforms. It defines the structure
|
||||
* of backup operations while delegating platform-specific implementations to subclasses.
|
||||
*
|
||||
* Build Process Integration:
|
||||
* 1. Platform-Specific Implementation:
|
||||
* - Each platform (web, electron, capacitor) has its own implementation
|
||||
* - Implementations are loaded dynamically via PlatformServiceFactory
|
||||
* - Located in ./platforms/{platform}/DatabaseBackupService.ts
|
||||
*
|
||||
* 2. Build Configuration:
|
||||
* - Vite config files (vite.config.*.mts) set VITE_PLATFORM
|
||||
* - PlatformServiceFactory uses this to load correct implementation
|
||||
* - Build process creates separate chunks for each platform
|
||||
*
|
||||
* 3. Data Handling:
|
||||
* - Supports multiple data formats (base64, ArrayBuffer, Blob)
|
||||
* - Platform implementations handle format conversion
|
||||
* - Ensures consistent backup format across platforms
|
||||
*
|
||||
* Usage:
|
||||
* - Create backup: DatabaseBackupService.createAndShareBackup(data)
|
||||
* - Platform-specific: new WebDatabaseBackupService().handleBackup()
|
||||
*
|
||||
* @see PlatformServiceFactory.ts
|
||||
* @see vite.config.web.mts
|
||||
* @see vite.config.electron.mts
|
||||
* @see vite.config.capacitor.mts
|
||||
*/
|
||||
|
||||
import { PlatformServiceFactory } from "./PlatformServiceFactory";
|
||||
import { log, error } from "../utils/logger";
|
||||
|
||||
export class DatabaseBackupService {
|
||||
/**
|
||||
* Template method that must be implemented by platform-specific services
|
||||
* @param base64Data - Backup data in base64 format
|
||||
* @param arrayBuffer - Backup data as ArrayBuffer
|
||||
* @param blob - Backup data as Blob
|
||||
* @throws Error if not implemented by subclass
|
||||
*/
|
||||
protected async handleBackup(
|
||||
_base64Data: string,
|
||||
_arrayBuffer: ArrayBuffer,
|
||||
_blob: Blob,
|
||||
): Promise<void> {
|
||||
throw new Error(
|
||||
"handleBackup must be implemented by platform-specific service",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create and share a backup
|
||||
* Uses PlatformServiceFactory to get platform-specific implementation
|
||||
*
|
||||
* @param base64Data - Backup data in base64 format
|
||||
* @param arrayBuffer - Backup data as ArrayBuffer
|
||||
* @param blob - Backup data as Blob
|
||||
* @returns Promise that resolves when backup is complete
|
||||
*/
|
||||
public static async createAndShareBackup(
|
||||
base64Data: string,
|
||||
arrayBuffer: ArrayBuffer,
|
||||
blob: Blob,
|
||||
): Promise<void> {
|
||||
try {
|
||||
log("Creating platform-specific backup service");
|
||||
const backupService = await this.getPlatformSpecificBackupService();
|
||||
log("Backup service created successfully");
|
||||
|
||||
log("Executing platform-specific backup");
|
||||
await backupService.handleBackup(base64Data, arrayBuffer, blob);
|
||||
log("Backup completed successfully");
|
||||
} catch (err) {
|
||||
error("Error during backup creation:", err);
|
||||
if (err instanceof Error) {
|
||||
error("Error details:", {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private static async getPlatformSpecificBackupService(): Promise<DatabaseBackupService> {
|
||||
const factory = PlatformServiceFactory.getInstance();
|
||||
return await factory.createDatabaseBackupService();
|
||||
}
|
||||
}
|
||||
195
src/services/PlatformServiceFactory.ts
Normal file
195
src/services/PlatformServiceFactory.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file PlatformServiceFactory.ts
|
||||
* @description Factory for creating platform-specific service implementations
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*
|
||||
* This factory implements the Abstract Factory pattern to create platform-specific
|
||||
* implementations of services. It uses Vite's dynamic import feature to load the
|
||||
* appropriate implementation based on the current platform (web, electron, etc.).
|
||||
*
|
||||
* Architecture:
|
||||
* 1. Singleton Pattern:
|
||||
* - Ensures only one factory instance exists
|
||||
* - Manages platform-specific service instances
|
||||
* - Maintains consistent state across the application
|
||||
*
|
||||
* 2. Dynamic Loading:
|
||||
* - Uses Vite's dynamic import for platform-specific code
|
||||
* - Loads services on-demand based on platform
|
||||
* - Handles platform detection and service instantiation
|
||||
*
|
||||
* 3. Platform Detection:
|
||||
* - Uses VITE_PLATFORM environment variable
|
||||
* - Supports web, electron, and capacitor platforms
|
||||
* - Falls back to 'web' if platform is not specified
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* // Get factory instance
|
||||
* const factory = PlatformServiceFactory.getInstance();
|
||||
*
|
||||
* // Create platform-specific service
|
||||
* const backupService = await factory.createDatabaseBackupService();
|
||||
* ```
|
||||
*
|
||||
* @see vite.config.web.mts
|
||||
* @see vite.config.electron.mts
|
||||
* @see vite.config.capacitor.mts
|
||||
* @see DatabaseBackupService
|
||||
*/
|
||||
|
||||
import { DatabaseBackupService } from "./DatabaseBackupService";
|
||||
import { DatabaseBackupService as StubDatabaseBackupService } from "./platforms/empty";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* Factory class for creating platform-specific service implementations
|
||||
*
|
||||
* This class manages the creation and instantiation of platform-specific
|
||||
* service implementations. It uses the Abstract Factory pattern to provide
|
||||
* a consistent interface for creating services across different platforms.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get factory instance
|
||||
* const factory = PlatformServiceFactory.getInstance();
|
||||
*
|
||||
* // Create platform-specific service
|
||||
* const backupService = await factory.createDatabaseBackupService();
|
||||
*
|
||||
* // Use the service
|
||||
* await backupService.handleBackup(data);
|
||||
* ```
|
||||
*/
|
||||
export class PlatformServiceFactory {
|
||||
/**
|
||||
* Singleton instance of the factory
|
||||
* @private
|
||||
*/
|
||||
private static instance: PlatformServiceFactory;
|
||||
|
||||
/**
|
||||
* Current platform identifier
|
||||
* @private
|
||||
*/
|
||||
private platform: string;
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
*
|
||||
* Initializes the factory with the current platform from environment variables.
|
||||
* Falls back to 'web' if no platform is specified.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private constructor() {
|
||||
this.platform = import.meta.env.VITE_PLATFORM || "web";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of the factory
|
||||
*
|
||||
* Creates a new instance if one doesn't exist, otherwise returns
|
||||
* the existing instance.
|
||||
*
|
||||
* @returns {PlatformServiceFactory} The singleton factory instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const factory = PlatformServiceFactory.getInstance();
|
||||
* ```
|
||||
*/
|
||||
public static getInstance(): PlatformServiceFactory {
|
||||
if (!PlatformServiceFactory.instance) {
|
||||
PlatformServiceFactory.instance = new PlatformServiceFactory();
|
||||
}
|
||||
return PlatformServiceFactory.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a platform-specific database backup service
|
||||
*
|
||||
* Dynamically loads and instantiates the appropriate implementation
|
||||
* based on the current platform. The implementation is loaded from
|
||||
* the platforms/{platform}/DatabaseBackupService.ts file.
|
||||
*
|
||||
* @returns {Promise<DatabaseBackupService>} A promise that resolves to a platform-specific backup service
|
||||
* @throws {Error} If the service fails to load or instantiate
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const factory = PlatformServiceFactory.getInstance();
|
||||
* try {
|
||||
* const backupService = await factory.createDatabaseBackupService();
|
||||
* await backupService.handleBackup(data);
|
||||
* } catch (error) {
|
||||
* logger.error('Failed to create backup service:', error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
public async createDatabaseBackupService(): Promise<DatabaseBackupService> {
|
||||
// List of supported platforms for web builds
|
||||
const webSupportedPlatforms = ["web", "capacitor", "electron"];
|
||||
|
||||
// Return stub implementation for unsupported platforms
|
||||
if (!webSupportedPlatforms.includes(this.platform)) {
|
||||
logger.log(
|
||||
`Using stub implementation for unsupported platform: ${this.platform}`,
|
||||
);
|
||||
return new StubDatabaseBackupService();
|
||||
}
|
||||
|
||||
try {
|
||||
logger.log(`Loading platform-specific service for ${this.platform}`);
|
||||
// Use dynamic import with platform-specific path
|
||||
const module = await import(
|
||||
/* @vite-ignore */
|
||||
`./platforms/${this.platform}/DatabaseBackupService.ts`
|
||||
);
|
||||
logger.log("Platform service loaded successfully");
|
||||
return new module.DatabaseBackupService();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[TimeSafari] Failed to load platform-specific service for ${this.platform}:`,
|
||||
error,
|
||||
);
|
||||
// Fallback to stub implementation on error
|
||||
logger.log("Falling back to stub implementation");
|
||||
return new StubDatabaseBackupService();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current platform identifier
|
||||
*
|
||||
* @returns {string} The current platform identifier
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const factory = PlatformServiceFactory.getInstance();
|
||||
* logger.log(factory.getPlatform()); // 'web', 'electron', or 'capacitor'
|
||||
* ```
|
||||
*/
|
||||
public getPlatform(): string {
|
||||
return this.platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current platform identifier
|
||||
*
|
||||
* This method is primarily used for testing purposes to override
|
||||
* the platform detection. Use with caution in production code.
|
||||
*
|
||||
* @param {string} platform - The platform identifier to set
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const factory = PlatformServiceFactory.getInstance();
|
||||
* factory.setPlatform('electron'); // For testing purposes only
|
||||
* ```
|
||||
*/
|
||||
public setPlatform(platform: string): void {
|
||||
this.platform = platform;
|
||||
}
|
||||
}
|
||||
105
src/services/ProfileService.ts
Normal file
105
src/services/ProfileService.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* @file ProfileService.ts
|
||||
* @description Service class for handling user profile operations
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
import { getHeaders } from "../libs/endorserServer";
|
||||
import type { UserProfile } from "@/types/interfaces";
|
||||
|
||||
export class ProfileService {
|
||||
/**
|
||||
* Saves a user profile to the server
|
||||
* @param activeDid - The user's active DID
|
||||
* @param partnerApiServer - The partner API server URL
|
||||
* @param profile - The profile data to save
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
static async saveProfile(
|
||||
activeDid: string,
|
||||
partnerApiServer: string,
|
||||
profile: Partial<UserProfile>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await fetch(
|
||||
`${partnerApiServer}/api/partner/userProfile`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(profile),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save profile: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error saving profile:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user profile from the server
|
||||
* @param activeDid - The user's active DID
|
||||
* @param partnerApiServer - The partner API server URL
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
static async deleteProfile(
|
||||
activeDid: string,
|
||||
partnerApiServer: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await fetch(
|
||||
`${partnerApiServer}/api/partner/userProfile`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete profile: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error deleting profile:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a user profile from the server
|
||||
* @param activeDid - The user's active DID
|
||||
* @param partnerApiServer - The partner API server URL
|
||||
* @returns Promise<UserProfile | null>
|
||||
*/
|
||||
static async loadProfile(
|
||||
activeDid: string,
|
||||
partnerApiServer: string,
|
||||
): Promise<UserProfile | null> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await fetch(
|
||||
`${partnerApiServer}/api/partner/userProfileForIssuer/${activeDid}`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load profile: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error("Error loading profile:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/services/RateLimitsService.ts
Normal file
110
src/services/RateLimitsService.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file RateLimitsService.ts
|
||||
* @description Service class for handling rate limit operations
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { logger } from "../utils/logger";
|
||||
import { getHeaders } from "../libs/endorserServer";
|
||||
import type { EndorserRateLimits, ImageRateLimits } from "../interfaces/limits";
|
||||
import axios from "axios";
|
||||
|
||||
export class RateLimitsService {
|
||||
/**
|
||||
* Fetches rate limits for a given DID
|
||||
* @param apiServer - The API server URL
|
||||
* @param did - The user's DID
|
||||
* @returns Promise<EndorserRateLimits>
|
||||
*/
|
||||
static async fetchRateLimits(
|
||||
apiServer: string,
|
||||
did: string,
|
||||
): Promise<EndorserRateLimits> {
|
||||
logger.log("Fetching rate limits for DID:", did);
|
||||
logger.log("Using API server:", apiServer);
|
||||
|
||||
try {
|
||||
const headers = await getHeaders(did);
|
||||
const response = await axios.get(
|
||||
`${apiServer}/api/v2/rate-limits/${did}`,
|
||||
{ headers },
|
||||
);
|
||||
logger.log("Rate limits response:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 400 || error.response?.status === 404)
|
||||
) {
|
||||
const errorData = error.response.data as {
|
||||
error?: { message?: string; code?: string };
|
||||
};
|
||||
if (
|
||||
errorData.error?.code === "UNREGISTERED_USER" ||
|
||||
error.response?.status === 404
|
||||
) {
|
||||
logger.log("User is not registered, returning default limits");
|
||||
return {
|
||||
doneClaimsThisWeek: "0",
|
||||
maxClaimsPerWeek: "0",
|
||||
nextWeekBeginDateTime: new Date().toISOString(),
|
||||
doneRegistrationsThisMonth: "0",
|
||||
maxRegistrationsPerMonth: "0",
|
||||
nextMonthBeginDateTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
logger.error("Error fetching rate limits:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches image rate limits for a given DID
|
||||
* @param apiServer - The API server URL
|
||||
* @param activeDid - The user's active DID
|
||||
* @returns Promise<ImageRateLimits>
|
||||
*/
|
||||
static async fetchImageRateLimits(
|
||||
apiServer: string,
|
||||
activeDid: string,
|
||||
): Promise<ImageRateLimits> {
|
||||
try {
|
||||
const headers = await getHeaders(activeDid);
|
||||
const response = await fetch(
|
||||
`${apiServer}/api/endorser/imageRateLimits/${activeDid}`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch image rate limits: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error("Error fetching image rate limits:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats rate limit error messages
|
||||
* @param error - The error object
|
||||
* @returns string
|
||||
*/
|
||||
static formatRateLimitError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const err = error as {
|
||||
response?: { data?: { error?: { message?: string } } };
|
||||
};
|
||||
return err.response?.data?.error?.message || "An unknown error occurred";
|
||||
}
|
||||
return "An unknown error occurred";
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,12 @@
|
||||
*/
|
||||
|
||||
import { Router } from "vue-router";
|
||||
import { deepLinkSchemas, baseUrlSchema } from "../types/deepLinks";
|
||||
import {
|
||||
deepLinkSchemas,
|
||||
baseUrlSchema,
|
||||
routeSchema,
|
||||
DeepLinkRoute,
|
||||
} from "../types/deepLinks";
|
||||
import { logConsoleAndDb } from "../db";
|
||||
import type { DeepLinkError } from "../interfaces/deepLinks";
|
||||
|
||||
@@ -111,7 +116,7 @@ export class DeepLinkHandler {
|
||||
): Promise<void> {
|
||||
const routeMap: Record<string, string> = {
|
||||
"user-profile": "user-profile",
|
||||
project: "project",
|
||||
"project-details": "project-details",
|
||||
"onboard-meeting-setup": "onboard-meeting-setup",
|
||||
"invite-one-accept": "invite-one-accept",
|
||||
"contact-import": "contact-import",
|
||||
@@ -124,25 +129,63 @@ export class DeepLinkHandler {
|
||||
did: "did",
|
||||
};
|
||||
|
||||
const routeName = routeMap[path];
|
||||
if (!routeName) {
|
||||
// First try to validate the route path
|
||||
let routeName: string;
|
||||
|
||||
try {
|
||||
// Validate route exists
|
||||
const validRoute = routeSchema.parse(path) as DeepLinkRoute;
|
||||
routeName = routeMap[validRoute];
|
||||
} catch (error) {
|
||||
// Log the invalid route attempt
|
||||
logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true);
|
||||
|
||||
// Redirect to error page with information about the invalid link
|
||||
await this.router.replace({
|
||||
name: "deep-link-error",
|
||||
query: {
|
||||
originalPath: path,
|
||||
errorCode: "INVALID_ROUTE",
|
||||
message: `The link you followed (${path}) is not supported`,
|
||||
},
|
||||
});
|
||||
|
||||
throw {
|
||||
code: "INVALID_ROUTE",
|
||||
message: `Unsupported route: ${path}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Validate parameters based on route type
|
||||
// Continue with parameter validation as before...
|
||||
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
|
||||
const validatedParams = await schema.parseAsync({
|
||||
...params,
|
||||
...query,
|
||||
});
|
||||
|
||||
await this.router.replace({
|
||||
name: routeName,
|
||||
params: validatedParams,
|
||||
query,
|
||||
});
|
||||
try {
|
||||
const validatedParams = await schema.parseAsync({
|
||||
...params,
|
||||
...query,
|
||||
});
|
||||
|
||||
await this.router.replace({
|
||||
name: routeName,
|
||||
params: validatedParams,
|
||||
query,
|
||||
});
|
||||
} catch (error) {
|
||||
// For parameter validation errors, provide specific error feedback
|
||||
await this.router.replace({
|
||||
name: "deep-link-error",
|
||||
query: {
|
||||
originalPath: path,
|
||||
errorCode: "INVALID_PARAMETERS",
|
||||
message: `The link parameters are invalid: ${(error as Error).message}`,
|
||||
},
|
||||
});
|
||||
|
||||
throw {
|
||||
code: "INVALID_PARAMETERS",
|
||||
message: (error as Error).message,
|
||||
details: error,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
src/services/platforms/capacitor/DatabaseBackupService.ts
Normal file
35
src/services/platforms/capacitor/DatabaseBackupService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @file DatabaseBackupService.ts
|
||||
* @description Capacitor-specific implementation of the DatabaseBackupService
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { DatabaseBackupService } from "../../DatabaseBackupService";
|
||||
import { Filesystem } from "@capacitor/filesystem";
|
||||
import { Share } from "@capacitor/share";
|
||||
|
||||
export default class CapacitorDatabaseBackupService extends DatabaseBackupService {
|
||||
protected async handleBackup(
|
||||
base64Data: string,
|
||||
_arrayBuffer: ArrayBuffer,
|
||||
_blob: Blob,
|
||||
): Promise<void> {
|
||||
// Capacitor platform handling
|
||||
const fileName = `database-backup-${new Date().toISOString()}.json`;
|
||||
const path = `backups/${fileName}`;
|
||||
|
||||
await Filesystem.writeFile({
|
||||
path,
|
||||
data: base64Data,
|
||||
directory: "CACHE",
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
await Share.share({
|
||||
title: "Database Backup",
|
||||
text: "Here's your database backup",
|
||||
url: path,
|
||||
});
|
||||
}
|
||||
}
|
||||
18
src/services/platforms/electron/DatabaseBackupService.ts
Normal file
18
src/services/platforms/electron/DatabaseBackupService.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DatabaseBackupService } from "../../DatabaseBackupService";
|
||||
import { dialog } from "electron";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
export default class ElectronDatabaseBackupService extends DatabaseBackupService {
|
||||
protected async handleBackup(base64Data: string): Promise<void> {
|
||||
const { filePath } = await dialog.showSaveDialog({
|
||||
title: "Save Database Backup",
|
||||
defaultPath: path.join(process.env.HOME || "", "database-backup.json"),
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
|
||||
if (filePath) {
|
||||
fs.writeFileSync(filePath, base64Data, "base64");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/services/platforms/empty.ts
Normal file
20
src/services/platforms/empty.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file empty.ts
|
||||
* @description Stub implementation for excluding platform-specific code
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { DatabaseBackupService } from "../DatabaseBackupService";
|
||||
|
||||
export default class StubDatabaseBackupService extends DatabaseBackupService {
|
||||
protected async handleBackup(
|
||||
_base64Data: string,
|
||||
_arrayBuffer: ArrayBuffer,
|
||||
_blob: Blob,
|
||||
): Promise<void> {
|
||||
throw new Error("This platform does not support database backups");
|
||||
}
|
||||
}
|
||||
|
||||
export { StubDatabaseBackupService as DatabaseBackupService };
|
||||
33
src/services/platforms/web/DatabaseBackupService.ts
Normal file
33
src/services/platforms/web/DatabaseBackupService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file DatabaseBackupService.ts
|
||||
* @description Web-specific implementation of the DatabaseBackupService
|
||||
* @author Matthew Raymer
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import { DatabaseBackupService } from "../../DatabaseBackupService";
|
||||
import { log, error } from "../../../utils/logger";
|
||||
|
||||
export default class WebDatabaseBackupService extends DatabaseBackupService {
|
||||
protected async handleBackup(
|
||||
_base64Data: string,
|
||||
_arrayBuffer: ArrayBuffer,
|
||||
blob: Blob,
|
||||
): Promise<void> {
|
||||
try {
|
||||
log("Starting web platform backup");
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `database-backup-${new Date().toISOString()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
log("Web platform backup completed");
|
||||
} catch (err) {
|
||||
error("Error during web platform backup:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/types/capacitor.d.ts
vendored
Normal file
64
src/types/capacitor.d.ts
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Type declarations for Capacitor modules used in the application.
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
|
||||
declare module "@capacitor/filesystem" {
|
||||
export interface FileWriteOptions {
|
||||
path: string;
|
||||
data: string;
|
||||
directory?: string;
|
||||
encoding?: string;
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
export interface FileReadResult {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface FileDeleteOptions {
|
||||
path: string;
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
export interface FilesystemDirectory {
|
||||
Cache: "CACHE";
|
||||
Documents: "DOCUMENTS";
|
||||
Data: "DATA";
|
||||
External: "EXTERNAL";
|
||||
ExternalStorage: "EXTERNAL_STORAGE";
|
||||
}
|
||||
|
||||
export interface Filesystem {
|
||||
writeFile(options: FileWriteOptions): Promise<void>;
|
||||
readFile(options: {
|
||||
path: string;
|
||||
directory?: string;
|
||||
}): Promise<FileReadResult>;
|
||||
deleteFile(options: FileDeleteOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export const Filesystem: Filesystem;
|
||||
export const Directory: FilesystemDirectory;
|
||||
export const Encoding: {
|
||||
UTF8: "utf8";
|
||||
ASCII: "ascii";
|
||||
UTF16: "utf16";
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@capacitor/share" {
|
||||
export interface ShareOptions {
|
||||
title?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
dialogTitle?: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface Share {
|
||||
share(options: ShareOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export const Share: Share;
|
||||
}
|
||||
@@ -27,13 +27,35 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
// Base URL validation schema
|
||||
// Add a union type of all valid route paths
|
||||
export const VALID_DEEP_LINK_ROUTES = [
|
||||
"user-profile",
|
||||
"project-details",
|
||||
"onboard-meeting-setup",
|
||||
"invite-one-accept",
|
||||
"contact-import",
|
||||
"confirm-gift",
|
||||
"claim",
|
||||
"claim-cert",
|
||||
"claim-add-raw",
|
||||
"contact-edit",
|
||||
"contacts",
|
||||
"did",
|
||||
] as const;
|
||||
|
||||
// Create a type from the array
|
||||
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
|
||||
|
||||
// Update your schema definitions to use this type
|
||||
export const baseUrlSchema = z.object({
|
||||
scheme: z.literal("timesafari"),
|
||||
path: z.string(),
|
||||
queryParams: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
// Use the type to ensure route validation
|
||||
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
|
||||
|
||||
// Parameter validation schemas for each route type
|
||||
export const deepLinkSchemas = {
|
||||
"user-profile": z.object({
|
||||
|
||||
32
src/types/index.ts
Normal file
32
src/types/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Index file for all type declarations.
|
||||
* @author Matthew Raymer
|
||||
*/
|
||||
|
||||
export * from "./interfaces";
|
||||
|
||||
import { GiveSummaryRecord, GiveVerifiableCredential } from "interfaces";
|
||||
|
||||
export interface GiveRecordWithContactInfo extends GiveSummaryRecord {
|
||||
jwtId: string;
|
||||
fullClaim: GiveVerifiableCredential;
|
||||
giver: {
|
||||
known: boolean;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
issuer: {
|
||||
known: boolean;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
receiver: {
|
||||
known: boolean;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
providerPlanName?: string;
|
||||
recipientProjectName?: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
430
src/types/interfaces.ts
Normal file
430
src/types/interfaces.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* @file interfaces.ts
|
||||
* @description Core type declarations for the TimeSafari application
|
||||
*
|
||||
* This module defines the core interfaces and types used throughout the application.
|
||||
* It serves as the central location for type definitions that are shared across
|
||||
* multiple components and services.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. DID (Decentralized Identifier) Types:
|
||||
* - IIdentifier: Core DID structure
|
||||
* - IKey: Cryptographic key information
|
||||
* - IService: Service endpoints and capabilities
|
||||
*
|
||||
* 2. Verifiable Credential Types:
|
||||
* - GenericCredWrapper: Base wrapper for all credentials
|
||||
* - GiveVerifiableCredential: Gift-related credentials
|
||||
* - OfferVerifiableCredential: Offer-related credentials
|
||||
* - RegisterVerifiableCredential: Registration credentials
|
||||
*
|
||||
* 3. Service Types:
|
||||
* - EndorserService: Claims and endorsements
|
||||
* - PushNotificationService: Web push notifications
|
||||
* - ProfileService: User profiles
|
||||
* - BackupService: Data backup
|
||||
*
|
||||
* @see src/interfaces/identifier.ts
|
||||
* @see src/interfaces/claims.ts
|
||||
* @see src/interfaces/limits.ts
|
||||
*/
|
||||
|
||||
import { GiveVerifiableCredential } from "../interfaces";
|
||||
|
||||
/**
|
||||
* Interface for a Decentralized Identifier (DID)
|
||||
*
|
||||
* This interface defines the structure of a DID, which is a unique identifier
|
||||
* that can be used to look up a DID document containing information associated
|
||||
* with the DID, such as public keys and service endpoints.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const identifier: IIdentifier = {
|
||||
* did: 'did:ethr:0x123...',
|
||||
* provider: 'ethr',
|
||||
* keys: [{
|
||||
* kid: 'keys-1',
|
||||
* kms: 'local',
|
||||
* type: 'Secp256k1',
|
||||
* publicKeyHex: '0x...',
|
||||
* meta: { derivationPath: "m/44'/60'/0'/0/0" }
|
||||
* }],
|
||||
* services: [{
|
||||
* id: 'endorser-service',
|
||||
* type: 'EndorserService',
|
||||
* serviceEndpoint: 'https://api.endorser.ch'
|
||||
* }]
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export interface IIdentifier {
|
||||
/**
|
||||
* The DID string in the format 'did:method:identifier'
|
||||
* @example 'did:ethr:0x123...'
|
||||
*/
|
||||
did: string;
|
||||
|
||||
/**
|
||||
* The DID method provider
|
||||
* @example 'ethr'
|
||||
*/
|
||||
provider: string;
|
||||
|
||||
/**
|
||||
* Array of cryptographic keys associated with the DID
|
||||
*/
|
||||
keys: Array<{
|
||||
/**
|
||||
* Key identifier
|
||||
* @example 'keys-1'
|
||||
*/
|
||||
kid: string;
|
||||
|
||||
/**
|
||||
* Key management system
|
||||
* @example 'local'
|
||||
*/
|
||||
kms: string;
|
||||
|
||||
/**
|
||||
* Key type
|
||||
* @example 'Secp256k1'
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Public key in hexadecimal format
|
||||
* @example '0x...'
|
||||
*/
|
||||
publicKeyHex: string;
|
||||
|
||||
/**
|
||||
* Optional metadata about the key
|
||||
*/
|
||||
meta?: {
|
||||
/**
|
||||
* HD wallet derivation path
|
||||
* @example "m/44'/60'/0'/0/0"
|
||||
*/
|
||||
derivationPath?: string;
|
||||
|
||||
/**
|
||||
* Key usage or purpose
|
||||
* @example "signing", "encryption"
|
||||
*/
|
||||
usage?: string;
|
||||
|
||||
/**
|
||||
* Key creation timestamp
|
||||
*/
|
||||
createdAt?: number;
|
||||
|
||||
/**
|
||||
* Additional key metadata
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Array of service endpoints associated with the DID
|
||||
*/
|
||||
services: Array<{
|
||||
/**
|
||||
* Service identifier
|
||||
* @example 'endorser-service'
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Service type
|
||||
* @example 'EndorserService'
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Service endpoint URL
|
||||
* @example 'https://api.endorser.ch'
|
||||
*/
|
||||
serviceEndpoint: string;
|
||||
|
||||
/**
|
||||
* Optional service description
|
||||
*/
|
||||
description?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Optional metadata about the identifier
|
||||
*/
|
||||
meta?: {
|
||||
/**
|
||||
* DID method-specific metadata
|
||||
* @example { network: "mainnet", chainId: 1 } for ethr
|
||||
*/
|
||||
method?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Identifier creation timestamp
|
||||
*/
|
||||
createdAt?: number;
|
||||
|
||||
/**
|
||||
* Last update timestamp
|
||||
*/
|
||||
updatedAt?: number;
|
||||
|
||||
/**
|
||||
* Additional identifier metadata
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a cryptographic key
|
||||
*
|
||||
* This interface defines the structure of a cryptographic key used in the
|
||||
* DID system. It includes both public and private key information, along
|
||||
* with metadata about the key's purpose and derivation.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const key: IKey = {
|
||||
* id: 'did:ethr:0x123...#keys-1',
|
||||
* type: 'Secp256k1VerificationKey2018',
|
||||
* controller: 'did:ethr:0x123...',
|
||||
* ethereumAddress: '0x123...',
|
||||
* publicKeyHex: '0x...',
|
||||
* privateKeyHex: '0x...',
|
||||
* meta: {
|
||||
* derivationPath: "m/44'/60'/0'/0/0"
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export interface IKey {
|
||||
/**
|
||||
* Unique identifier for the key
|
||||
* @example 'did:ethr:0x123...#keys-1'
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Key type specification
|
||||
* @example 'Secp256k1VerificationKey2018'
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* DID that controls this key
|
||||
* @example 'did:ethr:0x123...'
|
||||
*/
|
||||
controller: string;
|
||||
|
||||
/**
|
||||
* Associated Ethereum address
|
||||
* @example '0x123...'
|
||||
*/
|
||||
ethereumAddress: string;
|
||||
|
||||
/**
|
||||
* Public key in hexadecimal format
|
||||
* @example '0x...'
|
||||
*/
|
||||
publicKeyHex: string;
|
||||
|
||||
/**
|
||||
* Private key in hexadecimal format
|
||||
* @example '0x...'
|
||||
*/
|
||||
privateKeyHex: string;
|
||||
|
||||
/**
|
||||
* Optional metadata about the key
|
||||
*/
|
||||
meta?: {
|
||||
/**
|
||||
* HD wallet derivation path
|
||||
* @example "m/44'/60'/0'/0/0"
|
||||
*/
|
||||
derivationPath?: string;
|
||||
|
||||
/**
|
||||
* Key usage or purpose
|
||||
* @example "signing", "encryption"
|
||||
*/
|
||||
usage?: string;
|
||||
|
||||
/**
|
||||
* Key creation timestamp
|
||||
*/
|
||||
createdAt?: number;
|
||||
|
||||
/**
|
||||
* Additional key metadata
|
||||
*/
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a service endpoint
|
||||
*
|
||||
* This interface defines the structure of a service endpoint that can be
|
||||
* associated with a DID. Services provide additional functionality and
|
||||
* endpoints for DID operations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const service: IService = {
|
||||
* id: 'endorser-service',
|
||||
* type: 'EndorserService',
|
||||
* serviceEndpoint: 'https://api.endorser.ch',
|
||||
* description: 'Service for handling claims and endorsements',
|
||||
* metadata: {
|
||||
* version: '1.0.0',
|
||||
* capabilities: ['claims', 'endorsements'],
|
||||
* config: {
|
||||
* apiServer: 'https://api.endorser.ch'
|
||||
* }
|
||||
* }
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export interface IService {
|
||||
/**
|
||||
* Unique identifier for the service
|
||||
* @example 'endorser-service'
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Type of service
|
||||
* @example 'EndorserService'
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Service endpoint URL
|
||||
* @example 'https://api.endorser.ch'
|
||||
*/
|
||||
serviceEndpoint: string;
|
||||
|
||||
/**
|
||||
* Optional human-readable description
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* Optional service metadata
|
||||
*/
|
||||
metadata?: {
|
||||
/**
|
||||
* Service version
|
||||
* @example '1.0.0'
|
||||
*/
|
||||
version?: string;
|
||||
|
||||
/**
|
||||
* Array of service capabilities
|
||||
* @example ['claims', 'endorsements']
|
||||
*/
|
||||
capabilities?: string[];
|
||||
|
||||
/**
|
||||
* Service-specific configuration
|
||||
*/
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
status: "preparing" | "exporting" | "complete" | "error";
|
||||
message?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
/** User's profile description */
|
||||
description: string;
|
||||
/** User's location information */
|
||||
location?: {
|
||||
/** Latitude coordinate */
|
||||
lat: number;
|
||||
/** Longitude coordinate */
|
||||
lng: number;
|
||||
};
|
||||
/** User's given name */
|
||||
givenName?: string;
|
||||
/** User's family name */
|
||||
familyName?: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
export interface LeafletMouseEvent {
|
||||
latlng: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GiveRecordWithContactInfo {
|
||||
type?: string;
|
||||
agentDid: string;
|
||||
amount: number;
|
||||
amountConfirmed: number;
|
||||
description: string;
|
||||
fullClaim: GiveVerifiableCredential;
|
||||
fulfillsHandleId: string;
|
||||
fulfillsPlanHandleId?: string;
|
||||
fulfillsType?: string;
|
||||
handleId: string;
|
||||
issuedAt: string;
|
||||
issuerDid: string;
|
||||
jwtId: string;
|
||||
providerPlanHandleId?: string;
|
||||
recipientDid: string;
|
||||
unit: string;
|
||||
giver: {
|
||||
known: boolean;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
issuer: {
|
||||
known: boolean;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
receiver: {
|
||||
known: boolean;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
};
|
||||
providerPlanName?: string;
|
||||
recipientProjectName?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export interface TimeSafariError extends Error {
|
||||
/**
|
||||
* User-friendly error message
|
||||
*/
|
||||
userMessage?: string;
|
||||
|
||||
/**
|
||||
* Error code for programmatic handling
|
||||
*/
|
||||
code?: string;
|
||||
|
||||
/**
|
||||
* Additional error context
|
||||
*/
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,18 +1,108 @@
|
||||
import { logToDb } from "../db";
|
||||
|
||||
function safeStringify(obj: unknown) {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.stringify(obj, (key, value) => {
|
||||
// Skip Vue component instance properties
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
("$el" in value || "$options" in value || "$parent" in value)
|
||||
) {
|
||||
return "[Vue Component]";
|
||||
}
|
||||
|
||||
// Handle Vue router objects
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
("fullPath" in value || "path" in value || "name" in value)
|
||||
) {
|
||||
return {
|
||||
fullPath: value.fullPath,
|
||||
path: value.path,
|
||||
name: value.name,
|
||||
params: value.params,
|
||||
query: value.query,
|
||||
hash: value.hash,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle circular references
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
|
||||
// Handle functions
|
||||
if (typeof value === "function") {
|
||||
return `[Function: ${value.name || "anonymous"}]`;
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
function formatMessage(message: string, ...args: unknown[]): string {
|
||||
const prefix = "[TimeSafari]";
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
return `${prefix} ${message}${argsString}`;
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
log: (message: string, ...args: unknown[]) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message, ...args);
|
||||
console.log(formattedMessage);
|
||||
logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
|
||||
}
|
||||
},
|
||||
warn: (message: string, ...args: unknown[]) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(message, ...args);
|
||||
console.warn(formattedMessage);
|
||||
logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
|
||||
}
|
||||
},
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message, ...args); // Errors should always be logged
|
||||
console.error(formattedMessage);
|
||||
logToDb(message + (args.length > 0 ? " - " + safeStringify(args) : ""));
|
||||
},
|
||||
};
|
||||
|
||||
export function log(message: string, ...args: unknown[]): void {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(formattedMessage);
|
||||
}
|
||||
|
||||
export function error(message: string, ...args: unknown[]): void {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(formattedMessage);
|
||||
}
|
||||
|
||||
export function warn(message: string, ...args: unknown[]): void {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(formattedMessage);
|
||||
}
|
||||
|
||||
export function info(message: string, ...args: unknown[]): void {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(formattedMessage);
|
||||
}
|
||||
|
||||
export function debug(message: string, ...args: unknown[]): void {
|
||||
const formattedMessage = formatMessage(message, ...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(formattedMessage);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,17 @@
|
||||
<router-link :to="'/claim/' + claimId">
|
||||
<canvas ref="claimCanvas" class="w-full block mx-auto"></canvas>
|
||||
</router-link>
|
||||
<div class="qr-code-container">
|
||||
<QRCodeVue
|
||||
ref="qrCodeRef"
|
||||
:value="qrCodeData"
|
||||
:size="200"
|
||||
level="H"
|
||||
render-as="svg"
|
||||
:margin="0"
|
||||
:color="{ dark: '#000000', light: '#ffffff' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -13,13 +24,17 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { nextTick } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||
import QRCodeVue from "qrcode.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
|
||||
import { logger } from "../utils/logger";
|
||||
@Component
|
||||
@Component({
|
||||
components: {
|
||||
QRCodeVue,
|
||||
},
|
||||
})
|
||||
export default class ClaimCertificateView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@@ -31,6 +46,8 @@ export default class ClaimCertificateView extends Vue {
|
||||
|
||||
serverUtil = serverUtil;
|
||||
|
||||
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
|
||||
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
@@ -252,19 +269,23 @@ export default class ClaimCertificateView extends Vue {
|
||||
);
|
||||
|
||||
// Generate and draw QR code
|
||||
const qrCodeCanvas = document.createElement("canvas");
|
||||
await QRCode.toCanvas(
|
||||
qrCodeCanvas,
|
||||
APP_SERVER + "/claim/" + this.claimId,
|
||||
{
|
||||
width: 150,
|
||||
color: { light: "#0000" /* Transparent background */ },
|
||||
},
|
||||
);
|
||||
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
|
||||
await this.generateQRCode();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateQRCode() {
|
||||
if (!this.qrCodeRef) return;
|
||||
|
||||
const canvas = await this.qrCodeRef.toCanvas();
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Draw the QR code on the claim canvas
|
||||
const CANVAS_WIDTH = 1100;
|
||||
const CANVAS_HEIGHT = 850;
|
||||
ctx.drawImage(canvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
<section id="Content">
|
||||
<div v-if="claimData">
|
||||
<canvas ref="claimCanvas"></canvas>
|
||||
<div class="qr-code-container">
|
||||
<QRCodeVue
|
||||
ref="qrCodeRef"
|
||||
:value="qrCodeData"
|
||||
:size="200"
|
||||
level="H"
|
||||
render-as="svg"
|
||||
:margin="0"
|
||||
:color="{ dark: '#000000', light: '#ffffff' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -9,13 +20,19 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { nextTick } from "vue";
|
||||
import QRCode from "qrcode";
|
||||
import QRCodeVue from "qrcode.vue";
|
||||
|
||||
import { APP_SERVER, NotificationIface } from "../constants/app";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import * as endorserServer from "../libs/endorserServer";
|
||||
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
|
||||
import { logger } from "../utils/logger";
|
||||
@Component
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QRCodeVue,
|
||||
},
|
||||
})
|
||||
export default class ClaimReportCertificateView extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
@@ -23,10 +40,14 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
claimId = "";
|
||||
claimData = null;
|
||||
claimData: GenericCredWrapper<GenericVerifiableCredential> | null = null;
|
||||
|
||||
endorserServer = endorserServer;
|
||||
|
||||
private qrCodeRef: InstanceType<typeof QRCodeVue> | null = null;
|
||||
private readonly CANVAS_WIDTH = 1100;
|
||||
private readonly CANVAS_HEIGHT = 850;
|
||||
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
@@ -63,20 +84,12 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async drawCanvas(
|
||||
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>,
|
||||
) {
|
||||
async drawCanvas(claimData: GenericCredWrapper<GenericVerifiableCredential>) {
|
||||
await db.open();
|
||||
const allContacts = await db.contacts.toArray();
|
||||
|
||||
const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
const CANVAS_WIDTH = 1100;
|
||||
const CANVAS_HEIGHT = 850;
|
||||
|
||||
// size to approximate portrait of 8.5"x11"
|
||||
canvas.width = CANVAS_WIDTH;
|
||||
canvas.height = CANVAS_HEIGHT;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
// Load the background image
|
||||
@@ -84,7 +97,13 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
backgroundImage.src = "/img/background/cert-frame-2.jpg";
|
||||
backgroundImage.onload = async () => {
|
||||
// Draw the background image
|
||||
ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||
ctx.drawImage(
|
||||
backgroundImage,
|
||||
0,
|
||||
0,
|
||||
this.CANVAS_WIDTH,
|
||||
this.CANVAS_HEIGHT,
|
||||
);
|
||||
|
||||
// Set font and styles
|
||||
ctx.fillStyle = "black";
|
||||
@@ -98,8 +117,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const claimTypeWidth = ctx.measureText(claimTypeText).width;
|
||||
ctx.fillText(
|
||||
claimTypeText,
|
||||
(CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
|
||||
CANVAS_HEIGHT * 0.33,
|
||||
(this.CANVAS_WIDTH - claimTypeWidth) / 2, // Center horizontally
|
||||
this.CANVAS_HEIGHT * 0.33,
|
||||
);
|
||||
|
||||
if (claimData.claim.agent) {
|
||||
@@ -108,8 +127,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const presentedWidth = ctx.measureText(presentedText).width;
|
||||
ctx.fillText(
|
||||
presentedText,
|
||||
(CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
|
||||
CANVAS_HEIGHT * 0.37,
|
||||
(this.CANVAS_WIDTH - presentedWidth) / 2, // Center horizontally
|
||||
this.CANVAS_HEIGHT * 0.37,
|
||||
);
|
||||
const agentText = endorserServer.didInfoForCertificate(
|
||||
claimData.claim.agent,
|
||||
@@ -119,8 +138,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const agentWidth = ctx.measureText(agentText).width;
|
||||
ctx.fillText(
|
||||
agentText,
|
||||
(CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
|
||||
CANVAS_HEIGHT * 0.4,
|
||||
(this.CANVAS_WIDTH - agentWidth) / 2, // Center horizontally
|
||||
this.CANVAS_HEIGHT * 0.4,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,8 +154,8 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
const descriptionWidth = ctx.measureText(descriptionLine).width;
|
||||
ctx.fillText(
|
||||
descriptionLine,
|
||||
(CANVAS_WIDTH - descriptionWidth) / 2,
|
||||
CANVAS_HEIGHT * 0.45,
|
||||
(this.CANVAS_WIDTH - descriptionWidth) / 2,
|
||||
this.CANVAS_HEIGHT * 0.45,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,33 +168,43 @@ export default class ClaimReportCertificateView extends Vue {
|
||||
claimData.issuer,
|
||||
allContacts,
|
||||
);
|
||||
ctx.fillText(issuerText, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.6);
|
||||
ctx.fillText(
|
||||
issuerText,
|
||||
this.CANVAS_WIDTH * 0.3,
|
||||
this.CANVAS_HEIGHT * 0.6,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw claim ID
|
||||
ctx.font = "14px Arial";
|
||||
ctx.fillText(this.claimId, CANVAS_WIDTH * 0.3, CANVAS_HEIGHT * 0.7);
|
||||
ctx.fillText(
|
||||
this.claimId,
|
||||
this.CANVAS_WIDTH * 0.3,
|
||||
this.CANVAS_HEIGHT * 0.7,
|
||||
);
|
||||
ctx.fillText(
|
||||
"via EndorserSearch.com",
|
||||
CANVAS_WIDTH * 0.3,
|
||||
CANVAS_HEIGHT * 0.73,
|
||||
this.CANVAS_WIDTH * 0.3,
|
||||
this.CANVAS_HEIGHT * 0.73,
|
||||
);
|
||||
|
||||
// Generate and draw QR code
|
||||
const qrCodeCanvas = document.createElement("canvas");
|
||||
await QRCode.toCanvas(
|
||||
qrCodeCanvas,
|
||||
APP_SERVER + "/claim/" + this.claimId,
|
||||
{
|
||||
width: 150,
|
||||
color: { light: "#0000" /* Transparent background */ },
|
||||
},
|
||||
);
|
||||
ctx.drawImage(qrCodeCanvas, CANVAS_WIDTH * 0.6, CANVAS_HEIGHT * 0.55);
|
||||
await this.generateQRCode();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async generateQRCode() {
|
||||
if (!this.qrCodeRef) return;
|
||||
|
||||
const canvas = await this.qrCodeRef.toCanvas();
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Draw the QR code on the report canvas
|
||||
ctx.drawImage(canvas, this.CANVAS_WIDTH * 0.6, this.CANVAS_HEIGHT * 0.55);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -186,5 +215,18 @@ canvas {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.qr-code-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -829,7 +829,7 @@ export default class ConfirmGiftView extends Vue {
|
||||
3000,
|
||||
);
|
||||
} else {
|
||||
console.error("Got error submitting the confirmation:", result);
|
||||
logger.error("Got error submitting the confirmation:", result);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<h2 class="text-base flex gap-4 items-center">
|
||||
<span class="grow">
|
||||
<img
|
||||
src="../assets/blank-square.svg"
|
||||
src="@/assets/blank-square.svg"
|
||||
width="32"
|
||||
class="inline-block align-middle border border-slate-300 rounded-md mr-1"
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
<!-- Breadcrumb -->
|
||||
<div class="mb-8">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<div class="relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
class="text-lg text-center font-light px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<font-awesome icon="chevron-left" class="fa-fw" />
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
<button
|
||||
v-if="!showGiveNumbers"
|
||||
href=""
|
||||
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
|
||||
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-3 px-3 py-1.5 rounded-md"
|
||||
:style="
|
||||
contactsSelected.length > 0
|
||||
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
|
||||
@@ -130,7 +130,7 @@
|
||||
<div class="w-full text-right">
|
||||
<button
|
||||
href=""
|
||||
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 py-1 rounded-md"
|
||||
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
|
||||
@click="toggleShowContactAmounts()"
|
||||
>
|
||||
{{
|
||||
@@ -180,14 +180,7 @@
|
||||
data-testId="contactListItem"
|
||||
>
|
||||
<div class="grow overflow-hidden">
|
||||
<div class="flex items-center">
|
||||
<EntityIcon
|
||||
:contact="contact"
|
||||
:icon-size="24"
|
||||
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer"
|
||||
@click="showLargeIdenticon = contact"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-if="!showGiveNumbers"
|
||||
type="checkbox"
|
||||
@@ -204,14 +197,19 @@
|
||||
"
|
||||
/>
|
||||
|
||||
<h2
|
||||
class="text-base font-semibold ml-2 w-1/3 truncate flex-shrink-0"
|
||||
>
|
||||
<EntityIcon
|
||||
:contact="contact"
|
||||
:icon-size="48"
|
||||
class="inline-block align-text-bottom border border-slate-300 rounded cursor-pointer overflow-hidden"
|
||||
@click="showLargeIdenticon = contact"
|
||||
/>
|
||||
|
||||
<h2 class="text-base font-semibold w-1/3 truncate flex-shrink-0">
|
||||
{{ contactNameNonBreakingSpace(contact.name) }}
|
||||
</h2>
|
||||
|
||||
<span>
|
||||
<div class="flex items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<router-link
|
||||
:to="{
|
||||
path: '/did/' + encodeURIComponent(contact.did),
|
||||
@@ -220,81 +218,79 @@
|
||||
>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="text-xl text-blue-500 ml-4"
|
||||
class="text-xl text-blue-500"
|
||||
/>
|
||||
</router-link>
|
||||
|
||||
<span class="ml-4 text-sm overflow-hidden">{{
|
||||
<span class="text-sm overflow-hidden">{{
|
||||
libsUtil.shortDid(contact.did)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="ml-4 text-sm">
|
||||
<div class="text-sm">
|
||||
{{ contact.notes }}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div id="ContactActions" class="flex gap-1.5 mt-2">
|
||||
<div
|
||||
v-if="showGiveNumbers && contact.did != activeDid"
|
||||
class="ml-auto flex gap-1.5"
|
||||
<div
|
||||
v-if="showGiveNumbers && contact.did != activeDid"
|
||||
class="ml-auto flex gap-1.5 mt-2"
|
||||
>
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-l-md"
|
||||
:title="givenToMeDescriptions[contact.did] || ''"
|
||||
@click="confirmShowGiftedDialog(contact.did, activeDid)"
|
||||
>
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-l-md"
|
||||
:title="givenToMeDescriptions[contact.did] || ''"
|
||||
@click="confirmShowGiftedDialog(contact.did, activeDid)"
|
||||
>
|
||||
From:
|
||||
<br />
|
||||
{{
|
||||
/* eslint-disable prettier/prettier */
|
||||
showGiveTotals
|
||||
? ((givenToMeConfirmed[contact.did] || 0)
|
||||
+ (givenToMeUnconfirmed[contact.did] || 0))
|
||||
: showGiveConfirmed
|
||||
? (givenToMeConfirmed[contact.did] || 0)
|
||||
: (givenToMeUnconfirmed[contact.did] || 0)
|
||||
/* eslint-enable prettier/prettier */
|
||||
}}
|
||||
</button>
|
||||
From:
|
||||
<br />
|
||||
{{
|
||||
/* eslint-disable prettier/prettier */
|
||||
showGiveTotals
|
||||
? ((givenToMeConfirmed[contact.did] || 0)
|
||||
+ (givenToMeUnconfirmed[contact.did] || 0))
|
||||
: showGiveConfirmed
|
||||
? (givenToMeConfirmed[contact.did] || 0)
|
||||
: (givenToMeUnconfirmed[contact.did] || 0)
|
||||
/* eslint-enable prettier/prettier */
|
||||
}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white -ml-1.5 px-2 py-1.5 rounded-r-md border-l"
|
||||
:title="givenByMeDescriptions[contact.did] || ''"
|
||||
@click="confirmShowGiftedDialog(activeDid, contact.did)"
|
||||
>
|
||||
To:
|
||||
<br />
|
||||
{{
|
||||
/* eslint-disable prettier/prettier */
|
||||
showGiveTotals
|
||||
? ((givenByMeConfirmed[contact.did] || 0)
|
||||
+ (givenByMeUnconfirmed[contact.did] || 0))
|
||||
: showGiveConfirmed
|
||||
? (givenByMeConfirmed[contact.did] || 0)
|
||||
: (givenByMeUnconfirmed[contact.did] || 0)
|
||||
/* eslint-enable prettier/prettier */
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white -ml-1.5 px-2 py-1.5 rounded-r-md border-l"
|
||||
:title="givenByMeDescriptions[contact.did] || ''"
|
||||
@click="confirmShowGiftedDialog(activeDid, contact.did)"
|
||||
>
|
||||
To:
|
||||
<br />
|
||||
{{
|
||||
/* eslint-disable prettier/prettier */
|
||||
showGiveTotals
|
||||
? ((givenByMeConfirmed[contact.did] || 0)
|
||||
+ (givenByMeUnconfirmed[contact.did] || 0))
|
||||
: showGiveConfirmed
|
||||
? (givenByMeConfirmed[contact.did] || 0)
|
||||
: (givenByMeUnconfirmed[contact.did] || 0)
|
||||
/* eslint-enable prettier/prettier */
|
||||
}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400"
|
||||
data-testId="offerButton"
|
||||
@click="openOfferDialog(contact.did, contact.name)"
|
||||
>
|
||||
Offer
|
||||
</button>
|
||||
<button
|
||||
class="text-sm bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-blue-400"
|
||||
data-testId="offerButton"
|
||||
@click="openOfferDialog(contact.did, contact.name)"
|
||||
>
|
||||
Offer
|
||||
</button>
|
||||
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'contact-amounts',
|
||||
query: { contactDid: contact.did },
|
||||
}"
|
||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-slate-400"
|
||||
title="See more given activity"
|
||||
>
|
||||
<font-awesome icon="file-lines" class="fa-fw" />
|
||||
</router-link>
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'contact-amounts',
|
||||
query: { contactDid: contact.did },
|
||||
}"
|
||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-slate-400"
|
||||
title="See more given activity"
|
||||
>
|
||||
<fa icon="file-lines" class="fa-fw" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -317,7 +313,7 @@
|
||||
<button
|
||||
v-if="!showGiveNumbers"
|
||||
href=""
|
||||
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-2 px-1 py-1 rounded-md"
|
||||
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white ml-3 px-3 py-1.5 rounded-md"
|
||||
:style="
|
||||
contactsSelected.length > 0
|
||||
? 'background-image: linear-gradient(to bottom, #3b82f6, #1e40af);'
|
||||
|
||||
232
src/views/DeepLinkErrorView.vue
Normal file
232
src/views/DeepLinkErrorView.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<div class="deep-link-error">
|
||||
<div class="safe-area-spacer"></div>
|
||||
<h1>Invalid Deep Link</h1>
|
||||
<div class="error-details">
|
||||
<div class="error-message">
|
||||
<h3>Error Details</h3>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<div v-if="errorCode" class="error-code">
|
||||
Error Code: <span>{{ errorCode }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="originalPath" class="original-link">
|
||||
<h3>Attempted Link</h3>
|
||||
<code>timesafari://{{ formattedPath }}</code>
|
||||
<div class="debug-info">
|
||||
<h4>Parameters:</h4>
|
||||
<pre>{{ JSON.stringify(route.params, null, 2) }}</pre>
|
||||
<h4>Query:</h4>
|
||||
<pre>{{ JSON.stringify(route.query, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="primary-button" @click="goHome">Go to Home</button>
|
||||
<button class="secondary-button" @click="reportIssue">
|
||||
Report Issue
|
||||
</button>
|
||||
</div>
|
||||
<div class="supported-links">
|
||||
<h2>Supported Deep Links</h2>
|
||||
<ul>
|
||||
<li v-for="(routeItem, index) in validRoutes" :key="index">
|
||||
<code>timesafari://{{ routeItem }}/:id</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { VALID_DEEP_LINK_ROUTES } from "../types/deepLinks";
|
||||
import { logConsoleAndDb } from "../db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// Extract error information from query params
|
||||
const errorCode = computed(
|
||||
() => (route.query.errorCode as string) || "UNKNOWN_ERROR",
|
||||
);
|
||||
const errorMessage = computed(
|
||||
() =>
|
||||
(route.query.message as string) ||
|
||||
"The deep link you followed is invalid or not supported.",
|
||||
);
|
||||
const originalPath = computed(() => route.query.originalPath as string);
|
||||
const validRoutes = VALID_DEEP_LINK_ROUTES;
|
||||
|
||||
// Format the path and include any parameters
|
||||
const formattedPath = computed(() => {
|
||||
if (!originalPath.value) return "";
|
||||
const path = originalPath.value.replace(/^\/+/, "");
|
||||
|
||||
// Log for debugging
|
||||
logger.log("Original Path:", originalPath.value);
|
||||
logger.log("Route Params:", route.params);
|
||||
logger.log("Route Query:", route.query);
|
||||
|
||||
return path;
|
||||
});
|
||||
|
||||
// Navigation methods
|
||||
const goHome = () => router.replace({ name: "home" });
|
||||
const reportIssue = () => {
|
||||
// Open a support form or email
|
||||
window.open(
|
||||
"mailto:support@timesafari.app?subject=Invalid Deep Link&body=" +
|
||||
encodeURIComponent(
|
||||
`I encountered an error with a deep link: timesafari://${originalPath.value}\nError: ${errorMessage.value}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
// Log the error for analytics
|
||||
onMounted(() => {
|
||||
logConsoleAndDb(
|
||||
`[DeepLink] Error page displayed for path: ${originalPath.value}, code: ${errorCode.value}, params: ${JSON.stringify(route.params)}`,
|
||||
true,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.deep-link-error {
|
||||
padding-top: 60px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.safe-area-spacer {
|
||||
height: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #ff4444;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background-color: #f8f8f8;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-family: monospace;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-code span {
|
||||
background-color: #eee;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.original-link {
|
||||
padding: 12px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.original-link code {
|
||||
color: #0066cc;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin: 24px 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background-color: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background-color: #f2f2f2;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.supported-links {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.supported-links ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.supported-links li {
|
||||
padding: 8px 12px;
|
||||
background-color: #f8f8f8;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.supported-links code {
|
||||
font-family: monospace;
|
||||
color: #0066cc;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.debug-info h4 {
|
||||
margin: 8px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.debug-info pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -484,13 +484,13 @@
|
||||
<a href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1" target="_blank" rel="license noopener noreferrer">
|
||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
||||
<img
|
||||
src="../assets/help/creative-commons-circle.svg"
|
||||
src="@/assets/help/creative-commons-circle.svg"
|
||||
alt="CC circle"
|
||||
width="20"
|
||||
class="display: inline"
|
||||
/>
|
||||
<img
|
||||
src="../assets/help/creative-commons-zero.svg"
|
||||
src="@/assets/help/creative-commons-zero.svg"
|
||||
alt="CC zero"
|
||||
width="20"
|
||||
style="display: inline"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
98
src/views/LogView.vue
Normal file
98
src/views/LogView.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<!-- This is useful in an environment where the download doesn't work. -->
|
||||
<template>
|
||||
<QuickNav selected="" />
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Back Button -->
|
||||
<div class="relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center font-light px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<font-awesome icon="chevron-left" class="mr-2" />
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-6">Logs</h1>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
|
||||
>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Log Content -->
|
||||
<div v-if="loading" class="text-center">
|
||||
<font-awesome icon="spinner" class="fa-spin text-slate-400" />
|
||||
Loading logs...
|
||||
</div>
|
||||
<div v-else-if="!logs.length" class="text-center text-slate-500">
|
||||
No logs found.
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-8">
|
||||
<h2 class="text-lg font-semibold mb-2">{{ log.date }}</h2>
|
||||
<pre
|
||||
class="bg-slate-100 p-4 rounded-md overflow-x-auto whitespace-pre-wrap"
|
||||
>{{ log.message }}</pre
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { Router } from "vue-router";
|
||||
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import { db } from "../db/index";
|
||||
import { Log } from "../db/tables/logs";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
TopMessage,
|
||||
},
|
||||
})
|
||||
export default class LogView extends Vue {
|
||||
$router!: Router;
|
||||
|
||||
loading = true;
|
||||
logs: Log[] = [];
|
||||
error: string | null = null;
|
||||
|
||||
async mounted() {
|
||||
await this.loadLogs();
|
||||
}
|
||||
|
||||
async loadLogs() {
|
||||
try {
|
||||
this.error = null; // Clear any previous errors
|
||||
await db.open();
|
||||
// Get all logs and sort by date in reverse chronological order
|
||||
const allLogs = await db.logs.toArray();
|
||||
this.logs = allLogs.sort((a, b) => {
|
||||
const dateA = new Date(a.date);
|
||||
const dateB = new Date(b.date);
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Error loading logs:", error);
|
||||
this.error =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `An unknown error occurred while loading logs: ${String(error)}`;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -220,7 +220,7 @@
|
||||
</li>
|
||||
<li @click="openGiftDialogToProject()">
|
||||
<img
|
||||
src="../assets/blank-square.svg"
|
||||
src="@/assets/blank-square.svg"
|
||||
class="mx-auto border border-blue-300 rounded-md mb-1 cursor-pointer"
|
||||
/>
|
||||
<h3
|
||||
@@ -266,7 +266,7 @@
|
||||
<div class="text-center">
|
||||
<button
|
||||
data-testId="offerButton"
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 text-sm leading-tight rounded-md"
|
||||
@click="openOfferDialog()"
|
||||
>
|
||||
Offer to this (maybe with conditions)...
|
||||
@@ -353,7 +353,7 @@
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1rounded-md"
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 text-sm leading-tight rounded-md"
|
||||
@click="openGiftDialogToProject()"
|
||||
>
|
||||
Given To This...
|
||||
@@ -372,7 +372,10 @@
|
||||
<!-- Totals section -->
|
||||
<div class="mt-1 flex items-center min-h-[1.5rem]">
|
||||
<div v-if="loadingTotals" class="flex-1">
|
||||
<fa icon="spinner" class="fa-spin-pulse text-blue-500" />
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin-pulse text-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="givesTotalsByUnit.length > 0" class="flex-1">
|
||||
<span class="font-semibold mr-2 shrink-0">Totals</span>
|
||||
@@ -391,7 +394,7 @@
|
||||
</span>
|
||||
<span v-if="givesTotalsByUnit.length > 1">...</span>
|
||||
<span>
|
||||
<fa
|
||||
<font-awesome
|
||||
:icon="totalsExpanded ? 'chevron-up' : 'chevron-right'"
|
||||
class="fa-fw text-xs ml-1"
|
||||
/>
|
||||
@@ -404,7 +407,7 @@
|
||||
:key="total.unit"
|
||||
class="ml-2"
|
||||
>
|
||||
<fa
|
||||
<font-awesome
|
||||
:icon="libsUtil.iconForUnitCode(total.unit)"
|
||||
class="fa-fw text-slate-400 mr-1"
|
||||
/>
|
||||
@@ -431,7 +434,7 @@
|
||||
>
|
||||
<div class="flex justify-between gap-4">
|
||||
<span>
|
||||
<fa icon="user" class="fa-fw text-slate-400" />
|
||||
<font-awesome icon="user" class="fa-fw text-slate-400" />
|
||||
{{
|
||||
serverUtil.didInfo(
|
||||
give.agentDid,
|
||||
@@ -442,23 +445,26 @@
|
||||
}}
|
||||
</span>
|
||||
<span v-if="give.amount" class="whitespace-nowrap">
|
||||
<fa
|
||||
<font-awesome
|
||||
:icon="libsUtil.iconForUnitCode(give.unit)"
|
||||
class="fa-fw text-slate-400"
|
||||
/>{{ give.amount }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-slate-500">
|
||||
<fa icon="calendar" class="fa-fw text-slate-400" />
|
||||
<font-awesome icon="calendar" class="fa-fw text-slate-400" />
|
||||
{{ give.issuedAt?.substring(0, 10) }}
|
||||
</div>
|
||||
<div v-if="give.description" class="text-slate-500">
|
||||
<fa icon="comment" class="fa-fw text-slate-400" />
|
||||
<font-awesome icon="comment" class="fa-fw text-slate-400" />
|
||||
{{ give.description }}
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<a @click="onClickLoadClaim(give.jwtId)">
|
||||
<fa icon="file-lines" class="text-blue-500 cursor-pointer" />
|
||||
<font-awesome
|
||||
icon="file-lines"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a
|
||||
@@ -468,16 +474,16 @@
|
||||
"
|
||||
@click="deepCheckConfirmable(give)"
|
||||
>
|
||||
<fa
|
||||
<font-awesome
|
||||
icon="circle-check"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a v-else-if="checkingConfirmationForJwtId === give.jwtId">
|
||||
<fa icon="spinner" class="fa-spin-pulse" />
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" />
|
||||
</a>
|
||||
<a v-else @click="shallowNotifyWhyCannotConfirm(give)">
|
||||
<fa
|
||||
<font-awesome
|
||||
icon="circle-check"
|
||||
class="text-slate-500 cursor-pointer"
|
||||
/>
|
||||
@@ -505,7 +511,7 @@
|
||||
<div v-if="activeDid && isRegistered">
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1 rounded-md"
|
||||
class="block w-full bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 text-sm leading-tight rounded-md"
|
||||
@click="openGiftDialogFromProject()"
|
||||
>
|
||||
Given By This...
|
||||
|
||||
@@ -239,7 +239,7 @@
|
||||
class="border-b border-slate-300"
|
||||
>
|
||||
<a
|
||||
class="block py-4 flex gap-4"
|
||||
class="block py-4 flex gap-4 cursor-pointer"
|
||||
@click="onClickLoadProject(project.handleId)"
|
||||
>
|
||||
<div class="flex-none">
|
||||
@@ -279,13 +279,8 @@ import ProjectIcon from "../components/ProjectIcon.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import {
|
||||
didInfo,
|
||||
getHeaders,
|
||||
getPlanFromCache,
|
||||
OfferSummaryRecord,
|
||||
PlanData,
|
||||
} from "../libs/endorserServer";
|
||||
import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer";
|
||||
import { OfferSummaryRecord, PlanData } from "../interfaces/records";
|
||||
import * as libsUtil from "../libs/util";
|
||||
import { OnboardPage } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configurable pause duration (in seconds)
|
||||
PAUSE_DURATION=2
|
||||
MANUAL_CONTINUE=true
|
||||
|
||||
# Function to test deep link
|
||||
test_link() {
|
||||
echo "----------------------------------------"
|
||||
echo "Testing: $1"
|
||||
echo "Description: $2"
|
||||
echo "----------------------------------------"
|
||||
adb shell am start -W -a android.intent.action.VIEW -d "$1" app.timesafari.app
|
||||
|
||||
if [ "$MANUAL_CONTINUE" = true ]; then
|
||||
read -p "Press Enter to continue to next test..."
|
||||
else
|
||||
sleep $PAUSE_DURATION
|
||||
fi
|
||||
}
|
||||
|
||||
# Allow command line override of pause settings
|
||||
while getopts "t:a" opt; do
|
||||
case $opt in
|
||||
t) PAUSE_DURATION=$OPTARG ;;
|
||||
a) MANUAL_CONTINUE=false ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Starting TimeSafari Deep Link Tests"
|
||||
echo "======================================"
|
||||
echo "Pause duration: $PAUSE_DURATION seconds"
|
||||
echo "Manual continue: $MANUAL_CONTINUE"
|
||||
|
||||
# Contact Import Routes
|
||||
echo "\nTesting Contact Import Routes:"
|
||||
|
||||
# 1. Direct Query Parameter Import (URL-encoded JSON)
|
||||
QUERY_CONTACTS='[{
|
||||
"did":"did:ethr:"
|
||||
},{
|
||||
"did":"did:ethr:",
|
||||
"name":"Jordan",
|
||||
"nextPubKeyHashB64":"IBfRZfwdzeKOzqCx8b+WlLpMJHOAT9ZknIDJo7F3rZE=",
|
||||
"publicKeyBase64":"A1eIndfaxgMpVwyD5dYe74DgjuIo5SwPZFCcLdOemjf"
|
||||
}]'
|
||||
ENCODED_CONTACTS=$(echo $QUERY_CONTACTS | jq -c | python3 -c "import urllib.parse; print(urllib.parse.quote(input()))")
|
||||
test_link "timesafari://contact-import?contacts=$ENCODED_CONTACTS" "Bulk import via query parameters"
|
||||
|
||||
# 2. JWT Path Imports
|
||||
# Original JWT with multiple contacts
|
||||
BULK_JWT="eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NDA3NDA0NTMsImNvbnRhY3RzIjpbeyJkaWQiOiJkaWQ6ZXRocjoweGY5NjlBNURlRTdhNTgwNmQxQzM3ZjRFYzQ5QTU1NUFiOTc5MTEwODkifSx7ImRpZCI6ImRpZDpldGhyOjB4RkVkM2I0MTY5NDZiMjNGM0Y0NzI3OTkwNTMxNDRCNEUzNDE1NUI1YiIsIm5hbWUiOiJKb3JkYW4iLCJuZXh0UHViS2V5SGFzaEI2NCI6IklCZlJaZndkemVLT3pxQ3g4YitXbExwTUpIT0FUOVprbklESm83RjNyWkU9IiwicHVibGljS2V5QmFzZTY0IjoiQTFlSW5kZmF4Z01wVnd5RDVkWWU3NERnanQ5SW81U3dQWkZDY0xkT2VtamYifV0sImlzcyI6ImRpZDpldGhyOjB4RDUzMTE0ODMwRDRhNUQ5MDQxNkI0M0ZjOTlhMjViMGRGOGJiMUJBZCJ9.yKEFounxUGU9-grAMFHA12dif9BKYkftg8F3wAIcFYh0H_k1tevjEYyD1fvAyIxYxK5xR0E8moqMhi78ipJXcg"
|
||||
test_link "timesafari://contact-import/$BULK_JWT" "Multiple contacts via JWT"
|
||||
|
||||
# 3. Contact Page JWT Redirect
|
||||
test_link "timesafari://contacts?contactJwt=$BULK_JWT" "Multiple contacts redirect"
|
||||
|
||||
# Contact Management Routes
|
||||
test_link "timesafari://contact-edit/did:ethr:" \
|
||||
"Edit first contact"
|
||||
|
||||
# Error Cases
|
||||
echo "\nTesting Contact Import Error Cases:"
|
||||
test_link "timesafari://contact-import/eyJJTlZBTElEIn0" "Invalid JWT format"
|
||||
test_link "timesafari://contact-import?contacts=[{invalid:data}]" "Invalid contact data"
|
||||
|
||||
# Original Routes (preserved from previous version)
|
||||
echo "\nTesting Other Routes:"
|
||||
|
||||
# Test claim routes
|
||||
echo "\nTesting Claim Routes:"
|
||||
test_link "timesafari://claim/01JMAAFZRNSRTQ0EBSD70A8E1H"
|
||||
test_link "timesafari://claim-cert/01JMAAFZRNSRTQ0EBSD70A8E1H"
|
||||
|
||||
# Test project routes
|
||||
echo "\nTesting Project Routes:"
|
||||
test_link "timesafari://project/https%3A%2F%2Fendorser.ch%2Fentity%2F01JKW0QZX1XVCVZV85VXAMB31R"
|
||||
|
||||
# Test gift routes
|
||||
echo "\nTesting Gift Routes:"
|
||||
test_link "timesafari://confirm-gift/01JMTC8T961KFPP2N8ZB92ER4K"
|
||||
|
||||
# Test offer routes
|
||||
echo "\nTesting Offer Routes:"
|
||||
test_link "timesafari://offer-details/101"
|
||||
|
||||
# Test complex query parameters
|
||||
echo "\nTesting Complex Query Parameters:"
|
||||
test_link "timesafari://contact-import/jwt?contacts=%5B%7B%22name%22%3A%22Test%22%7D%5D"
|
||||
|
||||
# New test cases
|
||||
echo "\nTesting DID Routes:"
|
||||
test_link "timesafari://did/did:example:123"
|
||||
test_link "timesafari://did/did:example:456?view=details"
|
||||
|
||||
echo "\nTesting Additional Contact Routes:"
|
||||
test_link "timesafari://contact-import/jwt?contacts=%5B%7B%22did%22%3A%22did%3Aexample%3A123%22%7D%5D"
|
||||
test_link "timesafari://contact-edit/did:example:123?action=edit"
|
||||
|
||||
echo "\nTesting Error Cases:"
|
||||
test_link "timesafari://invalid-route/123"
|
||||
test_link "timesafari://claim/123?view=invalid"
|
||||
test_link "timesafari://did/invalid-did"
|
||||
|
||||
# Single invite JWT test
|
||||
# Header: {"typ":"JWT","alg":"ES256K"}
|
||||
# Payload: {
|
||||
# "iat": 1740740453,
|
||||
# "contact": {
|
||||
# "did": "did:ethr:",
|
||||
# "name": "Jordan",
|
||||
# "nextPubKeyHashB64": "IBfRZfwdzeKOzqCx8b+WlLpMJHOAT9ZknIDJo7F3rZE=",
|
||||
# "publicKeyBase64": "A1eIndfaxgMpVwyD5dYe74DgjuIo5SwPZFCcLdOemjf"
|
||||
# },
|
||||
# "iss": "did:ethr:"
|
||||
# }
|
||||
|
||||
SINGLE_INVITE_JWT="eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksifQ.eyJpYXQiOjE3NDA3NDA0NTMsImNvbnRhY3QiOnsiZGlkIjoiZGlkOmV0aHI6MHhGRWQzYjQxNjk0NmIyM0YzRjQ3Mjc5OTA1MzE0NEI0RTM0MTU1QjViIiwibmFtZSI6IkpvcmRhbiIsIm5leHRQdWJLZXlIYXNoQjY0IjoiSUJmUlpmd2R6ZUtPenFDeDhiK1dsTHBNSkhPQVQ5WmtuSURKbzdGM3JaRT0iLCJwdWJsaWNLZXlCYXNlNjQiOiJBMWVJbmRmYXhnTXBWd3lENWRZZTc0RGdqdUlvNVN3UFpGQ2NMZEtlbWpmIn0sImlzcyI6ImRpZDpldGhyOjB4RDUzMTE0ODMwRDRhNUQ5MDQxNkI0M0ZjOTlhMjViMGRGOGJiMUJBZCJ9.yKEFounxUGU9-grAMFHA12dif9BKYkftg8F3wAIcFYh0H_k1tevjEYyD1fvAyIxYxK5xR0E8moqMhi78ipJXcg"
|
||||
|
||||
test_link "timesafari://invite-one-accept/$SINGLE_INVITE_JWT" "Single contact invite via JWT"
|
||||
|
||||
echo "\nDeep link testing complete"
|
||||
echo "======================================"
|
||||
@@ -74,13 +74,30 @@ import { deleteContact, generateAndRegisterEthrUser, importUser } from './testUt
|
||||
test('Check activity feed - check that server is running', async ({ page }) => {
|
||||
// Load app homepage
|
||||
await page.goto('./');
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
|
||||
// Wait for and dismiss onboarding dialog, with retry logic
|
||||
const closeOnboarding = async () => {
|
||||
const closeButton = page.getByTestId('closeOnboardingAndFinish');
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click();
|
||||
await expect(closeButton).toBeHidden();
|
||||
}
|
||||
};
|
||||
|
||||
// Check that initial 10 activities have been loaded
|
||||
// Initial dismissal
|
||||
await closeOnboarding();
|
||||
|
||||
// Wait for network to be idle
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check and dismiss onboarding again if it reappeared
|
||||
await closeOnboarding();
|
||||
|
||||
// Wait for initial feed items to load
|
||||
await expect(page.locator('ul#listLatestActivity li:nth-child(10)')).toBeVisible();
|
||||
|
||||
|
||||
// Scroll down a bit to trigger loading additional activities
|
||||
await page.locator('ul#listLatestActivity li:nth-child(50)').scrollIntoViewIfNeeded();
|
||||
await page.locator('ul#listLatestActivity li:nth-child(20)').scrollIntoViewIfNeeded();
|
||||
});
|
||||
|
||||
test('Check discover results', async ({ page }) => {
|
||||
@@ -104,8 +121,11 @@ test('Check no-ID messaging in account', async ({ page }) => {
|
||||
// Check 'a friend needs to register you' notice
|
||||
await expect(page.locator('#noticeBeforeAnnounce')).toBeVisible();
|
||||
|
||||
// Check that there is no ID
|
||||
await expect(page.locator('#sectionIdentityDetails code.truncate')).toBeEmpty();
|
||||
// Check that there is no ID by finding the wrapper first
|
||||
const didWrapper = page.locator('[data-testId="didWrapper"]');
|
||||
await expect(didWrapper).toBeVisible();
|
||||
const codeElement = didWrapper.locator('code[role="code"]');
|
||||
await expect(codeElement).toBeEmpty();
|
||||
});
|
||||
|
||||
test('Check ability to share contact', async ({ page }) => {
|
||||
@@ -169,7 +189,14 @@ test('Check setting name & sharing info', async ({ page }) => {
|
||||
test('Confirm test API setting (may fail if you are running your own Time Safari)', async ({ page }, testInfo) => {
|
||||
// Load account view
|
||||
await page.goto('./account');
|
||||
await page.getByRole('heading', { name: 'Advanced' }).click();
|
||||
|
||||
// Wait for and click the Advanced heading
|
||||
const advancedHeading = page.getByRole('heading', { name: 'Advanced' });
|
||||
await advancedHeading.waitFor({ state: 'visible' });
|
||||
await advancedHeading.click();
|
||||
|
||||
// Wait for the Advanced section to be fully loaded
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// look into the config file: if it starts Time Safari, it might say which server it should set by default
|
||||
const webServer = testInfo.config.webServer;
|
||||
@@ -178,8 +205,12 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
|
||||
const endorserTerm = endorserWords?.find(word => word.startsWith(ENDORSER_ENV_NAME + '='));
|
||||
const endorserTermInConfig = endorserTerm?.substring(ENDORSER_ENV_NAME.length + 1);
|
||||
|
||||
const endorserServer = endorserTermInConfig || 'https://test-api.endorser.ch';
|
||||
await expect(page.getByRole('textbox').nth(1)).toHaveValue(endorserServer);
|
||||
// Find the Claim Server input field using the label's for attribute
|
||||
const serverInput = page.locator('input[type="text"]').first();
|
||||
await serverInput.waitFor({ state: 'visible' });
|
||||
|
||||
const endorserServer = endorserTermInConfig || 'https://api.endorser.ch';
|
||||
await expect(serverInput).toHaveValue(endorserServer);
|
||||
});
|
||||
|
||||
test('Check User 0 can register a random person', async ({ page }) => {
|
||||
|
||||
@@ -109,7 +109,11 @@ test('Record something given', async ({ page }) => {
|
||||
|
||||
// Refresh home view and check gift
|
||||
await page.goto('./');
|
||||
await page.locator('li').filter({ hasText: finalTitle }).locator('a').click();
|
||||
await page.locator('li')
|
||||
.filter({ hasText: finalTitle })
|
||||
.locator('a.cursor-pointer')
|
||||
.filter({ hasText: finalTitle })
|
||||
.click();
|
||||
await expect(page.getByRole('heading', { name: 'Verifiable Claim Details' })).toBeVisible();
|
||||
await expect(page.getByText(finalTitle, { exact: true })).toBeVisible();
|
||||
const page1Promise = page.waitForEvent('popup');
|
||||
|
||||
@@ -88,12 +88,8 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||
|
||||
test('Record 9 new gifts', async ({ page }) => {
|
||||
const giftCount = 9; // because 10 has taken us above 30 seconds
|
||||
|
||||
// Standard text
|
||||
const giftCount = 9;
|
||||
const standardTitle = 'Gift ';
|
||||
|
||||
// Field value arrays
|
||||
const finalTitles = [];
|
||||
const finalNumbers = [];
|
||||
|
||||
@@ -101,21 +97,19 @@ test('Record 9 new gifts', async ({ page }) => {
|
||||
const uniqueStrings = await createUniqueStringsArray(giftCount);
|
||||
const randomNumbers = await createRandomNumbersArray(giftCount);
|
||||
|
||||
// Populate array with titles
|
||||
// Populate arrays
|
||||
for (let i = 0; i < giftCount; i++) {
|
||||
let loopTitle = standardTitle + uniqueStrings[i];
|
||||
finalTitles.push(loopTitle);
|
||||
let loopNumber = randomNumbers[i];
|
||||
finalNumbers.push(loopNumber);
|
||||
finalTitles.push(standardTitle + uniqueStrings[i]);
|
||||
finalNumbers.push(randomNumbers[i]);
|
||||
}
|
||||
|
||||
// Import user 00
|
||||
await importUser(page, '00');
|
||||
|
||||
// Record new gifts
|
||||
// Record new gifts with optimized waiting
|
||||
for (let i = 0; i < giftCount; i++) {
|
||||
// Record something given
|
||||
await page.goto('./');
|
||||
// Record gift
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
if (i === 0) {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
}
|
||||
@@ -123,11 +117,16 @@ test('Record 9 new gifts', async ({ page }) => {
|
||||
await page.getByPlaceholder('What was given').fill(finalTitles[i]);
|
||||
await page.getByRole('spinbutton').fill(finalNumbers[i].toString());
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
|
||||
// Wait for success and dismiss
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
|
||||
// Refresh home view and check gift
|
||||
await page.goto('./');
|
||||
await expect(page.locator('li').filter({ hasText: finalTitles[i] })).toBeVisible();
|
||||
// Verify gift in list with network idle wait
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('ul#listLatestActivity li')
|
||||
.filter({ hasText: finalTitles[i] })
|
||||
.first())
|
||||
.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
@@ -27,10 +27,16 @@ npx playwright install
|
||||
|
||||
#### Full Test Suite
|
||||
|
||||
Run all local tests:
|
||||
To run all tests, make sure XCode is started and either Android Studio is started or an Android device is connected.
|
||||
|
||||
```bash
|
||||
npm run test-all
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
Run only web tests:
|
||||
|
||||
```bash
|
||||
npm run test:web
|
||||
```
|
||||
|
||||
Note: Tests may occasionally fail and succeed on rerun (especially if a different test fails).
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user