Browse Source

Merge branch 'master' into electron_fix_20250317

electron_fix_20250317
Matthew Raymer 1 week ago
parent
commit
ae25a066f2
  1. 68
      BUILDING.md
  2. BIN
      android/.gradle/8.11.1/checksums/checksums.lock
  3. BIN
      android/.gradle/8.11.1/checksums/md5-checksums.bin
  4. BIN
      android/.gradle/8.11.1/checksums/sha1-checksums.bin
  5. BIN
      android/.gradle/8.11.1/executionHistory/executionHistory.bin
  6. BIN
      android/.gradle/8.11.1/executionHistory/executionHistory.lock
  7. BIN
      android/.gradle/8.11.1/fileChanges/last-build.bin
  8. BIN
      android/.gradle/8.11.1/fileHashes/fileHashes.bin
  9. BIN
      android/.gradle/8.11.1/fileHashes/fileHashes.lock
  10. BIN
      android/.gradle/8.11.1/fileHashes/resourceHashesCache.bin
  11. 0
      android/.gradle/8.11.1/gc.properties
  12. BIN
      android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  13. 4
      android/.gradle/buildOutputCleanup/cache.properties
  14. BIN
      android/.gradle/file-system.probe
  15. 3
      android/Gemfile
  16. 1
      android/app/.gitignore
  17. 13
      android/app/build.gradle
  18. 398
      android/app/lint-baseline.xml
  19. 18
      android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
  20. 8
      android/app/src/main/AndroidManifest.xml
  21. 2
      android/app/src/main/assets/capacitor.config.json
  22. 2
      android/app/src/main/assets/public/index.html
  23. 7
      android/app/src/main/java/app/timesafari/MainActivity.java
  24. 2
      android/app/src/main/java/timesafari/app/MainActivity.java
  25. 4
      android/app/src/main/res/values/strings.xml
  26. 10
      android/build.gradle
  27. 7
      android/capacitor-android/build.gradle
  28. 2
      android/fastlane/Appfile
  29. 38
      android/fastlane/Fastfile
  30. 40
      android/fastlane/README.md
  31. 2
      android/gradle.properties
  32. 2
      android/gradle/wrapper/gradle-wrapper.properties
  33. 8
      android/local.properties
  34. BIN
      assets/icon-only.png
  35. 4
      capacitor.config.ts
  36. 3338
      package-lock.json
  37. 4
      package.json
  38. 196
      scripts/test-android.js
  39. 647
      scripts/test-ios.js
  40. 334
      src/components/ActivityListItem.vue
  41. 24
      src/electron/electron-logger.js
  42. 147
      src/electron/main.js
  43. 35
      src/electron/preload.js
  44. 1
      src/libs/util.ts
  45. 166
      src/main.ts
  46. 4
      src/registerServiceWorker.ts
  47. 14
      src/router/index.ts
  48. 53
      src/services/deepLinks.ts
  49. 24
      src/types/deepLinks.ts
  50. 20
      src/types/index.ts
  51. 31
      src/views/AccountViewView.vue
  52. 2
      src/views/ConfirmGiftView.vue
  53. 4
      src/views/ContactQRScanShowView.vue
  54. 232
      src/views/DeepLinkErrorView.vue
  55. 265
      src/views/HomeView.vue
  56. 98
      src/views/LogView.vue
  57. 28
      src/views/ProjectViewView.vue
  58. 123
      test-deeplinks.sh.bak
  59. 10
      test-playwright/TESTING.md

68
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,23 +127,20 @@ Prerequisites: macOS with Xcode installed
npx cap sync ios
```
3. Open the project in Xcode:
3. Copy the assets:
```bash
npx cap open ios
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
npx capacitor-assets generate --ios
```
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:
3. Open the project in Xcode:
```bash
rm -rf ios
npx cap add ios
npx cap open ios
```
... and then repeat the steps above.
4. Use Xcode to build and run on simulator or device.
### Android Build
@ -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 cap open android
npx capacitor-assets generate --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:
4. Open the project in Android Studio:
```bash
rm -rf android
npx cap add android
npx cap open 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

BIN
android/.gradle/8.11.1/checksums/checksums.lock

Binary file not shown.

BIN
android/.gradle/8.11.1/checksums/md5-checksums.bin

Binary file not shown.

BIN
android/.gradle/8.11.1/checksums/sha1-checksums.bin

Binary file not shown.

BIN
android/.gradle/8.11.1/executionHistory/executionHistory.bin

Binary file not shown.

BIN
android/.gradle/8.11.1/executionHistory/executionHistory.lock

Binary file not shown.

BIN
android/.gradle/8.11.1/fileChanges/last-build.bin

Binary file not shown.

BIN
android/.gradle/8.11.1/fileHashes/fileHashes.bin

Binary file not shown.

BIN
android/.gradle/8.11.1/fileHashes/fileHashes.lock

Binary file not shown.

BIN
android/.gradle/8.11.1/fileHashes/resourceHashesCache.bin

Binary file not shown.

0
android/.gradle/8.11.1/gc.properties

BIN
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

4
android/.gradle/buildOutputCleanup/cache.properties

@ -1,2 +1,2 @@
#Tue Mar 11 10:01:05 UTC 2025
gradle.version=8.10.2
#Fri Mar 21 07:27:50 UTC 2025
gradle.version=8.2.1

BIN
android/.gradle/file-system.probe

Binary file not shown.

3
android/Gemfile

@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"

1
android/app/.gitignore

@ -1,3 +1,2 @@
/build/*
!/build/.npmkeep
src/main/assets/public/assets/

13
android/app/build.gradle

@ -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 {

398
android/app/lint-baseline.xml

@ -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 &quot;UnsanitizedFilenameFromContentProvider&quot;"
errorLine1=" disable &apos;UnsanitizedFilenameFromContentProvider&apos;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="26"
column="18"/>
</issue>
<issue
id="UnknownIssueId"
message="Unknown issue id &quot;UnsanitizedFilenameFromContentProvider&quot;"
errorLine1=" disable &apos;UnsanitizedFilenameFromContentProvider&apos;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="26"
column="18"/>
</issue>
<issue
id="UnknownIssueId"
message="Unknown issue id &quot;LintBaselineFixed&quot;"
errorLine1=" ignore &apos;LintBaselineFixed&apos;"
errorLine2=" ~~~~~~~~~~~~~~~~~">
<location
file="build.gradle"
line="34"
column="17"/>
</issue>
<issue
id="UnknownIssueId"
message="Unknown issue id &quot;LintBaselineFixed&quot;"
errorLine1=" ignore &apos;LintBaselineFixed&apos;"
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(&quot;Accept&quot;) &amp;&amp; header.getValue().toLowerCase().contains(&quot;text/html&quot;)) {"
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(&quot;yyyyMMdd_HHmmss&quot;).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(&quot;yyyy-MM-dd&apos;T&apos;HH:mm&apos;Z&apos;&quot;);"
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="&lt;application android:usesCleartextTraffic=&quot;true&quot;>"
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=" &lt;intent-filter android:autoVerify=&quot;true&quot;>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="25"
column="28"/>
</issue>
<issue
id="ManifestOrder"
message="`&lt;uses-permission>` tag appears after `&lt;application>` tag"
errorLine1=" &lt;uses-permission android:name=&quot;android.permission.INTERNET&quot; />"
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 &apos;com.android.tools.build:gradle:8.2.1&apos;"
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 &apos;com.android.tools.build:gradle:8.2.1&apos;"
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 &quot;androidx.appcompat:appcompat:$androidxAppCompatVersion&quot;"
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 &quot;androidx.appcompat:appcompat:$androidxAppCompatVersion&quot;"
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 &quot;androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion&quot;"
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 &quot;androidx.test.ext:junit:$androidxJunitVersion&quot;"
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 &quot;androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion&quot;"
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 &quot;androidx.appcompat:appcompat:$androidxAppCompatVersion&quot;"
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 &quot;androidx.test.ext:junit:$androidxJunitVersion&quot;"
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 &quot;androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion&quot;"
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=&quot;#F0FF1414&quot;"
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="&lt;androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
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="&lt;widget version=&quot;1.0.0&quot; xmlns=&quot;http://www.w3.org/ns/widgets&quot; xmlns:cdv=&quot;http://cordova.apache.org/ns/1.0&quot;>"
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="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
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="&lt;vector xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;"
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=" &lt;string name=&quot;package_name&quot;>app.timesafari.app&lt;/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=" &lt;string name=&quot;custom_url_scheme&quot;>app.timesafari.app&lt;/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="&lt;adaptive-icon xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;>"
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="&lt;adaptive-icon xmlns:android=&quot;http://schemas.android.com/apk/res/android&quot;>"
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>

18
android/app/src/androidTest/java/app/timesafari/app/ExampleInstrumentedTest.java → android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java

@ -1,20 +1,26 @@
package app.timesafari.app;
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
import static org.junit.Assert.*;
/**
* 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());
}
}

8
android/app/src/main/AndroidManifest.xml

@ -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" />

2
android/app/src/main/assets/capacitor.config.json

@ -1,5 +1,5 @@
{
"appId": "app.timesafari.app",
"appId": "app.timesafari",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,

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

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

7
android/app/src/main/java/app/timesafari/MainActivity.java

@ -0,0 +1,7 @@
package app.timesafari;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
// ... existing code ...
}

2
android/app/src/main/java/app/timesafari/app/MainActivity.java → android/app/src/main/java/timesafari/app/MainActivity.java

@ -1,4 +1,4 @@
package app.timesafari.app;
package timesafari.app;
import com.getcapacitor.BridgeActivity;

4
android/app/src/main/res/values/strings.xml

@ -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>

10
android/build.gradle

@ -7,9 +7,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.0'
classpath 'com.android.tools.build:gradle:8.2.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'
}
}

7
android/capacitor-android/build.gradle

@ -1,7 +0,0 @@
android {
lintOptions {
disable 'UnsanitizedFilenameFromContentProvider'
abortOnError false
baseline file("lint-baseline.xml")
}
}

2
android/fastlane/Appfile

@ -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

38
android/fastlane/Fastfile

@ -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

40
android/fastlane/README.md

@ -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).

2
android/gradle.properties

@ -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

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

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

8
android/local.properties

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

4
capacitor.config.ts

@ -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,

3338
package-lock.json

File diff suppressed because it is too large

4
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",
@ -121,6 +120,7 @@
"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",

196
scripts/test-android.js

@ -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) => {
}
];
// Show test plan
log('\n📋 Test Plan:');
deeplinkTests.forEach((test, i) => {
log(`${i + 1}. ${test.description}`);
});
// Execute each test
let testsCompleted = 0;
for (const test of deeplinkTests) {
await executeDeeplink(test.url, test.description, log);
}
// Show progress
log(`\n📊 Progress: ${testsCompleted}/${deeplinkTests.length} tests completed`);
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');
// 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 });
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
@ -297,3 +443,9 @@ async function runAndroidTests() {
// Execute the test suite
runAndroidTests();
// Add cleanup handler for SIGINT
process.on('SIGINT', () => {
rl.close();
process.exit();
});

647
scripts/test-ios.js

@ -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...');
// 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');
log('\n🔍 DEBUG: Starting test data generation...');
// 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' });
} 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.');
}
}
log('✅ CocoaPods installation completed');
} catch (error) {
// 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...');
}
// 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,95 +511,129 @@ const runIosApp = async (log, simulator) => {
log('✅ App launched successfully');
};
/**
* Run deeplink tests
* Optionally tests deeplinks if the test data is available
*
* @param {function} log - Logging function
* @returns {Promise<void>}
*/
const runDeeplinkTests = async (log) => {
log('🔗 Starting deeplink tests...');
const validateTestData = (log) => {
log('\n=== VALIDATING TEST DATA ===');
// Register URL scheme if needed
checkAndRegisterUrlScheme(log);
const generateFreshTestData = () => {
log('\n🔄 Generating fresh test data...');
try {
// Ensure .generated directory exists
if (!existsSync('.generated')) {
mkdirSync('.generated', { recursive: true });
}
// Check if test data files exist first
const requiredFiles = [
'.generated/test-env.json',
'.generated/claim_details.json',
'.generated/contacts.json'
];
// 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'
});
for (const file of requiredFiles) {
if (!existsSync(file)) {
log(`⚠️ Required file ${file} does not exist`);
log('⚠️ Skipping deeplink tests');
return;
// 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');
}
try {
// Load test data
log('📂 Loading test data from .generated directory');
let testEnv, claimDetails, contacts;
log('Generated test data:', {
testEnv: testEnv,
contacts: contacts
});
try {
const testEnvContent = readFileSync('.generated/test-env.json', 'utf8');
testEnv = JSON.parse(testEnvContent);
log('✅ Loaded test-env.json');
return { testEnv, contacts };
} catch (error) {
log(`⚠️ Failed to load test-env.json: ${error.message}`);
return;
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 claimDetailsContent = readFileSync('.generated/claim_details.json', 'utf8');
claimDetails = JSON.parse(claimDetailsContent);
log('✅ Loaded claim_details.json');
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(`⚠️ Failed to load claim_details.json: ${error.message}`);
return;
log('⚠️ Error reading existing test data, regenerating...');
testData = generateFreshTestData();
}
}
try {
const contactsContent = readFileSync('.generated/contacts.json', 'utf8');
contacts = JSON.parse(contactsContent);
log('✅ Loaded contacts.json');
// 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(`⚠️ Failed to load contacts.json: ${error.message}`);
return;
log(`❌ Test data validation failed: ${error.message}`);
throw error;
}
};
/**
* Run deeplink tests
* Optionally tests deeplinks if the test data is available
*
* @param {function} log - Logging function
* @returns {Promise<void>}
*/
const runDeeplinkTests = async (log) => {
log('\n=== Starting Deeplink Tests ===');
// Check if the app URL scheme is registered in the simulator
log('🔍 Checking if URL scheme is registered in simulator...');
// Validate test data before proceeding
let testEnv, contacts;
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');
({ testEnv, contacts } = validateTestData(log));
} 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');
}
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
}
// Test URLs
// Now we can safely create the deeplink tests knowing we have valid data
const deeplinkTests = [
{
url: `timesafari://claim/${claimDetails.claim_id}`,
url: `timesafari://claim/${testEnv.CLAIM_ID}`,
description: 'Claim view'
},
{
url: `timesafari://claim-cert/${claimDetails.claim_id}`,
url: `timesafari://claim-cert/${testEnv.CERT_ID || testEnv.CLAIM_ID}`,
description: 'Claim certificate view'
},
{
url: `timesafari://claim-add-raw/${claimDetails.claim_id}`,
url: `timesafari://claim-add-raw/${testEnv.RAW_CLAIM_ID || testEnv.CLAIM_ID}`,
description: 'Raw claim addition'
},
{
@ -465,7 +645,14 @@ const runDeeplinkTests = async (log) => {
description: 'DID view with contact DID'
},
{
url: `timesafari://contact-edit/${testEnv.CONTACT1_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'
},
{
@ -474,21 +661,63 @@ const runDeeplinkTests = async (log) => {
}
];
// 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 {
// 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

334
src/components/ActivityListItem.vue

@ -0,0 +1,334 @@
<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="bg-slate-100 rounded-t-md border border-slate-300 p-3 sm:p-4">
<div class="flex items-center gap-2 mb-6">
<img
src="https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg"
class="size-8 object-cover rounded-full"
/>
<div>
<h3 class="font-semibold">
{{
record.giver.known ? record.giver.displayName : "Anonymous Giver"
}}
</h3>
<p class="ms-auto text-xs text-slate-500 italic">
{{ friendlyDate }}
</p>
</div>
</div>
<!-- Record Image -->
<div
v-if="record.image"
class="bg-cover mb-6 -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">
<template v-if="record.giver.profileImageUrl">
<EntityIcon
:profile-image-url="record.giver.profileImageUrl"
:class="[
!record.providerPlanName
? 'rounded-full size-[3rem] sm:size-[4rem] object-cover'
: 'rounded size-[3rem] sm:size-[4rem] object-cover',
]"
/>
</template>
<template v-else>
<!-- Project Icon -->
<template v-if="record.providerPlanName">
<ProjectIcon
:entity-id="record.providerPlanName"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</template>
<!-- Identicon for DIDs -->
<template v-else-if="record.giver.did">
<img
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
class="rounded-full size-[3rem] sm:size-[4rem]"
alt="Identicon"
/>
</template>
<!-- Unknown Person -->
<template v-else>
<fa
icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]"
/>
</template>
</template>
</div>
<div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2">
<fa
:icon="record.providerPlanName ? 'building' : 'user'"
class="fa-fw text-slate-400"
/>
{{ 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">
<template v-if="record.receiver.profileImageUrl">
<EntityIcon
:profile-image-url="record.receiver.profileImageUrl"
:class="[
!record.recipientProjectName
? 'rounded-full size-[3rem] sm:size-[4rem] object-cover'
: 'rounded size-[3rem] sm:size-[4rem] object-cover',
]"
/>
</template>
<template v-else>
<!-- Project Icon -->
<template v-if="record.recipientProjectName">
<ProjectIcon
:entity-id="record.recipientProjectName"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</template>
<!-- Identicon for DIDs -->
<template v-else-if="record.receiver.did">
<img
:src="`https://a.fsdn.com/con/app/proj/identicons/screenshots/225506.jpg`"
class="rounded-full size-[3rem] sm:size-[4rem]"
alt="Identicon"
/>
</template>
<!-- Unknown Person -->
<template v-else>
<fa
icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]"
/>
</template>
</template>
</div>
<div class="text-xs mt-2 line-clamp-3 sm:line-clamp-2">
<fa
:icon="record.recipientProjectName ? 'building' : 'user'"
class="fa-fw text-slate-400"
/>
{{ record.receiver.displayName }}
</div>
</div>
</div>
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
{{ description }}
</a>
</p>
<p class="text-sm">{{ subDescription }}</p>
</div>
<div
class="flex items-center gap-2 text-lg bg-slate-300 rounded-b-md px-3 sm:px-4 py-1 sm:py-2"
>
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
<fa icon="circle-info" class="fa-fw text-slate-500" />
</a>
</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,
},
})
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;
}
private formatParticipantInfo(): string {
const { giver, receiver } = this.record;
// Both participants are known contacts
if (giver.known && receiver.known) {
return `${giver.displayName} gave to ${receiver.displayName}`;
}
// Only giver is known
if (giver.known) {
const recipient = this.record.recipientProjectName
? `the project "${this.record.recipientProjectName}"`
: receiver.displayName;
return `${giver.displayName} gave to ${recipient}`;
}
// Only receiver is known
if (receiver.known) {
const provider = this.record.providerPlanName
? `the project "${this.record.providerPlanName}"`
: giver.displayName;
return `${receiver.displayName} received from ${provider}`;
}
// Neither is known
return this.formatUnknownParticipants();
}
private formatUnknownParticipants(): string {
const { giver, receiver, providerPlanName, recipientProjectName } =
this.record;
if (providerPlanName || recipientProjectName) {
const from = providerPlanName
? `the project "${providerPlanName}"`
: giver.displayName;
const to = recipientProjectName
? `the project "${recipientProjectName}"`
: receiver.displayName;
return `from ${from} to ${to}`;
}
return giver.displayName === receiver.displayName
? `between two who are ${giver.displayName}`
: `from ${giver.displayName} to ${receiver.displayName}`;
}
get description(): string {
const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
if (!claim.description) {
return "something not described";
}
return `${claim.description}`;
}
get subDescription(): string {
const participants = this.formatParticipantInfo();
return `${participants}`;
}
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 formattedTimestamp() {
// Add your timestamp formatting logic here
return this.record.timestamp;
}
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>

24
src/electron/electron-logger.js

@ -1,17 +1,20 @@
/**
* Electron-specific logger implementation
*/
const fs = require('fs');
const path = require('path');
const { app } = require('electron');
const fs = require("fs");
const path = require("path");
const { app } = require("electron");
// Create logs directory if it doesn't exist
const logsDir = path.join(app.getPath('userData'), 'logs');
const logsDir = path.join(app.getPath("userData"), "logs");
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logFile = path.join(logsDir, `electron-${new Date().toISOString().split('T')[0]}.log`);
const logFile = path.join(
logsDir,
`electron-${new Date().toISOString().split("T")[0]}.log`,
);
function log(level, message) {
const timestamp = new Date().toISOString();
@ -21,13 +24,14 @@ function log(level, message) {
fs.appendFileSync(logFile, logMessage);
// Also output to console
// eslint-disable-next-line no-console
console[level.toLowerCase()](message);
}
module.exports = {
info: (message) => log('INFO', message),
warn: (message) => log('WARN', message),
error: (message) => log('ERROR', message),
debug: (message) => log('DEBUG', message),
getLogPath: () => logFile
info: (message) => log("INFO", message),
warn: (message) => log("WARN", message),
error: (message) => log("ERROR", message),
debug: (message) => log("DEBUG", message),
getLogPath: () => logFile,
};

147
src/electron/main.js

@ -1,7 +1,6 @@
const { app, BrowserWindow, session, protocol, ipcMain, dialog } = require('electron');
const path = require('path');
const fs = require('fs');
const url = require('url');
const { app, BrowserWindow, session, protocol, dialog } = require("electron");
const path = require("path");
const fs = require("fs");
// Global window reference
let mainWindow = null;
@ -11,13 +10,15 @@ const isDev = !app.isPackaged;
// Helper for logging
function logDebug(...args) {
console.log('[DEBUG]', ...args);
// eslint-disable-next-line no-console
console.log("[DEBUG]", ...args);
}
function logError(...args) {
console.error('[ERROR]', ...args);
// eslint-disable-next-line no-console
console.error("[ERROR]", ...args);
if (!isDev && mainWindow) {
dialog.showErrorBox('TimeSafari Error', args.join(' '));
dialog.showErrorBox("TimeSafari Error", args.join(" "));
}
}
@ -25,22 +26,22 @@ function logError(...args) {
function getAppPath() {
if (app.isPackaged) {
const possiblePaths = [
path.join(process.resourcesPath, 'app.asar', 'dist-electron'),
path.join(process.resourcesPath, 'app.asar'),
path.join(process.resourcesPath, 'app'),
app.getAppPath()
path.join(process.resourcesPath, "app.asar", "dist-electron"),
path.join(process.resourcesPath, "app.asar"),
path.join(process.resourcesPath, "app"),
app.getAppPath(),
];
for (const testPath of possiblePaths) {
const testFile = path.join(testPath, 'www', 'index.html');
const testFile = path.join(testPath, "www", "index.html");
if (fs.existsSync(testFile)) {
logDebug(`Found valid app path: ${testPath}`);
return testPath;
}
}
logError('Could not find valid app path');
return path.join(process.resourcesPath, 'app.asar'); // Default fallback
logError("Could not find valid app path");
return path.join(process.resourcesPath, "app.asar"); // Default fallback
} else {
return __dirname;
}
@ -48,69 +49,81 @@ function getAppPath() {
// Create the browser window
function createWindow() {
logDebug('Creating window with paths:');
logDebug('- process.resourcesPath:', process.resourcesPath);
logDebug('- app.getAppPath():', app.getAppPath());
logDebug('- __dirname:', __dirname);
logDebug("Creating window with paths:");
logDebug("- process.resourcesPath:", process.resourcesPath);
logDebug("- app.getAppPath():", app.getAppPath());
logDebug("- __dirname:", __dirname);
// Create the browser window
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
webSecurity: true
}
webSecurity: true,
},
});
// Fix root file paths - replaces all protocol handling
protocol.interceptFileProtocol('file', (request, callback) => {
protocol.interceptFileProtocol("file", (request, callback) => {
let urlPath = request.url.substr(7); // Remove 'file://' prefix
urlPath = decodeURIComponent(urlPath); // Handle special characters
// Debug all asset requests
if (urlPath.includes('assets/') || urlPath.endsWith('.js') || urlPath.endsWith('.css') || urlPath.endsWith('.html')) {
if (
urlPath.includes("assets/") ||
urlPath.endsWith(".js") ||
urlPath.endsWith(".css") ||
urlPath.endsWith(".html")
) {
logDebug(`Intercepted request for: ${urlPath}`);
}
// Fix paths for files at root like registerSW.js or manifest.webmanifest
if (urlPath.endsWith('registerSW.js') ||
urlPath.endsWith('manifest.webmanifest') ||
urlPath.endsWith('sw.js')) {
if (
urlPath.endsWith("registerSW.js") ||
urlPath.endsWith("manifest.webmanifest") ||
urlPath.endsWith("sw.js")
) {
const appBasePath = getAppPath();
const filePath = path.join(appBasePath, 'www', path.basename(urlPath));
const filePath = path.join(appBasePath, "www", path.basename(urlPath));
if (fs.existsSync(filePath)) {
logDebug(`Serving ${urlPath} from ${filePath}`);
return callback({ path: filePath });
} else {
// For service worker, provide empty content to avoid errors
if (urlPath.endsWith('registerSW.js') || urlPath.endsWith('sw.js')) {
if (urlPath.endsWith("registerSW.js") || urlPath.endsWith("sw.js")) {
logDebug(`Providing empty SW file for ${urlPath}`);
// Create an empty JS file content that does nothing
const tempFile = path.join(app.getPath('temp'), path.basename(urlPath));
fs.writeFileSync(tempFile, '// Service workers disabled in Electron\n');
const tempFile = path.join(
app.getPath("temp"),
path.basename(urlPath),
);
fs.writeFileSync(
tempFile,
"// Service workers disabled in Electron\n",
);
return callback({ path: tempFile });
}
}
}
// Handle assets paths that might be requested from root
if (urlPath.startsWith('/assets/') || urlPath === '/assets') {
if (urlPath.startsWith("/assets/") || urlPath === "/assets") {
const appBasePath = getAppPath();
const filePath = path.join(appBasePath, 'www', urlPath);
const filePath = path.join(appBasePath, "www", urlPath);
logDebug(`Redirecting ${urlPath} to ${filePath}`);
return callback({ path: filePath });
}
// Handle assets paths that are missing the www folder
if (urlPath.includes('/assets/')) {
if (urlPath.includes("/assets/")) {
const appBasePath = getAppPath();
const relativePath = urlPath.substring(urlPath.indexOf('/assets/'));
const filePath = path.join(appBasePath, 'www', relativePath);
const relativePath = urlPath.substring(urlPath.indexOf("/assets/"));
const filePath = path.join(appBasePath, "www", relativePath);
if (fs.existsSync(filePath)) {
logDebug(`Fixing asset path ${urlPath} to ${filePath}`);
return callback({ path: filePath });
@ -126,61 +139,67 @@ function createWindow() {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"Content-Security-Policy": [
isDev
? "default-src 'self' file:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://*; connect-src 'self' https://*"
: "default-src 'self' file:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://image.timesafari.app https://*.americancloud.com; connect-src 'self' https://api.timesafari.app https://api.endorser.ch https://test-api.endorser.ch https://fonts.googleapis.com"
]
}
: "default-src 'self' file:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: https://image.timesafari.app https://*.americancloud.com; connect-src 'self' https://api.timesafari.app https://api.endorser.ch https://test-api.endorser.ch https://fonts.googleapis.com",
],
},
});
});
// Load the index.html with modifications
try {
const appPath = getAppPath();
const wwwFolder = path.join(appPath, 'www');
const indexPath = path.join(wwwFolder, 'index.html');
const wwwFolder = path.join(appPath, "www");
const indexPath = path.join(wwwFolder, "index.html");
logDebug('Loading app from:', indexPath);
logDebug("Loading app from:", indexPath);
// Check if the file exists
if (fs.existsSync(indexPath)) {
// Read and modify index.html to disable service worker
let indexContent = fs.readFileSync(indexPath, 'utf8');
let indexContent = fs.readFileSync(indexPath, "utf8");
// 1. Add base tag for proper path resolution
indexContent = indexContent.replace('<head>',
`<head>\n <base href="file://${wwwFolder}/">`);
indexContent = indexContent.replace(
"<head>",
`<head>\n <base href="file://${wwwFolder}/">`,
);
// 2. Disable service worker registration by replacing the script
if (indexContent.includes('registerSW.js')) {
if (indexContent.includes("registerSW.js")) {
indexContent = indexContent.replace(
/<script src="registerSW\.js"><\/script>/,
'<script>/* Service worker disabled in Electron */</script>'
"<script>/* Service worker disabled in Electron */</script>",
);
}
// Create a temp file with modified content
const tempDir = app.getPath('temp');
const tempIndexPath = path.join(tempDir, 'timesafari-index.html');
const tempDir = app.getPath("temp");
const tempIndexPath = path.join(tempDir, "timesafari-index.html");
fs.writeFileSync(tempIndexPath, indexContent);
// Load the modified index.html
mainWindow.loadFile(tempIndexPath).catch(err => {
logError('Failed to load via loadFile:', err);
mainWindow.loadFile(tempIndexPath).catch((err) => {
logError("Failed to load via loadFile:", err);
// Fallback to direct URL loading
mainWindow.loadURL(`file://${tempIndexPath}`).catch(err2 => {
logError('Both loading methods failed:', err2);
mainWindow.loadURL('data:text/html,<h1>Error: Failed to load TimeSafari</h1><p>Please contact support.</p>');
mainWindow.loadURL(`file://${tempIndexPath}`).catch((err2) => {
logError("Both loading methods failed:", err2);
mainWindow.loadURL(
"data:text/html,<h1>Error: Failed to load TimeSafari</h1><p>Please contact support.</p>",
);
});
});
} else {
logError(`Index file not found at: ${indexPath}`);
mainWindow.loadURL('data:text/html,<h1>Error: Cannot find application</h1><p>index.html not found</p>');
mainWindow.loadURL(
"data:text/html,<h1>Error: Cannot find application</h1><p>index.html not found</p>",
);
}
} catch (err) {
logError('Failed to load app:', err);
logError("Failed to load app:", err);
}
// Open DevTools in development
@ -188,7 +207,7 @@ function createWindow() {
mainWindow.webContents.openDevTools();
}
mainWindow.on('closed', () => {
mainWindow.on("closed", () => {
mainWindow = null;
});
}
@ -198,20 +217,20 @@ app.whenReady().then(() => {
logDebug(`Starting TimeSafari v${app.getVersion()}`);
// Skip the service worker registration for file:// protocol
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true';
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
createWindow();
app.on('activate', () => {
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logError('Uncaught Exception:', error);
process.on("uncaughtException", (error) => {
logError("Uncaught Exception:", error);
});

35
src/electron/preload.js

@ -3,14 +3,15 @@ const { contextBridge, ipcRenderer } = require("electron");
// Safety wrapper for logging
function safeLog(message) {
try {
console.log('[Preload]', message);
// eslint-disable-next-line no-console
console.log("[Preload]", message);
} catch (e) {
// Silent fail for logging
}
}
// Initialize
safeLog('Preload script starting...');
safeLog("Preload script starting...");
try {
// Mock service worker registration to prevent errors
@ -19,12 +20,12 @@ try {
window.navigator.serviceWorker = {
register: () => Promise.resolve({}),
getRegistration: () => Promise.resolve(null),
ready: Promise.resolve({})
ready: Promise.resolve({}),
};
}
// Safely expose specific APIs to the renderer process
contextBridge.exposeInMainWorld('electronAPI', {
contextBridge.exposeInMainWorld("electronAPI", {
// Basic flags/info
isElectron: true,
@ -34,7 +35,8 @@ try {
// Logging
log: (message) => {
try {
console.log('[Renderer]', message);
// eslint-disable-next-line no-console
console.log("[Renderer]", message);
} catch (e) {
// Silence any errors from logging
}
@ -43,24 +45,25 @@ try {
// Report errors to main process
reportError: (error) => {
try {
ipcRenderer.send('app-error', error.toString());
ipcRenderer.send("app-error", error.toString());
} catch (e) {
console.error('Failed to report error to main process', e);
// eslint-disable-next-line no-console
console.error("Failed to report error to main process", e);
}
},
// Safe path handling helper (no Node modules needed)
joinPath: (...parts) => {
return parts.join('/').replace(/\/\//g, '/');
return parts.join("/").replace(/\/\//g, "/");
},
// Fix asset URLs
resolveAssetUrl: (assetPath) => {
if (assetPath.startsWith('/assets/')) {
if (assetPath.startsWith("/assets/")) {
return assetPath; // Already properly formed
}
if (assetPath.startsWith('assets/')) {
return '/' + assetPath; // Add leading slash
if (assetPath.startsWith("assets/")) {
return "/" + assetPath; // Add leading slash
}
return assetPath;
},
@ -68,7 +71,7 @@ try {
// Send messages to main process
send: (channel, data) => {
// Whitelist channels for security
const validChannels = ['app-event', 'log-event', 'app-error'];
const validChannels = ["app-event", "log-event", "app-error"];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
@ -76,17 +79,17 @@ try {
// Receive messages from main process
receive: (channel, func) => {
const validChannels = ['app-notification', 'log-response'];
const validChannels = ["app-notification", "log-response"];
if (validChannels.includes(channel)) {
// Remove old listeners to avoid memory leaks
ipcRenderer.removeAllListeners(channel);
// Add the new listener
ipcRenderer.on(channel, (_, ...args) => func(...args));
}
}
},
});
safeLog('Preload script completed successfully');
safeLog("Preload script completed successfully");
} catch (err) {
safeLog('Error in preload script: ' + err.toString());
safeLog("Error in preload script: " + err.toString());
}

1
src/libs/util.ts

@ -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

166
src/main.ts

@ -7,7 +7,171 @@ 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,
faBuilding,
} 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,
faBuilding,
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";

4
src/registerServiceWorker.ts

@ -41,12 +41,12 @@ if (
export function registerServiceWorker() {
// Skip service worker registration in Electron
if (window.electronAPI?.isElectron) {
console.log('Running in Electron - skipping service worker registration');
console.log("Running in Electron - skipping service worker registration");
return;
}
// Regular service worker registration for web
if ('serviceWorker' in navigator) {
if ("serviceWorker" in navigator) {
// ... existing code ...
}
}

14
src/router/index.ts

@ -157,6 +157,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 +286,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:";

53
src/services/deepLinks.ts

@ -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,16 +129,37 @@ 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];
try {
const validatedParams = await schema.parseAsync({
...params,
...query,
@ -144,5 +170,22 @@ export class DeepLinkHandler {
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,
};
}
}
}

24
src/types/deepLinks.ts

@ -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({

20
src/types/index.ts

@ -0,0 +1,20 @@
export interface GiveRecordWithContactInfo {
jwtId: string;
fullClaim: unknown; // Replace with proper type
giver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
receiver: {
known: boolean;
displayName: string;
profileImageUrl?: string;
};
providerPlanName?: string;
recipientProjectName?: string;
description?: string;
subDescription?: string;
image?: string;
timestamp: string;
}

31
src/views/AccountViewView.vue

@ -562,11 +562,22 @@
<router-link
id="switch-identity-link"
:to="{ name: 'identity-switcher' }"
class="block w-fit text-center text-md uppercase 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-4 py-2 rounded-md mb-2"
class="block w-fit text-center 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-4 py-2 rounded-md mb-2"
>
Switch Identifier
</router-link>
<div class="flex mt-4">
<button>
<router-link
:to="{ name: 'statistics' }"
class="block w-fit text-center 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-4 py-2 rounded-md mb-2"
>
See Global Animated History of Giving
</router-link>
</button>
</div>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">
Import Contacts & Settings Database
@ -856,17 +867,6 @@
</div>
</label>
<div class="flex mt-4">
<button>
<router-link
:to="{ name: 'statistics' }"
class="block w-fit text-center 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-4 py-2 rounded-md mb-2"
>
See Global Animated History of Giving
</router-link>
</button>
</div>
<div id="sectionPasskeyExpiration" class="flex justify-between">
<span>
<span class="text-slate-500 text-sm font-bold mb-2">
@ -912,6 +912,13 @@
/>
</div>
</label>
<router-link
:to="{ name: 'logs' }"
class="block w-fit text-center 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-4 py-2 rounded-md mb-2"
>
View Logs
</router-link>
</div>
</section>
</template>

2
src/views/ConfirmGiftView.vue

@ -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",

4
src/views/ContactQRScanShowView.vue

@ -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" />

232
src/views/DeepLinkErrorView.vue

@ -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>

265
src/views/HomeView.vue

@ -187,23 +187,23 @@
</div>
<!-- Results List -->
<div class="bg-slate-100 rounded-md px-4 py-3 mt-4 mb-4">
<div class="mt-4 mb-4">
<div class="flex items-center mb-4">
<h2 class="text-xl font-bold">
<h2 class="text-xl font-bold flex items-center gap-4">
Latest Activity
<button @click="openFeedFilters()">
<span class="text-xs text-white">
<font-awesome
<button
v-if="resultsAreFiltered()"
icon="filter"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
/>
<font-awesome
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
@click="openFeedFilters()"
>
<fa icon="filter" class="fa-fw" />
</button>
<button
v-else
icon="filter"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-1 py-1.5 rounded-md"
/>
</span>
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] px-3 py-1.5 rounded-md text-xs text-white"
@click="openFeedFilters()"
>
<fa icon="filter" class="fa-fw" />
</button>
</h2>
</div>
@ -250,84 +250,20 @@
</div>
<InfiniteScroll @reached-bottom="loadMoreGives">
<ul id="listLatestActivity" class="border-t border-slate-300">
<li
<ul id="listLatestActivity" class="space-y-4">
<ActivityListItem
v-for="record in feedData"
:key="record.jwtId"
class="border-b border-slate-300 py-2"
>
<div
v-if="record.jwtId == feedLastViewedClaimId"
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
>
You've already seen all the following
</div>
<div class="grid grid-cols-12">
<span class="pt-1 col-span-1 justify-self-start">
<span>
<font-awesome
icon="circle-user"
:class="
computeKnownPersonIconStyleClassNames(
record.giver.known || record.receiver.known,
)
"
@click="toastUser('This involves your contacts.')"
/>
<font-awesome
icon="gift"
class="pl-3 text-slate-500"
@click="toastUser('This is a gift.')"
/>
</span>
</span>
<span class="col-span-10 justify-self-stretch overflow-hidden">
<span class="pl-2 block break-words">
{{ giveDescription(record) }}
</span>
<a @click="onClickLoadClaim(record.jwtId)">
<font-awesome
icon="file-lines"
class="pl-2 text-slate-500 cursor-pointer"
:record="record"
:last-viewed-claim-id="feedLastViewedClaimId"
:is-registered="isRegistered"
:active-did="activeDid"
:confirmer-id-list="record.confirmerIdList"
@load-claim="onClickLoadClaim"
@view-image="openImageViewer"
@cache-image="cacheImageData"
@confirm-claim="confirmClaim"
/>
</a>
</span>
<span class="col-span-1 justify-self-end">
<router-link
v-if="record.fulfillsPlanHandleId"
:to="
'/project/' +
encodeURIComponent(record.fulfillsPlanHandleId)
"
>
<font-awesome icon="hammer" class="text-blue-500" />
</router-link>
<router-link
v-if="record.providerPlanHandleId"
:to="
'/project/' +
encodeURIComponent(record.providerPlanHandleId)
"
>
<font-awesome icon="hammer" class="text-blue-500" />
</router-link>
</span>
</div>
<div v-if="record.image" class="w-full">
<div
class="cursor-pointer"
@click="openImageViewer(record.image)"
>
<img
:src="record.image"
class="w-full aspect-[3/2] object-cover rounded-xl mt-2"
alt="shared content"
@load="cacheImageData($event, record.image)"
/>
</div>
</div>
</li>
</ul>
</InfiniteScroll>
<div v-if="isFeedLoading">
@ -369,6 +305,7 @@ import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import ImageViewer from "../components/ImageViewer.vue";
import ActivityListItem from "../components/ActivityListItem.vue";
import {
AppString,
NotificationIface,
@ -403,6 +340,9 @@ import {
OnboardPage,
} from "../libs/util";
import { GiveSummaryRecord } from "../interfaces";
import * as serverUtil from "../libs/endorserServer";
// import { fa0 } from "@fortawesome/free-solid-svg-icons";
interface GiveRecordWithContactInfo extends GiveSummaryRecord {
jwtId: string;
giver: {
@ -442,6 +382,7 @@ import { logger } from "../utils/logger";
TopMessage,
UserNameDialog,
ImageViewer,
ActivityListItem,
},
})
export default class HomeView extends Vue {
@ -522,10 +463,88 @@ export default class HomeView extends Vue {
this.isCreatingIdentifier = false;
this.allMyDids = [newDid];
}
} catch (error) {
logConsoleAndDb(
"Error retrieving all account DIDs on home page:" + error,
true,
const settings = await retrieveSettingsForActiveAccount();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.feedLastViewedClaimId = settings.lastViewedClaimId;
this.givenName = settings.firstName || "";
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isRegistered = !!settings.isRegistered;
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId;
this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc;
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(
OnboardPage.Home,
);
}
// someone may have have registered after sharing contact info, so recheck
if (!this.isRegistered && this.activeDid) {
try {
const resp = await fetchEndorserRateLimits(
this.apiServer,
this.axios,
this.activeDid,
);
if (resp.status === 200) {
await updateAccountSettings(this.activeDid, {
isRegistered: true,
...(await retrieveSettingsForActiveAccount()),
});
this.isRegistered = true;
}
} catch (e) {
// ignore the error... just keep us unregistered
}
}
// this returns a Promise but we don't need to wait for it
this.updateAllFeed();
if (this.activeDid) {
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
}
if (this.activeDid) {
const offersToUserProjects = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
err.userMessage ||
"There was an error retrieving your settings or the latest activity.",
},
5000,
);
}
}
@ -1079,5 +1098,67 @@ export default class HomeView extends Vue {
this.selectedImage = imageUrl;
this.isImageViewerOpen = true;
}
async confirmClaim(record: GiveRecordWithContactInfo) {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Confirm",
text: "Do you personally confirm that this is true?",
onYes: async () => {
const goodClaim = serverUtil.removeSchemaContext(
serverUtil.removeVisibleToDids(
serverUtil.addLastClaimOrHandleAsIdIfMissing(
record.fullClaim,
record.jwtId,
record.handleId,
),
),
);
const confirmationClaim = {
"@context": "https://schema.org",
"@type": "AgreeAction",
object: goodClaim,
};
const result = await serverUtil.createAndSubmitClaim(
confirmationClaim,
this.activeDid,
this.apiServer,
this.axios,
);
if (result.type === "success") {
this.$notify(
{
group: "alert",
type: "success",
title: "Success",
text: "Confirmation submitted.",
},
3000,
);
// Refresh the feed to show updated confirmation status
await this.updateAllFeed();
} else {
logger.error("Error submitting confirmation:", result);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem submitting the confirmation.",
},
5000,
);
}
},
},
-1,
);
}
}
</script>

98
src/views/LogView.vue

@ -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>

28
src/views/ProjectViewView.vue

@ -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"
/>

123
test-deeplinks.sh.bak

@ -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 "======================================"

10
test-playwright/TESTING.md

@ -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).

Loading…
Cancel
Save