Compare commits

...

35 Commits

Author SHA1 Message Date
8a2966a13e Enhance commentary and logging. 2025-07-07 11:17:10 -06:00
bfa75484f3 Fix issue showing the actions on contact screen. (Test still fails later.) 2025-07-07 11:16:18 -06:00
f71bd9fb42 Fix webapp-run command for test:web. 2025-07-06 19:02:09 -06:00
2bd191e255 Move remaining strings for Environment & Platform to type-checked values. 2025-07-06 14:41:20 -06:00
5905ae871a Refactor build files, separating & consolidating & renaming as needed. 2025-07-06 13:13:35 -06:00
65877627c7 refactor documentation 2025-07-06 09:28:42 -06:00
cbe8c2e427 order build commands alphabetically in package.json 2025-07-06 09:24:37 -06:00
538c2a4369 change each package.json build command to be consistent, with action first, followed by platform 2025-07-06 09:22:49 -06:00
36469a7fd2 remove unused deploy-specific file (now handled by NODE_ENV) 2025-07-05 21:55:11 -06:00
43c5a28153 remove vite.config files that are split into platform files 2025-07-05 21:51:09 -06:00
b6c932b22c rename env names to match proposed build names 2025-07-05 21:45:05 -06:00
322c785119 begin corrections for builds in specific environments (dev/test/prod) 2025-07-05 21:38:46 -06:00
a96cc8155c fix incorrect checks for success 2025-07-04 16:58:18 -06:00
1b283a0045 Merge pull request 'Lock to Portrait Mode (iOS and Android)' (#141) from app-portrait-mode-lock into master
Reviewed-on: #141
2025-06-27 21:47:11 -04:00
afd407e178 add portrait-mode camera to CHANGELOG 2025-06-27 19:46:30 -06:00
Jose Olarte III
59b13823c8 Feature: lock orientation mode 2025-06-23 17:39:21 +08:00
3baa6633a6 on mobile: bump version to 1.0.2 and build to 35 2025-06-20 20:27:16 -06:00
bda98eb632 reword the account-download button 2025-06-20 19:36:16 -06:00
eea1cb995a bump to version 1.0.3-beta 2025-06-20 19:27:07 -06:00
276e0a741b put version on front page so that people can tell whether to refresh 2025-06-20 19:03:50 -06:00
e46d6133fb bump to version 1.0.1 2025-06-20 15:56:47 -06:00
94994a7251 allow blocking another person's content from this user (with iViewContent contact field) 2025-06-20 15:53:31 -06:00
838723c26b remove debugging info messages (change to debug if we want these -- and tell us how to turn off debug locally) 2025-06-20 14:01:08 -06:00
bb6eb92ba1 fix ? instead of 0 in rate limits, update location verbiage 2025-06-20 13:34:14 -06:00
a997d4cb92 Merge branch 'migrate-dexie-to-sqlite' 2025-06-20 11:49:51 -06:00
73733345ff bump to version 1.0.0-beta 2025-06-20 11:46:09 -06:00
3118f71320 fix linting (whitespace only) 2025-06-18 21:44:11 -06:00
d12f23aa81 Merge pull request 'Make all external URLs go to the /deep-link/ endpoint to redirect to mobile vs web' (#139) from deep-link-redirect into master
Reviewed-on: #139
2025-06-18 23:33:12 -04:00
e9a8a3c1e7 add support for deep-link query parameters 2025-06-18 19:31:16 -06:00
1e0efe6011 lengthen the error timeout when the message may be complicated, eg. with details from the server 2025-06-18 18:32:55 -06:00
16557f1e4b update build instruction & package-lock.json 2025-06-18 17:32:41 -06:00
c4a54967bc fix linting 2025-06-18 16:33:55 -06:00
20ade415dc bump to version 0.5.8 build 34 2025-06-18 16:31:31 -06:00
6689520270 fix all copies for externally-shared links to redirected deep links 2025-06-18 15:53:16 -06:00
3fd6c2b80d add first cut at deep-link redirecting, with one example contact-import that works on mobile 2025-06-18 13:16:17 -06:00
69 changed files with 1053 additions and 744 deletions

View File

@@ -3,7 +3,7 @@
# iOS doesn't like spaces in the app title.
TIME_SAFARI_APP_TITLE="TimeSafari_Test"
VITE_APP_SERVER=https://test.timesafari.app
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
# This is the claim ID for actions in the BVC project.
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch

7
.gitignore vendored
View File

@@ -8,6 +8,7 @@ signature.bin
# generated during `npm run build`
sw_scripts-combined.js
*.pem
tsconfig.node.tsbuildinfo
verified.txt
myenv
@@ -40,19 +41,15 @@ pnpm-debug.log*
playwright-tests
dist-electron-packages
.ruby-version
+.env
# Test files generated by scripts test-ios.js & test-android.js
.generated/
.env.default
vendor/
# Build logs
build_logs/
# PWA icon files generated by capacitor-assets
icons
android/app/src/main/res/
android/app/src/main/res/

View File

@@ -6,12 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Fixed
- Set the environment variables correctly (so simulator deep link domain is right).
### Changed
- Photo is pinned to profile mode.
- NODE_ENV is now mandatory.
## [1.0.0] - 2025.06.20 - 9b69c0b22c7e3ac0584219f5ac434a02bda2e01b
## [1.0.2] - 2025.06.20 - 276e0a741bc327de3380c4e508cccb7fee58c06d
### Added
- Version on feed title
## [1.0.1] - 2025.06.20
### Added
- Allow a user to block someone else's content from view
## [1.0.0] - 2025.06.20 - 5aa693de6337e5dbb278bfddc6bd39094bc14f73
### Added
- Web-oriented migration from IndexedDB to SQLite
## [0.5.8]
### Added
- /deep-link/ path for URLs that are shared with people
### Changed
- External links now go to /deep-link/...
- Feed visuals now have arrow imagery from giver to receiver
## [0.4.7]
### Fixed
- Cameras everywhere

View File

@@ -3,49 +3,22 @@
[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude
and expand to crowd-fund with time & money, then record and see the impact of contributions.
## Database Migration Status
**Current Status**: The application is undergoing a migration from Dexie (IndexedDB) to SQLite using absurd-sql. This migration is in **Phase 2** with a well-defined migration fence in place.
### Migration Progress
-**SQLite Database Service**: Fully implemented with absurd-sql
-**Platform Service Layer**: Unified database interface across platforms
-**Settings Migration**: Core user settings transferred
-**Account Migration**: Identity and key management
- 🔄 **Contact Migration**: User contact data (via import interface)
- 📋 **Code Cleanup**: Remove unused Dexie imports
### Migration Fence
The migration is controlled by a **migration fence** that separates legacy Dexie code from the new SQLite implementation. See [Migration Fence Definition](doc/migration-fence-definition.md) for complete details.
**Key Points**:
- Legacy Dexie database is disabled by default (`USE_DEXIE_DB = false`)
- All database operations go through `PlatformService`
- Migration tools provide controlled access to both databases
- Clear separation between legacy and new code
### Migration Documentation
- [Migration Guide](doc/migration-to-wa-sqlite.md) - Complete migration process
- [Migration Fence Definition](doc/migration-fence-definition.md) - Fence boundaries and rules
- [Database Migration Guide](doc/database-migration-guide.md) - User-facing migration tools
## Roadmap
See [project.task.yaml](project.task.yaml) for current priorities.
(Numbers at the beginning of lines are estimated hours. See [taskyaml.org](https://taskyaml.org/) for details.)
See [ClickUp](https://sharing.clickup.com/9014278710/l/h/8cmnyhp-174/10573fec74e2ba0) for current priorities.
## Setup & Building
Quick start:
* For setup, we recommend [pkgx](https://pkgx.dev), which installs what you need (either automatically or with the `dev` command). Core dependencies are typescript & npm; when building for other platforms, you'll need other things such as those in the pkgx.yaml & BUILDING.md files.
* For setup, we recommend [pkgx](https://pkgx.dev), which installs what you need (either automatically or with the `dev` command). Core dependencies are typescript & npm; when building for other platforms, you'll need other things such as those in the pkgx.yaml & doc/BUILDING.md files.
```bash
npm install
npm run dev
NODE_ENV=dev npm run start:web
```
See [BUILDING.md](BUILDING.md) for more details.
See [BUILDING.md](doc/BUILDING.md) for more details.
## Tests
@@ -97,9 +70,6 @@ The application uses a platform-agnostic database layer:
**Development Guidelines**:
- Always use `PlatformService` for database operations
- Never import Dexie directly in application code
- Test with `USE_DEXIE_DB = false` for new features
- Use migration tools for data transfer between systems
### Kudos

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 33
versionName "0.5.7"
versionCode 35
versionName "1.0.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -13,6 +13,7 @@
android:exported="true"
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -11,17 +11,6 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
- Git
- 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 (see below).
## Initial Setup
Install dependencies:
@@ -33,7 +22,7 @@ Install dependencies:
## Web Dev Locally
```bash
npm run dev
NODE_ENV=dev npm run start:web
```
## Web Build for Server
@@ -41,7 +30,8 @@ Install dependencies:
1. Run the production build:
```bash
npm run build:web
rm -rf dist
NODE_ENV=prod npm run build:web
```
The built files will be in the `dist` directory.
@@ -51,7 +41,7 @@ Install dependencies:
You'll likely want to use test locations for the Endorser & image & partner servers; see "DEFAULT_ENDORSER_API_SERVER" & "DEFAULT_IMAGE_API_SERVER" & "DEFAULT_PARTNER_API_SERVER" below.
```bash
npm run serve
NODE_ENV=dev npm run serve:web
```
### Compile and minify for test & production
@@ -62,16 +52,18 @@ Install dependencies:
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Run a build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build:web`
* Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version in the step later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 1.0.0 && git push origin 1.0.0`.
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 1.0.2 && git push origin 1.0.2`.
* For test, build the app (because test server is not yet set up to build):
```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web
NODE_ENV=test TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web
```
... and transfer to the test server:
@@ -90,13 +82,13 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.5.9 && npm install && npm run build:web && cd -`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web && cd -`
(The plain `npm run build:web` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-2 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, commit, and push. Also record what version is on production.
## Docker Deployment
@@ -213,10 +205,10 @@ docker run -d \
```bash
# For AppImage (recommended)
npm run electron:build-linux
npm run build:electron-linux
# For .deb package
npm run electron:build-linux-deb
npm run build:electron-linux-deb
```
3. The packaged applications will be in `dist-electron-packages/`:
@@ -228,19 +220,19 @@ docker run -d \
1. Build the electron app in production mode:
```bash
npm run build:web
NODE_ENV=prod npm run build:web
npm run build:electron
npm run electron:build-mac
npm run build:electron-mac
```
2. Package the Electron app for macOS:
```bash
# For Intel Macs
npm run electron:build-mac
npm run build:electron-mac
# For Universal build (Intel + Apple Silicon)
npm run electron:build-mac-universal
npm run build:electron-mac-universal
```
3. The packaged applications will be in `dist-electron-packages/`:
@@ -262,7 +254,7 @@ For public distribution on macOS, you need to code sign and notarize your app:
2. Build with signing:
```bash
npm run electron:build-mac
npm run build:electron-mac
```
### Running the Packaged App
@@ -301,10 +293,10 @@ For testing the Electron build before packaging:
```bash
# Build and run in development mode (includes DevTools)
npm run electron:dev
npm run build:electron
# Build in production mode and test
npm run build:electron-prod && npm run electron:start
npm run build:electron-prod && npm run start:electron
```
## Mobile Builds (Capacitor)
@@ -359,12 +351,9 @@ Prerequisites: macOS with Xcode installed
4. Bump the version to match Android & package.json:
```
cd ios/App
xcrun agvtool new-version 33
cd ios/App && xcrun agvtool new-version 35 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.2;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.7;/g" > temp && mv temp App.xcodeproj/project.pbxproj
cd -
```
5. Open the project in Xcode:
@@ -479,3 +468,12 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
```
... though when we tried that most recently it failed to 'build' the APK with: http(s) scheme and host attribute are missing, but are required for Android App Links [AppLinkUrlError]
## 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
```

View File

@@ -100,6 +100,7 @@ try {
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
- `src/services/deepLinks.ts`: Deep link processing service
- `src/main.capacitor.ts`: Capacitor integration
- `src/views/DeepLinkRedirectView.vue`: Page to handle links to both mobile and web
## Type Safety Examples

View File

@@ -27,6 +27,32 @@ The Database Migration feature allows you to compare and migrate data between De
- Clear success and error messaging
- Export functionality for comparison data
## Database Migration Status
**Current Status**: The application is undergoing a migration from Dexie (IndexedDB) to SQLite using absurd-sql. This migration is in **Phase 2** with a well-defined migration fence in place.
### Migration Progress
-**SQLite Database Service**: Fully implemented with absurd-sql
-**Platform Service Layer**: Unified database interface across platforms
-**Settings Migration**: Core user settings transferred
-**Account Migration**: Identity and key management
- 🔄 **Contact Migration**: User contact data (via import interface)
- 📋 **Code Cleanup**: Remove unused Dexie imports
### Migration Fence
The migration is controlled by a **migration fence** that separates legacy Dexie code from the new SQLite implementation. See [Migration Fence Definition](doc/migration-fence-definition.md) for complete details.
**Key Points**:
- Legacy Dexie database is disabled by default (`USE_DEXIE_DB = false`)
- All database operations go through `PlatformService`
- Migration tools provide controlled access to both databases
- Clear separation between legacy and new code
### Migration Documentation
- [Migration Guide](doc/migration-to-wa-sqlite.md) - Complete migration process
- [Migration Fence Definition](doc/migration-fence-definition.md) - Fence boundaries and rules
- [Database Migration Guide](doc/database-migration-guide.md) - User-facing migration tools
## Prerequisites
### Enable Dexie Database

View File

@@ -15,17 +15,21 @@
<script type="module">
const platform = process.env.VITE_PLATFORM;
switch (platform) {
case 'capacitor':
case 'capacitor': // BuildPlatform.Capacitor
import('./src/main.capacitor.ts');
break;
case 'electron':
case 'electron': // BuildPlatform.Electron
import('./src/main.electron.ts');
break;
case 'pywebview':
case 'pywebview': // BuildPlatform.PyWebView
import('./src/main.pywebview.ts');
break;
default:
case 'web': // BuildPlatform.Web
import('./src/main.web.ts');
break;
default:
console.error(`Unknown platform: ${platform}`);
throw new Error(`Unknown platform: ${platform}`);
}
</script>
</body>

View File

@@ -403,7 +403,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.7;
MARKETING_VERSION = 1.0.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 33;
CURRENT_PROJECT_VERSION = 35;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.7;
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

View File

@@ -37,8 +37,6 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "1.0.0",
"version": "1.0.3-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "1.0.0",
"version": "1.0.3-beta",
"dependencies": {
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",

View File

@@ -1,49 +1,42 @@
{
"name": "timesafari",
"version": "1.0.0",
"version": "1.0.3-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"
},
"scripts": {
"dev": "vite --config vite.config.dev.mts --host",
"serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
"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 && node scripts/copy-wasm.js",
"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",
"test:android": "node scripts/test-android.js",
"test:ios": "node scripts/test-ios.js",
"build-start:pywebview": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py",
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
"build:capacitor": "vite build --config vite.config.capacitor.mts",
"build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
"build:electron-linux": "npm run build:electron && electron-builder --linux AppImage",
"build:electron-linux-deb": "npm run build:electron && electron-builder --linux deb",
"build:electron-linux-prod": "NODE_ENV=prod npm run build:electron && electron-builder --linux AppImage",
"build:electron-mac": "npm run build:electron-prod && electron-builder --mac",
"build:electron-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal",
"build:electron-prod": "NODE_ENV=prod npm run build:electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
"check:android-device": "adb devices | grep -w 'device' || (echo 'No Android device connected' && exit 1)",
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
"electron:dev": "npm run build && electron .",
"electron:start": "electron .",
"clean:android": "adb uninstall app.timesafari.app || true",
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
"build:electron-prod": "NODE_ENV=production npm run build:electron",
"pywebview:dev": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py",
"pywebview:build": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py",
"pywebview:package-linux": "vite build --mode pywebview --config vite.config.pywebview.mts && .venv/bin/python -m PyInstaller --name TimeSafari --add-data 'dist:www' src/pywebview/main.py",
"pywebview:package-win": "vite build --mode pywebview --config vite.config.pywebview.mts && .venv/Scripts/python -m PyInstaller --name TimeSafari --add-data 'dist;www' src/pywebview/main.py",
"pywebview:package-mac": "vite build --mode pywebview --config vite.config.pywebview.mts && .venv/bin/python -m PyInstaller --name TimeSafari --add-data 'dist:www' src/pywebview/main.py",
"fastlane:ios:beta": "cd ios && fastlane beta",
"fastlane:ios:release": "cd ios && fastlane release",
"fastlane:android:beta": "cd android && fastlane beta",
"fastlane:android:release": "cd android && fastlane release",
"electron:build-mac": "npm run build:electron-prod && electron-builder --mac",
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
"clean:electron": "rimraf dist-electron",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"package:pywebview-linux": "vite build --config vite.config.pywebview.mts && .venv/bin/python -m PyInstaller --name TimeSafari --add-data 'dist:www' src/pywebview/main.py",
"package:pywebview-mac": "vite build --config vite.config.pywebview.mts && .venv/bin/python -m PyInstaller --name TimeSafari --add-data 'dist:www' src/pywebview/main.py",
"package:pywebview-win": "vite build --config vite.config.pywebview.mts && .venv/Scripts/python -m PyInstaller --name TimeSafari --add-data 'dist;www' src/pywebview/main.py",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"serve:web": "vite preview",
"start:electron": "electron .",
"start:web": "vite --config vite.config.web.mts --host",
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:android": "node scripts/test-android.js",
"test:ios": "node scripts/test-ios.js",
"test:mobile": "npm run build:capacitor && npm run test:android && npm run test:ios",
"test:prerequisites": "node scripts/check-prerequisites.js",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on"
},
"dependencies": {
"@capacitor-community/sqlite": "6.0.2",

View File

@@ -101,7 +101,7 @@ export default defineConfig({
/**
* This could be an array of servers, meaning we could start the Endorser server as well:
* {
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
* command: "cd ../endorser-ch; NODE_ENV=test-local npm run start:web",
* url: 'http://localhost:3000',
* reuseExistingServer: !process.env.CI,
* },
@@ -112,7 +112,7 @@ export default defineConfig({
*/
webServer: {
command:
"VITE_APP_SERVER=http://localhost:8081 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_PASSKEYS_ENABLED=true npm run dev -- --port=8081",
"NODE_ENV=dev VITE_APP_SERVER=http://localhost:8081 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_PASSKEYS_ENABLED=true npm run start:web -- --port=8081",
url: "http://localhost:8081",
reuseExistingServer: !process.env.CI,
},

View File

@@ -74,7 +74,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
// webServer: {
// command:
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run start:web",
// url: "http://localhost:8080",
// reuseExistingServer: !process.env.CI,
// },

View File

@@ -50,8 +50,8 @@ backup and database export, with platform-specific download instructions. * *
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On Android: You will be prompted to choose a location to save your
backup file.
On Android: You will be prompted to choose an app for sharing your
backup file. To save on your phone, you will need a file manager app.
</li>
</ul>
</div>

View File

@@ -77,7 +77,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
@click="copyToClipboard('A link to this page', deepLinkUrl)"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
@@ -104,7 +104,7 @@ import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer";
import { NotificationIface } from "../constants/app";
import { APP_SERVER, NotificationIface } from "../constants/app";
@Component
export default class HiddenDidDialog extends Vue {
@@ -117,7 +117,8 @@ export default class HiddenDidDialog extends Vue {
activeDid = "";
allMyDids: Array<string> = [];
canShare = false;
windowLocation = window.location.href;
deepLinkPathSuffix = "";
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
R = R;
serverUtil = serverUtil;
@@ -129,17 +130,21 @@ export default class HiddenDidDialog extends Vue {
}
open(
deepLinkPathSuffix: string,
roleName: string,
visibleToDids: string[],
allContacts: Array<Contact>,
activeDid: string,
allMyDids: Array<string>,
) {
this.deepLinkPathSuffix = deepLinkPathSuffix;
this.roleName = roleName;
this.visibleToDids = visibleToDids;
this.allContacts = allContacts;
this.activeDid = activeDid;
this.allMyDids = allMyDids;
this.deepLinkUrl = APP_SERVER + "/deep-link/" + this.deepLinkPathSuffix;
this.isOpen = true;
}
@@ -173,11 +178,11 @@ export default class HiddenDidDialog extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
this.copyToClipboard("A link to this page", this.deepLinkUrl);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation,
url: this.deepLinkUrl,
});
}
}

View File

@@ -7,6 +7,7 @@ export enum AppString {
// This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari",
// iOS doesn't like spaces in the app title.
APP_NAME_NO_SPACES = "TimeSafari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",

View File

@@ -29,12 +29,12 @@ import { arrayBufferToBase64 } from "@/libs/crypto";
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const secretBase64 = arrayBufferToBase64(randomBytes);
console.log('secretBase64', secretBase64); // useful while we have multiple DBs activating (at least on web)
// Each migration can include multiple SQL statements (with semicolons)
const MIGRATIONS = [
{
name: "001_initial",
// see ../db/tables files for explanations of the fields
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -119,6 +119,12 @@ const MIGRATIONS = [
);
`,
},
{
name: "002_add_iViewContent_to_contacts",
sql: `
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
},
];
/**

View File

@@ -219,9 +219,9 @@ export async function logConsoleAndDb(
isError = false,
): Promise<void> {
if (isError) {
logger.error(`${new Date().toISOString()} ${message}`);
logger.error(`${new Date().toISOString()}`, message);
} else {
logger.log(`${new Date().toISOString()} ${message}`);
logger.log(`${new Date().toISOString()}`, message);
}
await logToDb(message);
}

View File

@@ -1,15 +1,16 @@
export interface ContactMethod {
export type ContactMethod = {
label: string;
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string;
}
};
export interface Contact {
export type Contact = {
//
// When adding a property, consider whether it should be added when exporting & sharing contacts.
// When adding a property, consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
did: string;
contactMethods?: Array<ContactMethod>;
iViewContent?: boolean;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;
@@ -17,9 +18,15 @@ export interface Contact {
publicKeyBase64?: string;
seesMe?: boolean; // cached value of the server setting
registered?: boolean; // cached value of the server setting
}
};
export type ContactWithJsonStrings = Contact & {
/**
* This is for those cases (eg. with a DB) where every field is a primitive (and not an object).
*
* This is so that we can reuse most of the type and don't have to maintain another copy.
* Another approach uses typescript conditionals: https://chatgpt.com/share/6855cdc3-ab5c-8007-8525-726612016eb2
*/
export type ContactWithJsonStrings = Omit<Contact, "contactMethods"> & {
contactMethods?: string;
};

View File

@@ -1,9 +1,10 @@
const { contextBridge, ipcRenderer } = require("electron");
import { NodeEnv, BuildPlatform } from "@/interfaces/build";
const logger = {
log: (message, ...args) => {
// Always log in development, log with context in production
if (process.env.NODE_ENV !== "production") {
if (process.env.NODE_ENV !== NodeEnv.Prod) {
/* eslint-disable no-console */
console.log(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */
@@ -23,7 +24,7 @@ const logger = {
},
info: (message, ...args) => {
// Always log info in development, log with context in production
if (process.env.NODE_ENV !== "production") {
if (process.env.NODE_ENV !== NodeEnv.Prod) {
/* eslint-disable no-console */
console.info(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */
@@ -53,7 +54,7 @@ const getPath = (pathType) => {
logger.info("Preload script starting...");
// Force electron platform in the renderer process
window.process = { env: { VITE_PLATFORM: "electron" } };
window.process = { env: { VITE_PLATFORM: BuildPlatform.Electron } };
try {
contextBridge.exposeInMainWorld("electronAPI", {
@@ -76,12 +77,12 @@ try {
// Environment info
env: {
isElectron: true,
isDev: process.env.NODE_ENV === "development",
platform: "electron", // Explicitly set platform
isDev: process.env.NODE_ENV === NodeEnv.Dev,
platform: BuildPlatform.Electron, // Explicitly set platform
},
// Path utilities
getBasePath: () => {
return process.env.NODE_ENV === "development" ? "/" : "./";
return process.env.NODE_ENV === NodeEnv.Dev ? "/" : "./";
},
});

21
src/interfaces/build.ts Normal file
View File

@@ -0,0 +1,21 @@
export const NodeEnv = {
Dev: "dev",
Test: "test",
Prod: "prod",
} as const;
export type NodeEnv = typeof NodeEnv[keyof typeof NodeEnv];
export const BuildEnv = {
Development: "development",
Testing: "testing",
Production: "production",
} as const;
export type BuildEnv = typeof BuildEnv[keyof typeof BuildEnv];
export const BuildPlatform = {
Web: "web",
Electron: "electron",
Capacitor: "capacitor",
PyWebView: "pywebview",
} as const;
export type BuildPlatform = typeof BuildPlatform[keyof typeof BuildPlatform];

View File

@@ -29,18 +29,17 @@ import { z } from "zod";
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [
"user-profile",
"project",
"onboard-meeting-setup",
"invite-one-accept",
"contact-import",
"confirm-gift",
// note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts
"claim",
"claim-cert",
"claim-add-raw",
"contact-edit",
"contacts",
"claim-cert",
"confirm-gift",
"contact-import",
"did",
"invite-one-accept",
"onboard-meeting-setup",
"project",
"user-profile",
] as const;
// Create a type from the array
@@ -58,44 +57,39 @@ export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
"user-profile": z.object({
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
// note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts
claim: z.object({
id: z.string(),
}),
"claim-cert": z.object({
id: z.string(),
}),
"claim-add-raw": z.object({
id: z.string(),
claim: z.string().optional(),
claimJwtId: z.string().optional(),
}),
"contact-edit": z.object({
did: z.string(),
"claim-cert": z.object({
id: z.string(),
}),
contacts: z.object({
contacts: z.string(), // JSON string of contacts array
"confirm-gift": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
did: z.object({
did: z.string(),
}),
"invite-one-accept": z.object({
jwt: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"user-profile": z.object({
id: z.string(),
}),
};
export type DeepLinkParams = {

View File

@@ -60,7 +60,7 @@ import {
KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records";
import { logger } from "../utils/logger";
import { logger, safeStringify } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
/**
@@ -437,19 +437,23 @@ export async function getHeaders(
}
headers["Authorization"] = "Bearer " + token;
} catch (error) {
// This rarely happens: we've seen it when they have account info but the
// encryption secret got lost. But in most cases we want users to at
// least see their feed -- and anything else that returns results for
// anonymous users.
// We'll continue with an anonymous request... still want to show feed and other things, but ideally let them know.
// This rarely happens: we've seen it when they have account info but the
// encryption secret got lost.
// Replicate this in Chrome: go to Storage and hit 'Clear site data'.
// Check the util.ts retrieveFullyDecryptedAccount method where it calls simpleDecrypt.
// In most cases we want users to at least see their feed -- and anything
// else that returns results for anonymous users.
// We'll continue with an anonymous request... still want to show feed
// and other things, but we need to let them know.
logConsoleAndDb(
"Something failed in getHeaders call (will proceed anonymously" +
($notify ? " and notify user" : "") +
"): " +
// IntelliJ type system complains about getCircularReplacer() with: Argument of type '(obj: any, key: string, value: any) => any' is not assignable to parameter of type '(this: any, key: string, value: any) => any'.
//JSON.stringify(error, getCircularReplacer()), // JSON.stringify(error) on a Dexie error throws another error about: Converting circular structure to JSON
error,
error + " - " + safeStringify(error),
true,
);
if ($notify) {
@@ -1074,7 +1078,8 @@ export async function generateEndorserJwtUrlForAccount(
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
const viewPrefix =
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
return viewPrefix + vcJwt;
}

View File

@@ -10,8 +10,8 @@ import {
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faArrowUpRightFromSquare,
faBan,
faBitcoinSign,
faBurst,
@@ -92,8 +92,8 @@ library.add(
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faArrowUpRightFromSquare,
faBan,
faBitcoinSign,
faBurst,

View File

@@ -17,7 +17,7 @@ import {
updateDefaultSettings,
} from "../db/index";
import { Account, AccountEncrypted } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts";
import { Contact, ContactWithJsonStrings } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import {
@@ -605,7 +605,7 @@ export const retrieveFullyDecryptedAccount = async (
dbAccount.values.length === 0 ||
dbAccount.values[0].length === 0
) {
throw new Error("Account not found.");
throw new Error("Account not found for did: " + activeDid);
}
const fullAccountData = databaseUtil.mapQueryResultToValues(
dbAccount,
@@ -974,19 +974,16 @@ export interface DatabaseExport {
*/
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
// Convert each contact to a plain object and ensure all fields are included
const rows = contacts.map((contact) => ({
did: contact.did,
name: contact.name || null,
contactMethods: contact.contactMethods
? JSON.stringify(parseJsonField(contact.contactMethods, []))
: null,
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null,
notes: contact.notes || null,
profileImageUrl: contact.profileImageUrl || null,
publicKeyBase64: contact.publicKeyBase64 || null,
seesMe: contact.seesMe || false,
registered: contact.registered || false,
}));
const rows = contacts.map((contact) => {
const exContact: ContactWithJsonStrings = R.omit(
["contactMethods"],
contact,
);
exContact.contactMethods = contact.contactMethods
? JSON.stringify(contact.contactMethods, [])
: undefined;
return exContact;
});
return {
data: {

View File

@@ -34,8 +34,7 @@ import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger";
import { logger, safeStringify } from "./utils/logger";
logger.log("[Capacitor] Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
@@ -72,10 +71,10 @@ const handleDeepLink = async (data: { url: string }) => {
await router.isReady();
await deepLinkHandler.handleDeepLink(data.url);
} catch (error) {
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
logger.error("[DeepLink] Error handling deep link: ", error);
handleApiError(
{
message: error instanceof Error ? error.message : String(error),
message: error instanceof Error ? error.message : safeStringify(error),
} as AxiosError,
"deep-link",
);

View File

@@ -10,15 +10,11 @@ import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.log("Platform", JSON.stringify({ platform }));
logger.log("PWA enabled", JSON.stringify({ pwa_enabled }));
// const platform = process.env.VITE_PLATFORM;
// const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
// Global Error Handler
function setupGlobalErrorHandler(app: VueApp) {
logger.log("[App Init] Setting up global error handler");
app.config.errorHandler = (
err: unknown,
instance: ComponentPublicInstance | null,
@@ -38,30 +34,13 @@ function setupGlobalErrorHandler(app: VueApp) {
// Function to initialize the app
export function initializeApp() {
logger.log("[App Init] Starting app initialization");
logger.log("[App Init] Platform:", process.env.VITE_PLATFORM);
const app = createApp(App);
logger.log("[App Init] Vue app created");
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera);
logger.log("[App Init] Components registered");
const pinia = createPinia();
app.use(pinia);
logger.log("[App Init] Pinia store initialized");
app.use(VueAxios, axios);
logger.log("[App Init] Axios initialized");
app.use(router);
logger.log("[App Init] Router initialized");
app.use(Notifications);
logger.log("[App Init] Notifications initialized");
setupGlobalErrorHandler(app);
logger.log("[App Init] App initialization complete");
return app;
}

View File

@@ -1,15 +1,14 @@
import { initBackend } from "absurd-sql/dist/indexeddb-main-thread";
import { initializeApp } from "./main.common";
import { logger } from "./utils/logger";
import { BuildPlatform } from "@/interfaces/build";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.info("[Web] PWA enabled", { pwa_enabled });
logger.info("[Web] Platform", { platform });
// Only import service worker for web builds
if (platform !== "electron" && pwa_enabled) {
if (platform !== BuildPlatform.Electron && pwa_enabled) {
import("./registerServiceWorker"); // Web PWA support
}
@@ -28,10 +27,10 @@ function sqlInit() {
// workers through the main thread
initBackend(worker);
}
if (platform === "web" || platform === "development") {
if (platform === BuildPlatform.Web) {
sqlInit();
} else {
logger.info("[Web] SQL not initialized for platform", { platform });
logger.warn("[Web] SQL not initialized for platform", { platform });
}
app.mount("#app");

View File

@@ -1,10 +1,11 @@
/* eslint-disable no-console */
import { register } from "register-service-worker";
import { NodeEnv, BuildPlatform } from "@/interfaces/build";
// Check if we're in an Electron environment
const isElectron =
process.env.VITE_PLATFORM === "electron" ||
process.env.VITE_PLATFORM === BuildPlatform.Electron ||
process.env.VITE_DISABLE_PWA === "true" ||
window.navigator.userAgent.toLowerCase().includes("electron");
@@ -15,7 +16,7 @@ const isElectron =
if (
!isElectron &&
process.env.VITE_PWA_ENABLED === "true" &&
process.env.NODE_ENV === "production"
process.env.NODE_ENV === NodeEnv.Prod
) {
register(`${process.env.BASE_URL}sw.js`, {
ready() {

View File

@@ -83,6 +83,11 @@ const routes: Array<RouteRecordRaw> = [
name: "discover",
component: () => import("../views/DiscoverView.vue"),
},
{
path: "/deep-link/:path*",
name: "deep-link",
component: () => import("../views/DeepLinkRedirectView.vue"),
},
{
path: "/gifted-details",
name: "gifted-details",

View File

@@ -3,6 +3,7 @@ import { WebPlatformService } from "./platforms/WebPlatformService";
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService";
import { ElectronPlatformService } from "./platforms/ElectronPlatformService";
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService";
import { BuildPlatform } from "@/interfaces/build";
/**
* Factory class for creating platform-specific service implementations.
@@ -35,19 +36,19 @@ export class PlatformServiceFactory {
return PlatformServiceFactory.instance;
}
const platform = process.env.VITE_PLATFORM || "web";
const platform = process.env.VITE_PLATFORM || BuildPlatform.Web;
switch (platform) {
case "capacitor":
case BuildPlatform.Capacitor:
PlatformServiceFactory.instance = new CapacitorPlatformService();
break;
case "electron":
case BuildPlatform.Electron:
PlatformServiceFactory.instance = new ElectronPlatformService();
break;
case "pywebview":
case BuildPlatform.PyWebView:
PlatformServiceFactory.instance = new PyWebViewPlatformService();
break;
case "web":
case BuildPlatform.Web:
default:
PlatformServiceFactory.instance = new WebPlatformService();
break;

View File

@@ -6,7 +6,8 @@
*/
import { AxiosError } from "axios";
import { logger } from "../utils/logger";
import { logger, safeStringify } from "../utils/logger";
import { BuildPlatform } from "@/interfaces/build";
/**
* Handles API errors with platform-specific logging and error processing.
@@ -36,8 +37,9 @@ import { logger } from "../utils/logger";
* ```
*/
export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") {
logger.error(`[Capacitor API Error] ${endpoint}:`, {
if (process.env.VITE_PLATFORM === BuildPlatform.Capacitor) {
const endpointStr = safeStringify(endpoint); // we've seen this as an object in deep links
logger.error(`[Capacitor API Error] ${endpointStr}:`, {
message: error.message,
status: error.response?.status,
data: error.response?.data,

View File

@@ -27,18 +27,16 @@
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
*
* Supported Routes:
* - user-profile: View user profile
* - project: View project details
* - onboard-meeting-setup: Setup onboarding meeting
* - invite-one-accept: Accept invitation
* - contact-import: Import contacts
* - confirm-gift: Confirm gift
* - claim: View claim
* - claim-cert: View claim certificate
* - claim-add-raw: Add raw claim
* - contact-edit: Edit contact
* - contacts: View contacts
* - claim-cert: View claim certificate
* - confirm-gift
* - contact-import: Import contacts
* - did: View DID
* - invite-one-accept: Accept invitation
* - onboard-meeting-members
* - project: View project details
* - user-profile: View user profile
*
* @example
* const handler = new DeepLinkHandler(router);
@@ -81,14 +79,15 @@ export class DeepLinkHandler {
string,
{ name: string; paramKey?: string }
> = {
// note that similar lists are in src/interfaces/deepLinks.ts
claim: { name: "claim" },
"claim-add-raw": { name: "claim-add-raw" },
"claim-cert": { name: "claim-cert" },
"confirm-gift": { name: "confirm-gift" },
"contact-import": { name: "contact-import", paramKey: "jwt" },
did: { name: "did", paramKey: "did" },
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
"onboard-meeting-members": { name: "onboard-meeting-members" },
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
project: { name: "project" },
"user-profile": { name: "user-profile" },
};
@@ -99,7 +98,7 @@ export class DeepLinkHandler {
*
* @param url - The deep link URL to parse (format: scheme://path[?query])
* @throws {DeepLinkError} If URL format is invalid
* @returns Parsed URL components (path, params, query)
* @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
*/
private parseDeepLink(url: string) {
const parts = url.split("://");
@@ -115,7 +114,16 @@ export class DeepLinkHandler {
});
const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/");
const [routePath, ...pathParams] = path.split("/");
// logger.info(
// "[DeepLink] Debug:",
// "Route Path:",
// routePath,
// "Path Params:",
// pathParams,
// "Query String:",
// queryString,
// );
// Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) {
@@ -134,45 +142,14 @@ export class DeepLinkHandler {
}
const params: Record<string, string> = {};
if (param) {
if (pathParams) {
// Now we know routePath exists in ROUTE_MAP
const routeConfig = this.ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = param;
params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
}
return { path: routePath, params, query };
}
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
};
}
}
/**
* Routes the deep link to appropriate view with validated parameters.
* Validates route and parameters using Zod schemas before routing.
@@ -243,6 +220,39 @@ export class DeepLinkHandler {
code: "INVALID_PARAMETERS",
message: (error as Error).message,
details: error,
params: params,
query: query,
};
}
}
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
};
}
}

View File

@@ -39,7 +39,6 @@ import {
generateUpdateStatement,
generateInsertStatement,
} from "../db/databaseUtil";
import { updateDefaultSettings } from "../db/databaseUtil";
import { importFromMnemonic } from "../libs/util";
/**
@@ -156,11 +155,14 @@ export async function getDexieContacts(): Promise<Contact[]> {
await db.open();
const contacts = await db.contacts.toArray();
logger.info(
`[MigrationService] Retrieved ${contacts.length} contacts from Dexie`,
`[IndexedDBMigrationService] Retrieved ${contacts.length} contacts from Dexie`,
);
return contacts;
} catch (error) {
logger.error("[MigrationService] Error retrieving Dexie contacts:", error);
logger.error(
"[IndexedDBMigrationService] Error retrieving Dexie contacts:",
error,
);
throw new Error(`Failed to retrieve Dexie contacts: ${error}`);
}
}
@@ -214,11 +216,14 @@ export async function getSqliteContacts(): Promise<Contact[]> {
}
logger.info(
`[MigrationService] Retrieved ${contacts.length} contacts from SQLite`,
`[IndexedDBMigrationService] Retrieved ${contacts.length} contacts from SQLite`,
);
return contacts;
} catch (error) {
logger.error("[MigrationService] Error retrieving SQLite contacts:", error);
logger.error(
"[IndexedDBMigrationService] Error retrieving SQLite contacts:",
error,
);
throw new Error(`Failed to retrieve SQLite contacts: ${error}`);
}
}
@@ -251,11 +256,14 @@ export async function getDexieSettings(): Promise<Settings[]> {
await db.open();
const settings = await db.settings.toArray();
logger.info(
`[MigrationService] Retrieved ${settings.length} settings from Dexie`,
`[IndexedDBMigrationService] Retrieved ${settings.length} settings from Dexie`,
);
return settings;
} catch (error) {
logger.error("[MigrationService] Error retrieving Dexie settings:", error);
logger.error(
"[IndexedDBMigrationService] Error retrieving Dexie settings:",
error,
);
throw new Error(`Failed to retrieve Dexie settings: ${error}`);
}
}
@@ -309,11 +317,14 @@ export async function getSqliteSettings(): Promise<Settings[]> {
}
logger.info(
`[MigrationService] Retrieved ${settings.length} settings from SQLite`,
`[IndexedDBMigrationService] Retrieved ${settings.length} settings from SQLite`,
);
return settings;
} catch (error) {
logger.error("[MigrationService] Error retrieving SQLite settings:", error);
logger.error(
"[IndexedDBMigrationService] Error retrieving SQLite settings:",
error,
);
throw new Error(`Failed to retrieve SQLite settings: ${error}`);
}
}
@@ -353,11 +364,14 @@ export async function getSqliteAccounts(): Promise<string[]> {
}
logger.info(
`[MigrationService] Retrieved ${dids.length} accounts from SQLite`,
`[IndexedDBMigrationService] Retrieved ${dids.length} accounts from SQLite`,
);
return dids;
} catch (error) {
logger.error("[MigrationService] Error retrieving SQLite accounts:", error);
logger.error(
"[IndexedDBMigrationService] Error retrieving SQLite accounts:",
error,
);
throw new Error(`Failed to retrieve SQLite accounts: ${error}`);
}
}
@@ -391,11 +405,14 @@ export async function getDexieAccounts(): Promise<Account[]> {
await accountsDB.open();
const accounts = await accountsDB.accounts.toArray();
logger.info(
`[MigrationService] Retrieved ${accounts.length} accounts from Dexie`,
`[IndexedDBMigrationService] Retrieved ${accounts.length} accounts from Dexie`,
);
return accounts;
} catch (error) {
logger.error("[MigrationService] Error retrieving Dexie accounts:", error);
logger.error(
"[IndexedDBMigrationService] Error retrieving Dexie accounts:",
error,
);
throw new Error(`Failed to retrieve Dexie accounts: ${error}`);
}
}
@@ -429,7 +446,7 @@ export async function getDexieAccounts(): Promise<Account[]> {
* ```
*/
export async function compareDatabases(): Promise<DataComparison> {
logger.info("[MigrationService] Starting database comparison");
logger.info("[IndexedDBMigrationService] Starting database comparison");
const [
dexieContacts,
@@ -470,7 +487,7 @@ export async function compareDatabases(): Promise<DataComparison> {
},
};
logger.info("[MigrationService] Database comparison completed", {
logger.info("[IndexedDBMigrationService] Database comparison completed", {
dexieContacts: dexieContacts.length,
sqliteContacts: sqliteContacts.length,
dexieSettings: dexieSettings.length,
@@ -679,6 +696,7 @@ function compareAccounts(dexieAccounts: Account[], sqliteDids: string[]) {
* ```
*/
function contactsEqual(contact1: Contact, contact2: Contact): boolean {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ifEmpty = (arg: any, def: any) => (arg ? arg : def);
const contact1Methods =
contact1.contactMethods &&
@@ -954,7 +972,7 @@ export function generateComparisonYaml(comparison: DataComparison): string {
export async function migrateContacts(
overwriteExisting: boolean = false,
): Promise<MigrationResult> {
logger.info("[MigrationService] Starting contact migration", {
logger.info("[IndexedDBMigrationService] Starting contact migration", {
overwriteExisting,
});
@@ -990,7 +1008,7 @@ export async function migrateContacts(
);
await platformService.dbExec(sql, params);
result.contactsMigrated++;
logger.info(`[MigrationService] Updated contact: ${contact.did}`);
logger.info(`[IndexedDBMigrationService] Updated contact: ${contact.did}`);
} else {
result.warnings.push(
`Contact ${contact.did} already exists, skipping`,
@@ -1004,17 +1022,17 @@ export async function migrateContacts(
);
await platformService.dbExec(sql, params);
result.contactsMigrated++;
logger.info(`[MigrationService] Added contact: ${contact.did}`);
logger.info(`[IndexedDBMigrationService] Added contact: ${contact.did}`);
}
} catch (error) {
const errorMsg = `Failed to migrate contact ${contact.did}: ${error}`;
logger.error("[MigrationService]", errorMsg);
logger.error("[IndexedDBMigrationService]", errorMsg);
result.errors.push(errorMsg);
result.success = false;
}
}
logger.info("[MigrationService] Contact migration completed", {
logger.info("[IndexedDBMigrationService] Contact migration completed", {
contactsMigrated: result.contactsMigrated,
errors: result.errors.length,
warnings: result.warnings.length,
@@ -1023,7 +1041,7 @@ export async function migrateContacts(
return result;
} catch (error) {
const errorMsg = `Contact migration failed: ${error}`;
logger.error("[MigrationService]", errorMsg);
logger.error("[IndexedDBMigrationService]", errorMsg);
result.errors.push(errorMsg);
result.success = false;
return result;
@@ -1063,7 +1081,7 @@ export async function migrateContacts(
* ```
*/
export async function migrateSettings(): Promise<MigrationResult> {
logger.info("[MigrationService] Starting settings migration");
logger.info("[IndexedDBMigrationService] Starting settings migration");
const result: MigrationResult = {
success: true,
@@ -1076,17 +1094,17 @@ export async function migrateSettings(): Promise<MigrationResult> {
try {
const dexieSettings = await getDexieSettings();
logger.info("[MigrationService] Migrating settings", {
logger.info("[IndexedDBMigrationService] Migrating settings", {
dexieSettings: dexieSettings.length,
});
const platformService = PlatformServiceFactory.getInstance();
// Create an array of promises for all settings migrations
const migrationPromises = dexieSettings.map(async (setting) => {
logger.info("[MigrationService] Starting to migrate settings", setting);
let sqliteSettingRaw:
| { columns: string[]; values: unknown[][] }
| undefined;
logger.info(
"[IndexedDBMigrationService] Starting to migrate settings",
setting,
);
// adjust SQL based on the accountDid key, maybe null
let conditional: string;
@@ -1098,15 +1116,18 @@ export async function migrateSettings(): Promise<MigrationResult> {
conditional = "accountDid = ?";
preparams = [setting.accountDid];
}
sqliteSettingRaw = await platformService.dbQuery(
const sqliteSettingRaw = await platformService.dbQuery(
"SELECT * FROM settings WHERE " + conditional,
preparams,
);
logger.info("[MigrationService] Migrating one set of settings:", {
setting,
sqliteSettingRaw,
});
logger.info(
"[IndexedDBMigrationService] Migrating one set of settings:",
{
setting,
sqliteSettingRaw,
},
);
if (sqliteSettingRaw?.values?.length) {
// should cover the master settings, where accountDid is null
delete setting.id; // don't conflict with the id in the sqlite database
@@ -1117,7 +1138,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
conditional,
preparams,
);
logger.info("[MigrationService] Updating settings", {
logger.info("[IndexedDBMigrationService] Updating settings", {
sql,
params,
});
@@ -1127,10 +1148,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
// insert new setting
delete setting.id; // don't conflict with the id in the sqlite database
delete setting.activeDid; // ensure we don't set the activeDid (since master settings are an update and don't hit this case)
const { sql, params } = generateInsertStatement(
setting,
"settings",
);
const { sql, params } = generateInsertStatement(setting, "settings");
await platformService.dbExec(sql, params);
result.settingsMigrated++;
}
@@ -1140,7 +1158,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
const updatedSettings = await Promise.all(migrationPromises);
logger.info(
"[MigrationService] Finished migrating settings",
"[IndexedDBMigrationService] Finished migrating settings",
updatedSettings,
result,
);
@@ -1148,7 +1166,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
return result;
} catch (error) {
logger.error(
"[MigrationService] Complete settings migration failed:",
"[IndexedDBMigrationService] Complete settings migration failed:",
error,
);
const errorMessage = `Settings migration failed: ${error}`;
@@ -1192,7 +1210,7 @@ export async function migrateSettings(): Promise<MigrationResult> {
* ```
*/
export async function migrateAccounts(): Promise<MigrationResult> {
logger.info("[MigrationService] Starting account migration");
logger.info("[IndexedDBMigrationService] Starting account migration");
const result: MigrationResult = {
success: true,
@@ -1248,14 +1266,17 @@ export async function migrateAccounts(): Promise<MigrationResult> {
);
}
logger.info("[MigrationService] Successfully migrated account", {
did,
dateCreated: account.dateCreated,
});
logger.info(
"[IndexedDBMigrationService] Successfully migrated account",
{
did,
dateCreated: account.dateCreated,
},
);
} catch (error) {
const errorMessage = `Failed to migrate account ${did}: ${error}`;
result.errors.push(errorMessage);
logger.error("[MigrationService] Account migration failed:", {
logger.error("[IndexedDBMigrationService] Account migration failed:", {
error,
did,
});
@@ -1272,7 +1293,7 @@ export async function migrateAccounts(): Promise<MigrationResult> {
result.errors.push(errorMessage);
result.success = false;
logger.error(
"[MigrationService] Complete account migration failed:",
"[IndexedDBMigrationService] Complete account migration failed:",
error,
);
return result;
@@ -1306,11 +1327,11 @@ export async function migrateAll(): Promise<MigrationResult> {
try {
logger.info(
"[MigrationService] Starting complete migration from Dexie to SQLite",
"[IndexedDBMigrationService] Starting complete migration from Dexie to SQLite",
);
// Step 1: Migrate Accounts (foundational)
logger.info("[MigrationService] Step 1: Migrating accounts...");
logger.info("[IndexedDBMigrationService] Step 1: Migrating accounts...");
const accountsResult = await migrateAccounts();
if (!accountsResult.success) {
result.errors.push(
@@ -1322,7 +1343,7 @@ export async function migrateAll(): Promise<MigrationResult> {
result.warnings.push(...accountsResult.warnings);
// Step 2: Migrate Settings (depends on accounts)
logger.info("[MigrationService] Step 2: Migrating settings...");
logger.info("[IndexedDBMigrationService] Step 2: Migrating settings...");
const settingsResult = await migrateSettings();
if (!settingsResult.success) {
result.errors.push(
@@ -1335,7 +1356,7 @@ export async function migrateAll(): Promise<MigrationResult> {
// Step 4: Migrate Contacts (independent, but after accounts for consistency)
// ... but which is better done through the contact import view
// logger.info("[MigrationService] Step 4: Migrating contacts...");
// logger.info("[IndexedDBMigrationService] Step 4: Migrating contacts...");
// const contactsResult = await migrateContacts();
// if (!contactsResult.success) {
// result.errors.push(
@@ -1354,7 +1375,7 @@ export async function migrateAll(): Promise<MigrationResult> {
result.contactsMigrated;
logger.info(
`[MigrationService] Complete migration successful: ${totalMigrated} total records migrated`,
`[IndexedDBMigrationService] Complete migration successful: ${totalMigrated} total records migrated`,
{
accounts: result.accountsMigrated,
settings: result.settingsMigrated,
@@ -1367,7 +1388,10 @@ export async function migrateAll(): Promise<MigrationResult> {
} catch (error) {
const errorMessage = `Complete migration failed: ${error}`;
result.errors.push(errorMessage);
logger.error("[MigrationService] Complete migration failed:", error);
logger.error(
"[IndexedDBMigrationService] Complete migration failed:",
error,
);
return result;
}
}

View File

@@ -25,7 +25,6 @@ class MigrationRegistry {
*/
registerMigration(migration: Migration): void {
this.migrations.push(migration);
logger.info(`[MigrationService] Registered migration: ${migration.name}`);
}
/**
@@ -42,7 +41,6 @@ class MigrationRegistry {
*/
clearMigrations(): void {
this.migrations = [];
logger.info("[MigrationService] Cleared all registered migrations");
}
}
@@ -94,10 +92,6 @@ export async function runMigrations<T>(
);
const appliedMigrations = extractMigrationNames(appliedMigrationsResult);
logger.info(
`[MigrationService] Found ${appliedMigrations.size} applied migrations`,
);
// Get all registered migrations
const migrations = migrationRegistry.getMigrations();
@@ -106,21 +100,12 @@ export async function runMigrations<T>(
return;
}
logger.info(
`[MigrationService] Running ${migrations.length} registered migrations`,
);
// Run each migration that hasn't been applied yet
for (const migration of migrations) {
if (appliedMigrations.has(migration.name)) {
logger.info(
`[MigrationService] Skipping already applied migration: ${migration.name}`,
);
continue;
}
logger.info(`[MigrationService] Applying migration: ${migration.name}`);
try {
// Execute the migration SQL
await sqlExec(migration.sql);
@@ -141,8 +126,6 @@ export async function runMigrations<T>(
throw new Error(`Migration ${migration.name} failed: ${error}`);
}
}
logger.info("[MigrationService] All migrations completed successfully");
} catch (error) {
logger.error("[MigrationService] Migration process failed:", error);
throw error;

View File

@@ -1,6 +1,7 @@
import { logToDb } from "../db/databaseUtil";
import { BuildPlatform, NodeEnv } from "@/interfaces/build";
function safeStringify(obj: unknown) {
export function safeStringify(obj: unknown) {
const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => {
@@ -21,7 +22,7 @@ function safeStringify(obj: unknown) {
export const logger = {
debug: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") {
if (process.env.NODE_ENV !== NodeEnv.Prod) {
// eslint-disable-next-line no-console
console.debug(message, ...args);
// const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
@@ -30,8 +31,8 @@ export const logger = {
},
log: (message: string, ...args: unknown[]) => {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor"
process.env.NODE_ENV !== NodeEnv.Prod ||
process.env.VITE_PLATFORM === BuildPlatform.Capacitor
) {
// eslint-disable-next-line no-console
console.log(message, ...args);
@@ -41,9 +42,9 @@ export const logger = {
},
info: (message: string, ...args: unknown[]) => {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor" ||
process.env.VITE_PLATFORM === "electron"
process.env.NODE_ENV !== NodeEnv.Prod ||
process.env.VITE_PLATFORM === BuildPlatform.Capacitor ||
process.env.VITE_PLATFORM === BuildPlatform.Electron
) {
// eslint-disable-next-line no-console
console.info(message, ...args);
@@ -52,23 +53,18 @@ export const logger = {
}
},
warn: (message: string, ...args: unknown[]) => {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor" ||
process.env.VITE_PLATFORM === "electron"
) {
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
}
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
},
error: (message: string, ...args: unknown[]) => {
// Errors will always be logged
// eslint-disable-next-line no-console
console.error(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
const messageString = safeStringify(message);
const argsString = args.length > 0 ? safeStringify(args) : "";
logToDb(messageString + argsString);
},
};

View File

@@ -126,7 +126,7 @@
<div class="flex justify-center text-center text-sm leading-tight mb-1">
People {{ profileImageUrl ? "without your image" : "" }} see this
<br />
(if you've let them see your activity):
(if you've let them see which posts are yours):
</div>
<div class="flex justify-center">
<EntityIcon
@@ -349,8 +349,9 @@
</div>
<div v-if="includeUserProfileLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at your
place.
The location you choose will be shared with the world until you remove
this checkbox. For your security, choose a location nearby but not
exactly at your true location, like at your town center.
</p>
<l-map
@@ -435,11 +436,11 @@
<p class="text-sm">
You have done
<b
>{{ endorserLimits?.doneClaimsThisWeek || "?" }} claim{{
>{{ endorserLimits?.doneClaimsThisWeek ?? "?" }} claim{{
endorserLimits?.doneClaimsThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this
out of <b>{{ endorserLimits?.maxClaimsPerWeek ?? "?" }}</b> for this
week. Your claims counter resets at
<b class="whitespace-nowrap">{{
readableDate(endorserLimits?.nextWeekBeginDateTime)
@@ -449,14 +450,14 @@
You have done
<b
>{{
endorserLimits?.doneRegistrationsThisMonth || "?"
endorserLimits?.doneRegistrationsThisMonth ?? "?"
}}
registration{{
endorserLimits?.doneRegistrationsThisMonth === 1 ? "" : "s"
}}</b
>
out of
<b>{{ endorserLimits?.maxRegistrationsPerMonth || "?" }}</b> for this
<b>{{ endorserLimits?.maxRegistrationsPerMonth ?? "?" }}</b> for this
this month.
<i>(You cannot register anyone on your first day.)</i>
Your registration counter resets at
@@ -467,11 +468,11 @@
<p class="mt-3 text-sm">
You have uploaded
<b
>{{ imageLimits?.doneImagesThisWeek || "?" }} image{{
>{{ imageLimits?.doneImagesThisWeek ?? "?" }} image{{
imageLimits?.doneImagesThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this
out of <b>{{ imageLimits?.maxImagesPerWeek ?? "?" }}</b> for this
week. Your image counter resets at
<b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime)
@@ -1572,24 +1573,25 @@ export default class AccountViewView extends Vue {
* @throws Will notify the user if there is an export error.
*/
public async exportDatabase() {
try {
// Generate the blob from the database
const blob = await this.generateDatabaseBlob();
throw new Error("Not implemented");
// try {
// // Generate the blob from the database
// const blob = await this.generateDatabaseBlob();
// Create a temporary URL for the blob
this.downloadUrl = this.createBlobURL(blob);
// // Create a temporary URL for the blob
// this.downloadUrl = this.createBlobURL(blob);
// Trigger the download
this.downloadDatabaseBackup(this.downloadUrl);
// // Trigger the download
// this.downloadDatabaseBackup(this.downloadUrl);
// Notify the user that the download has started
this.notifyDownloadStarted();
// // Notify the user that the download has started
// this.notifyDownloadStarted();
// Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
} catch (error) {
this.handleExportError(error);
}
// // Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure
// setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
// } catch (error) {
// this.handleExportError(error);
// }
}
/**

View File

@@ -49,21 +49,32 @@
v-if="veriClaim.id"
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
class="text-blue-500 mt-2"
title="Printable Certificate"
title="View Printable Certificate"
>
<font-awesome
icon="square"
class="text-white bg-yellow-500 p-1"
/>
</router-link>
<button
v-if="veriClaim.id"
class="text-blue-500 ml-2 mt-2"
title="Copy Printable Certificate Link"
@click="
copyToClipboard(
'A link to the certificate page',
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
)
"
>
<font-awesome icon="link" class="text-yellow-500 p-1" />
</button>
</div>
<!-- show link icon to copy this URL to the clipboard -->
<div class="flex justify-end w-full">
<button
title="Copy Link"
@click="
copyToClipboard('A link to this page', window.location.href)
"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>
<font-awesome icon="link" class="text-slate-500" />
</button>
@@ -405,7 +416,7 @@
contacts can see more details:
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -428,7 +439,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -546,7 +557,7 @@ import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { db } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
@@ -593,8 +604,9 @@ export default class ClaimView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href;
windowDeepLink = window.location.href; // changed in the setup for deep linking
APP_SERVER = APP_SERVER;
R = R;
yaml = yaml;
libsUtil = libsUtil;
@@ -671,6 +683,7 @@ export default class ClaimView extends Vue {
5000,
);
}
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
this.canShare = !!navigator.share;
}
@@ -1006,11 +1019,11 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
this.copyToClipboard("A link to this page", this.windowDeepLink);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation,
url: this.windowDeepLink,
});
}

View File

@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href;
windowLocation = window.location.href; // this is changed to a deep link in the setup
R = R;
yaml = yaml;
@@ -566,6 +566,9 @@ export default class ConfirmGiftView extends Vue {
}
const claimId = decodeURIComponent(pathParam);
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
await this.loadClaim(claimId, this.activeDid);
}
@@ -676,12 +679,12 @@ export default class ConfirmGiftView extends Vue {
/**
* Add participant (giver/recipient) name & URL info
*/
this.giverName = this.didInfo(this.giveDetails?.agentDid);
if (this.giveDetails?.agentDid) {
this.giverName = this.didInfo(this.giveDetails.agentDid);
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
}
this.recipientName = this.didInfo(this.giveDetails?.recipientDid);
if (this.giveDetails?.recipientDid) {
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
}

View File

@@ -124,12 +124,14 @@ import * as databaseUtil from "../db/databaseUtil";
import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
setVisibilityUtil,
} from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue";
import { retrieveAccountMetadata } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { parseJsonField } from "../db/databaseUtil";
import { Account } from "@/db/tables/accounts";
interface QRScanResult {
rawValue?: string;
@@ -157,6 +159,7 @@ export default class ContactQRScanFull extends Vue {
apiServer = "";
givenName = "";
isRegistered = false;
profileImageUrl = "";
qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@@ -179,6 +182,7 @@ export default class ContactQRScanFull extends Vue {
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(this.activeDid);
if (account) {
@@ -588,9 +592,19 @@ export default class ContactQRScanFull extends Vue {
);
}
onCopyUrlToClipboard() {
async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard()
.copy(this.qrValue)
.copy(jwtUrl)
.then(() => {
this.$notify(
{

View File

@@ -177,6 +177,7 @@ import { getContactJwtFromJwtUrl } from "../libs/crypto";
import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
register,
setVisibilityUtil,
} from "../libs/endorserServer";
@@ -187,6 +188,7 @@ import { logger } from "../utils/logger";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Account } from "@/db/tables/accounts";
interface QRScanResult {
rawValue?: string;
@@ -216,6 +218,7 @@ export default class ContactQRScanShow extends Vue {
isRegistered = false;
qrValue = "";
isScanning = false;
profileImageUrl = "";
error: string | null = null;
// QR Scanner properties
@@ -253,6 +256,7 @@ export default class ContactQRScanShow extends Vue {
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
if (account) {
@@ -667,10 +671,19 @@ export default class ContactQRScanShow extends Vue {
});
}
onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard()
.copy(this.qrValue)
.copy(jwtUrl)
.then(() => {
this.$notify(
{

View File

@@ -126,7 +126,6 @@
<div class="flex items-center gap-2">
<button
v-if="showGiveNumbers"
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
:class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()"
@@ -142,7 +141,6 @@
</button>
<button
href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()"
>
@@ -493,7 +491,7 @@ export default class ContactsView extends Vue {
private async processContactJwt() {
// handle a contact sent via URL
//
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
// For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) {
@@ -619,7 +617,7 @@ export default class ContactsView extends Vue {
title: "Error with Invite",
text: message,
},
5000,
-1,
);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
@@ -1124,7 +1122,7 @@ export default class ContactsView extends Vue {
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
-1,
);
}
} catch (error) {
@@ -1158,7 +1156,7 @@ export default class ContactsView extends Vue {
title: "Registration Error",
text: userMessage,
},
5000,
-1,
);
}
}
@@ -1397,7 +1395,8 @@ export default class ContactsView extends Vue {
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts,
});
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt;
const contactsJwtUrl =
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
useClipboard()
.copy(contactsJwtUrl)
.then(() => {

View File

@@ -77,6 +77,7 @@
@click="confirmSetVisibility(contactFromDid, false)"
>
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-else-if="
@@ -87,6 +88,32 @@
@click="confirmSetVisibility(contactFromDid, true)"
>
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-if="
contactFromDid?.iViewContent &&
contactFromDid.did !== activeDid
"
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I view their content"
@click="confirmViewContent(contactFromDid, false)"
>
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.iViewContent &&
contactFromDid?.did !== activeDid
"
class="text-sm 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 mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I do not view their content"
@click="confirmViewContent(contactFromDid, true)"
>
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
@@ -825,9 +852,9 @@ export default class DIDView extends Vue {
title: "Visibility Refreshed",
text:
libsUtil.nameForContact(contact, true) +
" can " +
(visibility ? "" : "not ") +
"see your activity.",
" can" +
(visibility ? "" : " not") +
" see your activity.",
},
3000,
);
@@ -857,6 +884,64 @@ export default class DIDView extends Vue {
);
}
}
/**
* Confirm whether the user want to see/hide the other's content, then execute it
*
* @param contact Contact content to show/hide from user
* @param view whether user wants to view this contact
*/
async confirmViewContent(contact: Contact, view: boolean) {
const contentVisibilityPrompt = view
? "Are you sure you want to see their content?"
: "Are you sure you want to hide their content from you?";
this.$notify(
{
group: "modal",
type: "confirm",
title: "Set Content Visibility",
text: contentVisibilityPrompt,
onYes: async () => {
const success = await this.setViewContent(contact, view);
if (success) {
contact.iViewContent = view; // see visibility note about not working inside setVisibility
}
},
},
-1,
);
}
/**
* Updates contact content visibility for this device
*
* @param contact - Contact to update content visibility for
* @param visibility - New content visibility state
* @returns Boolean indicating success
*/
async setViewContent(contact: Contact, visibility: boolean) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET iViewContent = ? WHERE did = ?",
[visibility, contact.did],
);
if (USE_DEXIE_DB) {
db.contacts.update(contact.did, { iViewContent: visibility });
}
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
"You will" +
(visibility ? "" : " not") +
` see ${contact.name}'s activity.`,
},
3000,
);
return true;
}
}
</script>

View File

@@ -102,7 +102,7 @@
icon-name="chart"
svg-class="-ml-1 mr-3 h-5 w-5"
/>
Download Account
Show Account Seed
</button>
<button
@@ -1122,6 +1122,7 @@ export default class DatabaseMigration extends Vue {
private loadingMessage = "";
private error = "";
private warning = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private exportedData: Record<string, any> | null = null;
private successMessage = "";
@@ -1134,6 +1135,7 @@ export default class DatabaseMigration extends Vue {
* @param {any} setting - The setting object
* @returns {string} The display name for the setting
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getSettingDisplayName(setting: any): string {
// Handle exported JSON format (has 'type' and 'did' fields)
if (setting.type && setting.did) {
@@ -1153,6 +1155,7 @@ export default class DatabaseMigration extends Vue {
* @param {any} account - The account object
* @returns {boolean} True if account has identity
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAccountHasIdentity(account: any): boolean {
// Handle exported JSON format (has 'hasIdentity' field)
if (account.hasIdentity !== undefined) {
@@ -1170,6 +1173,7 @@ export default class DatabaseMigration extends Vue {
* @param {any} account - The account object
* @returns {boolean} True if account has mnemonic
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAccountHasMnemonic(account: any): boolean {
// Handle exported JSON format (has 'hasMnemonic' field)
if (account.hasMnemonic !== undefined) {

View File

@@ -66,9 +66,14 @@ const formattedPath = computed(() => {
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);
logger.log(
"[DeepLinkError] Original Path:",
originalPath.value,
"Route Params:",
route.params,
"Route Query:",
route.query,
);
return path;
});

View File

@@ -0,0 +1,228 @@
<template>
<!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative mb-4">
Redirecting to Time Safari
</h1>
<div v-if="destinationUrl" class="space-y-4">
<!-- Platform-specific messaging -->
<div class="text-center text-gray-600 mb-4">
<p v-if="isMobile">
{{
isIOS
? "Opening Time Safari app on your iPhone..."
: "Opening Time Safari app on your Android device..."
}}
</p>
<p v-else>Opening Time Safari app...</p>
<p class="text-sm mt-2">
<span v-if="isMobile"
>If the app doesn't open automatically, use one of these
options:</span
>
<span v-else>Choose how you'd like to open this link:</span>
</p>
</div>
<!-- Deep Link Button -->
<div class="text-center">
<a
:href="deepLinkUrl || '#'"
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
@click="handleDeepLinkClick"
>
<span v-if="isMobile">Open in Time Safari App</span>
<span v-else>Try Opening in Time Safari App</span>
</a>
</div>
<!-- Web Fallback Link -->
<div class="text-center">
<a
:href="webUrl || '#'"
target="_blank"
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
@click="handleWebFallbackClick"
>
<span v-if="isMobile">Open in Web Browser Instead</span>
<span v-else>Open in Web Browser</span>
</a>
</div>
<!-- Manual Instructions -->
<div class="text-center text-sm text-gray-500 mt-4">
<p v-if="isMobile">
Or manually open:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
<p v-else>
If you have the Time Safari app installed, you can also copy this
link:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
</div>
<!-- Platform info for debugging -->
<div
v-if="isDevelopment"
class="text-center text-xs text-gray-400 mt-4"
>
<p>
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
</p>
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
</div>
</div>
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
{{ pageError }}
</div>
<div v-else class="text-center text-gray-600">
<p>Processing redirect...</p>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { APP_SERVER } from "@/constants/app";
import { NodeEnv } from "@/interfaces/build";
import { logger } from "@/utils/logger";
import { errorStringForLog } from "@/libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({})
export default class DeepLinkRedirectView extends Vue {
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
pageError: string | null = null;
destinationUrl: string | null = null; // full path after "/deep-link/"
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
isDevelopment: boolean = false;
userAgent: string = "";
private platformService = PlatformServiceFactory.getInstance();
mounted() {
// Get the path from the route parameter (catch-all parameter)
const pathParam = this.$route.params.path;
// If pathParam is an array (catch-all parameter), join it
const fullPath = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
// Get query parameters from the route
const queryParams = this.$route.query;
// Build query string if there are query parameters
let queryString = "";
if (Object.keys(queryParams).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
const stringValue = Array.isArray(value) ? value[0] : value;
if (stringValue !== null && stringValue !== undefined) {
searchParams.append(key, stringValue);
}
}
});
queryString = "?" + searchParams.toString();
}
// Combine path with query parameters
const fullPathWithQuery = fullPath + queryString;
this.destinationUrl = fullPathWithQuery;
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
this.isDevelopment = process.env.NODE_ENV !== NodeEnv.Prod;
this.userAgent = navigator.userAgent;
this.openDeepLink();
}
private openDeepLink() {
if (!this.deepLinkUrl || !this.webUrl) {
this.pageError =
"No deep link was provided. Check the URL and try again.";
return;
}
try {
// For mobile, try the deep link URL; for desktop, use the web URL
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
// Method 1: Try window.location.href (works on most browsers)
window.location.href = redirectUrl;
// Method 2: Fallback - create and click a link element
setTimeout(() => {
try {
const link = document.createElement("a");
link.href = redirectUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
logger.error(
"Fallback deep link failed: " + errorStringForLog(error),
);
this.pageError =
"Redirecting to the Time Safari app failed. Please use a manual option below.";
}
}, 100);
} catch (error) {
logger.error("Deep link redirect failed: " + errorStringForLog(error));
this.pageError =
"Unable to open the Time Safari app. Please use a manual option below.";
}
}
private handleDeepLinkClick(event: Event) {
if (!this.deepLinkUrl) return;
// Prevent default to handle the click manually
event.preventDefault();
this.openDeepLink();
}
private handleWebFallbackClick(event: Event) {
if (!this.webUrl) return;
// Get platform capabilities
const capabilities = this.platformService.getCapabilities();
// For mobile, try to open in a new tab/window
if (capabilities.isMobile) {
event.preventDefault();
window.open(this.webUrl, "_blank");
}
// For desktop, let the default behavior happen (opens in same tab)
}
// Computed properties for template
get isMobile(): boolean {
return this.platformService.getCapabilities().isMobile;
}
get isIOS(): boolean {
return this.platformService.getCapabilities().isIOS;
}
}
</script>

View File

@@ -12,6 +12,7 @@ Raymer * @version 1.0.0 */
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
{{ AppString.APP_NAME }}
<span class="text-xs text-gray-500">{{ package.version }}</span>
</h1>
<OnboardingDialog ref="onboardingDialog" />
@@ -101,8 +102,7 @@ Raymer * @version 1.0.0 */
class="text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
@click="showNameThenIdDialog()"
>
Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
info
Show them your identification info
</button>
</div>
<UserNameDialog ref="userNameDialog" />
@@ -353,6 +353,7 @@ import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "../interfaces/give";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import * as Package from "../../package.json";
interface Claim {
claim?: Claim; // For nested claims in Verifiable Credentials
@@ -443,11 +444,13 @@ export default class HomeView extends Vue {
AppString = AppString;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
package = Package;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
blockedContactDids: Array<string> = [];
feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string;
feedLastViewedClaimId?: string;
@@ -519,7 +522,6 @@ export default class HomeView extends Vue {
// Retrieve DIDs with better error handling
try {
this.allMyDids = await retrieveAccountDids();
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
} catch (error) {
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
throw new Error(
@@ -552,9 +554,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logConsoleAndDb(
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
);
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve settings: ${error}`,
@@ -571,25 +570,14 @@ export default class HomeView extends Vue {
// Load contacts with graceful fallback
try {
const platformService = PlatformServiceFactory.getInstance();
const dbContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
this.allContacts = databaseUtil.mapQueryResultToValues(
dbContacts,
) as Contact[];
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
logConsoleAndDb(
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
);
this.loadContacts();
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve contacts: ${error}`,
true,
);
this.allContacts = []; // Ensure we have a valid empty array
this.blockedContactDids = [];
this.$notify(
{
group: "alert",
@@ -641,9 +629,6 @@ export default class HomeView extends Vue {
});
}
this.isRegistered = true;
logConsoleAndDb(
`[HomeView] User ${this.activeDid} is now registered`,
);
}
} catch (error) {
logConsoleAndDb(
@@ -685,11 +670,6 @@ export default class HomeView extends Vue {
this.newOffersToUserHitLimit = offersToUser.hitLimit;
this.numNewOffersToUserProjects = offersToProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
logConsoleAndDb(
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
`${this.numNewOffersToUserProjects} project offers`,
);
}
} catch (error) {
logConsoleAndDb(
@@ -702,7 +682,7 @@ export default class HomeView extends Vue {
group: "alert",
type: "warning",
title: "Feed Loading Issue",
text: "Some feed data may be unavailable. Pull to refresh.",
text: "Some feed data may be unavailable. Try refreshing the page.",
},
5000,
);
@@ -761,6 +741,9 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
this.blockedContactDids = this.allContacts
.filter((c) => !c.iViewContent)
.map((c) => c.did);
}
/**
@@ -1028,6 +1011,7 @@ export default class HomeView extends Vue {
);
if (results.data.length > 0) {
endOfResults = false;
// gather any contacts that user has blocked from view
await this.processFeedResults(results.data);
await this.updateFeedLastViewedId(results.data);
}
@@ -1215,7 +1199,7 @@ export default class HomeView extends Vue {
}
/**
* Checks if record should be included based on filters
* Checks if record should be included based on filters & preferences
*
* @internal
* @callGraph
@@ -1241,6 +1225,10 @@ export default class HomeView extends Vue {
record: GiveSummaryRecord,
fulfillsPlan?: FulfillsPlan,
): boolean {
if (this.blockedContactDids.includes(record.issuerDid)) {
return false;
}
if (!this.isAnyFeedFilterOn) {
return true;
}

View File

@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
}
inviteLink(jwt: string): string {
return APP_SERVER + "/invite-one-accept/" + jwt;
return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
}
copyInviteAndNotify(inviteId: string, jwt: string) {

View File

@@ -720,7 +720,7 @@ export default class OnboardMeetingView extends Vue {
onboardMeetingMembersLink(): string {
if (this.currentMeeting) {
return `${APP_SERVER}/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
return `${APP_SERVER}/deep-link/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
this.currentMeeting?.password || "",
)}`;
}

View File

@@ -27,6 +27,12 @@
>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
<button title="Copy Link to Project" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</h2>
</div>
</div>
@@ -55,7 +61,11 @@
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
{{ issuerInfoObject?.displayName }}
</span>
<span class="inline-flex items-center">
<span
v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center"
>
<router-link
:to="{
path: '/did/' + encodeURIComponent(issuer),
@@ -113,7 +123,7 @@
class="fa-fw text-slate-400"
></font-awesome>
<a
:href="addScheme(url)"
:href="ensureScheme(url)"
target="_blank"
class="underline text-blue-500"
>
@@ -632,7 +642,7 @@ import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import {
db,
@@ -646,6 +656,7 @@ import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { useClipboard } from "@vueuse/core";
/**
* Project View Component
* @author Matthew Raymer
@@ -842,6 +853,28 @@ export default class ProjectViewView extends Vue {
});
}
onCopyLinkClick() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this project was copied to the clipboard.",
},
2000,
);
});
}
// Isn't there a better way to make this available to the template?
expandText() {
this.expanded = true;
@@ -1304,7 +1337,7 @@ export default class ProjectViewView extends Vue {
}
// return an HTTPS URL if it's not a global URL
addScheme(url: string) {
ensureScheme(url: string) {
if (!libsUtil.isGlobalUri(url)) {
return "https://" + url;
}
@@ -1465,7 +1498,13 @@ export default class ProjectViewView extends Vue {
}
openHiddenDidDialog() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
"project/" + shortestProjectId,
"creator",
this.issuerVisibleToDids,
this.allContacts,

View File

@@ -144,7 +144,7 @@ export default class QuickActionBvcBeginView extends Vue {
"HUR",
BVC_MEETUPS_PROJECT_CLAIM_ID,
);
if (timeResult.type === "success") {
if (timeResult.success) {
timeSuccess = true;
} else {
logger.error("Error sending time:", timeResult);
@@ -154,7 +154,7 @@ export default class QuickActionBvcBeginView extends Vue {
type: "danger",
title: "Error",
text:
timeResult?.error?.userMessage ||
timeResult?.error ||
"There was an error sending the time.",
},
5000,
@@ -171,7 +171,7 @@ export default class QuickActionBvcBeginView extends Vue {
apiServer,
axios,
);
if (attendResult.type === "success") {
if (attendResult.success) {
attendedSuccess = true;
} else {
logger.error("Error sending attendance:", attendResult);
@@ -181,7 +181,7 @@ export default class QuickActionBvcBeginView extends Vue {
type: "danger",
title: "Error",
text:
attendResult?.error?.userMessage ||
attendResult?.error ||
"There was an error sending the attendance.",
},
5000,

View File

@@ -105,7 +105,7 @@ export default class ShareMyContactInfoView extends Vue {
group: "alert",
type: "info",
title: "Copied",
text: "Your contact info was copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.",
text: "Your contact info was copied to the clipboard. Have them click on it, or paste it in the box on their 'Contacts' screen.",
},
5000,
);

View File

@@ -16,6 +16,7 @@
</button>
Individual Profile
</h1>
<div class="text-sm text-center text-slate-500"></div>
</div>
<!-- Loading Animation -->
@@ -32,6 +33,12 @@
<div class="text-sm">
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</div>
<p v-if="profile.description" class="mt-4 text-slate-600">
{{ profile.description }}
@@ -100,6 +107,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import {
APP_SERVER,
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
USE_DEXIE_DB,
@@ -113,6 +121,7 @@ import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Settings } from "@/db/tables/settings";
import { useClipboard } from "@vueuse/core";
@Component({
components: {
LMap,
@@ -186,6 +195,10 @@ export default class UserProfileView extends Vue {
if (response.status === 200) {
const result = await response.json();
this.profile = result.data;
if (this.profile && this.profile.rowId !== profileId) {
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
this.profile.rowId = profileId;
}
} else {
throw new Error("Failed to load profile");
}
@@ -204,5 +217,22 @@ export default class UserProfileView extends Vue {
this.isLoading = false;
}
}
onCopyLinkClick() {
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this profile was copied to the clipboard.",
},
2000,
);
});
}
}
</script>

View File

@@ -1,55 +0,0 @@
import * as path from "path";
import { promises as fs } from "fs";
import { fileURLToPath } from "url";
export async function loadAppConfig() {
const packageJson = await loadPackageJson();
const appName = process.env.TIME_SAFARI_APP_TITLE || packageJson.name;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
return {
pwaConfig: {
manifest: {
name: appName,
short_name: appName,
icons: [
{
src: "./img/icons/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "./img/icons/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "./img/icons/android-chrome-maskable-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "./img/icons/android-chrome-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
},
aliasConfig: {
"@": path.resolve(path.dirname(__dirname), "src"),
buffer: path.resolve(path.dirname(__dirname), "node_modules", "buffer"),
"dexie-export-import/dist/import":
"dexie-export-import/dist/import/index.js",
},
};
}
async function loadPackageJson() {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJsonPath = path.resolve(path.dirname(__dirname), "package.json");
const packageJsonData = await fs.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonData);
}

View File

@@ -2,14 +2,14 @@ import { test, expect } from '@playwright/test';
import { importUser, generateNewEthrUser, switchToUser } from './testUtils';
test('New offers for another user', async ({ page }) => {
const user01Did = await generateNewEthrUser(page);
const newUserDid = await generateNewEthrUser(page);
await page.goto('./');
await page.getByTestId('closeOnboardingAndFinish').click();
await expect(page.getByTestId('newDirectOffersActivityNumber')).toBeHidden();
await importUser(page, '00');
await page.goto('./contacts');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(user01Did + ', A Friend');
await page.getByPlaceholder('URL or DID, Name, Public Key').fill(newUserDid + ', A Friend');
await expect(page.locator('button > svg.fa-plus')).toBeVisible();
await page.locator('button > svg.fa-plus').click();
await expect(page.locator('div[role="alert"] span:has-text("Contact Added")')).toBeVisible();
@@ -18,7 +18,7 @@ test('New offers for another user', async ({ page }) => {
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
// show buttons to make offers directly to people
await page.getByRole('button').filter({ hasText: /See Hours/i }).click();
await page.getByRole('button').filter({ hasText: /See Actions/i }).click();
// make an offer directly to user 1
// Generate a random string of 3 characters, skipping the "0." at the beginning
@@ -42,7 +42,7 @@ test('New offers for another user', async ({ page }) => {
await expect(page.locator('div[role="alert"] button > svg.fa-xmark')).toBeHidden(); // ensure alert is gone
// as user 1, go to the home page and check that two offers are shown as new
await switchToUser(page, user01Did);
await switchToUser(page, newUserDid);
await page.goto('./');
let offerNumElem = page.getByTestId('newDirectOffersActivityNumber');
await expect(offerNumElem).toHaveText('2');

View File

@@ -8,5 +8,5 @@
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["vite.config.*"]
"include": ["vite.config.*", "./src/interfaces/build.ts"]
}

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
import { BuildPlatform } from "./src/interfaces/build.ts";
export default defineConfig(async () => createBuildConfig('capacitor'));
export default defineConfig(async () => createBuildConfig(BuildPlatform.Capacitor));

View File

@@ -45,71 +45,73 @@ interface PWAConfig {
}
interface AppConfig {
pwaConfig: PWAConfig;
aliasConfig: {
[key: string]: string;
};
}
export async function loadAppConfig(): Promise<AppConfig> {
const packageJson = await loadPackageJson();
const appName = process.env.TIME_SAFARI_APP_TITLE || packageJson.name;
return {
pwaConfig: {
registerType: "autoUpdate",
strategies: "injectManifest",
srcDir: ".",
filename: "sw_scripts-combined.js",
manifest: {
name: appName,
short_name: appName,
theme_color: "#4a90e2",
background_color: "#ffffff",
icons: [
{
src: "./img/icons/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "./img/icons/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "./img/icons/android-chrome-maskable-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "./img/icons/android-chrome-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
share_target: {
action: "/share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "photo",
accept: ["image/*"],
},
],
},
},
},
},
aliasConfig: {
"@": path.resolve(__dirname, "src"),
buffer: path.resolve(__dirname, "node_modules", "buffer"),
"dexie-export-import/dist/import":
"dexie-export-import/dist/import/index.js",
},
}
}
export async function loadPwaConfig(): Promise<PWAConfig> {
const packageJson = await loadPackageJson();
const appName = process.env.TIME_SAFARI_APP_TITLE || packageJson.name;
return {
registerType: "autoUpdate",
strategies: "injectManifest",
srcDir: ".",
filename: "sw_scripts-combined.js",
manifest: {
name: appName,
short_name: appName,
theme_color: "#4a90e2",
background_color: "#ffffff",
icons: [
{
src: "./img/icons/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "./img/icons/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "./img/icons/android-chrome-maskable-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "./img/icons/android-chrome-maskable-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
share_target: {
action: "/share-target",
method: "POST",
enctype: "multipart/form-data",
params: {
files: [
{
name: "photo",
accept: ["image/*"],
},
],
},
},
},
};
}

View File

@@ -1,31 +1,45 @@
import { defineConfig, UserConfig, Plugin } from "vite";
import { defineConfig, UserConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import dotenv from "dotenv";
import { loadAppConfig } from "./vite.config.utils.mts";
import { loadAppConfig } from "./vite.config.common-utils.mts";
import path from "path";
import { fileURLToPath } from 'url';
import { NodeEnv, BuildEnv, BuildPlatform } from "./src/interfaces/build.ts";
// Load environment variables
dotenv.config();
let buildEnv: BuildEnv;
if (process.env.NODE_ENV === NodeEnv.Dev) {
buildEnv = BuildEnv.Development;
} else if (process.env.NODE_ENV === NodeEnv.Test) {
buildEnv = BuildEnv.Testing;
} else if (process.env.NODE_ENV === NodeEnv.Prod) {
buildEnv = BuildEnv.Production;
} else {
console.error("NODE_ENV is not set. Invoke with NODE_ENV=" + Object.values(NodeEnv).join("|"));
throw new Error("NODE_ENV is not set. Invoke with NODE_ENV=" + Object.values(NodeEnv).join("|"));
}
console.log(`Environment: ${buildEnv}`);
dotenv.config({ path: `.env.${buildEnv}` });
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export async function createBuildConfig(mode: string): Promise<UserConfig> {
export async function createBuildConfig(platform: BuildPlatform): Promise<UserConfig> {
const appConfig = await loadAppConfig();
const isElectron = mode === "electron";
const isCapacitor = mode === "capacitor";
const isPyWebView = mode === "pywebview";
console.log(`Platform: ${platform}`);
const isElectron = platform === BuildPlatform.Electron;
const isCapacitor = platform === BuildPlatform.Capacitor;
const isPyWebView = platform === BuildPlatform.PyWebView;
// Explicitly set platform and disable PWA for Electron
process.env.VITE_PLATFORM = mode;
process.env.VITE_PWA_ENABLED = isElectron ? 'false' : 'true';
process.env.VITE_PLATFORM = platform;
process.env.VITE_PWA_ENABLED = (isElectron || isPyWebView || isCapacitor)
? 'false'
: 'true';
process.env.VITE_DISABLE_PWA = isElectron ? 'true' : 'false';
if (isElectron || isPyWebView || isCapacitor) {
process.env.VITE_PWA_ENABLED = 'false';
}
return {
base: isElectron || isPyWebView ? "./" : "/",
plugins: [vue()],
@@ -56,7 +70,7 @@ export async function createBuildConfig(mode: string): Promise<UserConfig> {
},
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PLATFORM': JSON.stringify(platform),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isElectron),
'process.env.VITE_DISABLE_PWA': JSON.stringify(isElectron),
__dirname: isElectron ? JSON.stringify(process.cwd()) : '""',
@@ -78,10 +92,10 @@ export async function createBuildConfig(mode: string): Promise<UserConfig> {
'path': path.resolve(__dirname, './src/utils/node-modules/path.js'),
'fs': path.resolve(__dirname, './src/utils/node-modules/fs.js'),
'crypto': path.resolve(__dirname, './src/utils/node-modules/crypto.js'),
'nostr-tools/nip06': mode === 'development'
'nostr-tools/nip06': buildEnv === BuildEnv.Development
? 'nostr-tools/nip06'
: path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
'nostr-tools/core': mode === 'development'
'nostr-tools/core': buildEnv === BuildEnv.Development
? 'nostr-tools'
: path.resolve(__dirname, 'node_modules/nostr-tools'),
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
@@ -108,4 +122,4 @@ export async function createBuildConfig(mode: string): Promise<UserConfig> {
};
}
export default defineConfig(async () => createBuildConfig('web'));
export default defineConfig(async () => createBuildConfig(BuildPlatform.Web));

View File

@@ -1,4 +0,0 @@
import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => createBuildConfig('development'));

View File

@@ -1,9 +1,10 @@
import { defineConfig, mergeConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
import path from 'path';
import { BuildPlatform } from "./src/interfaces/build.ts";
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('electron');
const baseConfig = await createBuildConfig(BuildPlatform.Electron);
return mergeConfig(baseConfig, {
build: {

View File

@@ -1,71 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'),
stream: 'stream-browserify',
util: 'util',
crypto: 'crypto-browserify',
assert: 'assert/',
http: 'stream-http',
https: 'https-browserify',
url: 'url/',
zlib: 'browserify-zlib',
path: 'path-browserify',
fs: false,
tty: 'tty-browserify',
net: false,
dns: false,
child_process: false,
os: false
},
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
},
optimizeDeps: {
include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core'],
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
build: {
sourcemap: true,
target: 'esnext',
chunkSizeWarningLimit: 1000,
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true
},
rollupOptions: {
external: [
'stream', 'util', 'crypto', 'http', 'https', 'url', 'zlib',
'path', 'fs', 'tty', 'assert', 'net', 'dns', 'child_process', 'os'
],
output: {
globals: {
stream: 'stream',
util: 'util',
crypto: 'crypto',
http: 'http',
https: 'https',
url: 'url',
zlib: 'zlib',
path: 'path',
assert: 'assert',
tty: 'tty'
}
}
}
}
});

View File

@@ -1,4 +1,5 @@
import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
import { BuildPlatform } from "./src/interfaces/build.ts";
export default defineConfig(async () => createBuildConfig('pywebview'));
export default defineConfig(async () => createBuildConfig(BuildPlatform.PyWebView));

View File

@@ -1,53 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
export default defineConfig({
plugins: [vue()],
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp'
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'nostr-tools': path.resolve(__dirname, 'node_modules/nostr-tools'),
'nostr-tools/nip06': path.resolve(__dirname, 'node_modules/nostr-tools/nip06'),
'nostr-tools/core': path.resolve(__dirname, 'node_modules/nostr-tools/core'),
stream: 'stream-browserify',
util: 'util',
crypto: 'crypto-browserify'
},
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
},
optimizeDeps: {
include: ['nostr-tools', 'nostr-tools/nip06', 'nostr-tools/core', '@jlongster/sql.js'],
esbuildOptions: {
define: {
global: 'globalThis'
}
}
},
build: {
sourcemap: true,
target: 'esnext',
chunkSizeWarningLimit: 1000,
commonjsOptions: {
include: [/node_modules/],
transformMixedEsModules: true
},
rollupOptions: {
external: ['stream', 'util', 'crypto'],
output: {
globals: {
stream: 'stream',
util: 'util',
crypto: 'crypto'
}
}
}
},
assetsInclude: ['**/*.wasm']
});

View File

@@ -1,17 +1,18 @@
import { defineConfig, mergeConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { createBuildConfig } from "./vite.config.common.mts";
import { loadAppConfig } from "./vite.config.utils.mts";
import { loadPwaConfig } from "./vite.config.common-utils.mts";
import { BuildPlatform } from "./src/interfaces/build.ts";
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('web');
const appConfig = await loadAppConfig();
const baseConfig = await createBuildConfig(BuildPlatform.Web);
const pwaConfig = await loadPwaConfig();
return mergeConfig(baseConfig, {
plugins: [
VitePWA({
registerType: 'autoUpdate',
manifest: appConfig.pwaConfig?.manifest,
manifest: pwaConfig.manifest,
devOptions: {
enabled: false
},