Compare commits

..

11 Commits

49 changed files with 629 additions and 425 deletions

View File

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

7
.gitignore vendored
View File

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

View File

@@ -11,6 +11,17 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
- Git - Git
- For desktop builds: Additional build tools based on your OS - 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 ## Initial Setup
Install dependencies: Install dependencies:
@@ -22,7 +33,7 @@ Install dependencies:
## Web Dev Locally ## Web Dev Locally
```bash ```bash
NODE_ENV=dev npm run start:web npm run dev
``` ```
## Web Build for Server ## Web Build for Server
@@ -31,7 +42,7 @@ Install dependencies:
```bash ```bash
rm -rf dist rm -rf dist
NODE_ENV=prod npm run build:web npm run build:web
``` ```
The built files will be in the `dist` directory. The built files will be in the `dist` directory.
@@ -41,7 +52,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. 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 ```bash
NODE_ENV=dev npm run serve:web npm run serve
``` ```
### Compile and minify for test & production ### Compile and minify for test & production
@@ -50,9 +61,11 @@ Install dependencies:
* `npx prettier --write ./sw_scripts/` * `npx prettier --write ./sw_scripts/`
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`. * Update the ClickUp tasks & CHANGELOG.md & the version in package.json.
* Run a build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build:web` * Run install & build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build:web`
* If also deploying to mobile, update the new version in the ios & android filed.
* Commit everything (since the commit hash is used the app). * Commit everything (since the commit hash is used the app).
@@ -63,7 +76,7 @@ Install dependencies:
* For test, build the app (because test server is not yet set up to build): * For test, build the app (because test server is not yet set up to build):
```bash ```bash
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 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: ... and transfer to the test server:
@@ -205,10 +218,10 @@ docker run -d \
```bash ```bash
# For AppImage (recommended) # For AppImage (recommended)
npm run build:electron-linux npm run electron:build-linux
# For .deb package # For .deb package
npm run build:electron-linux-deb npm run electron:build-linux-deb
``` ```
3. The packaged applications will be in `dist-electron-packages/`: 3. The packaged applications will be in `dist-electron-packages/`:
@@ -220,19 +233,19 @@ docker run -d \
1. Build the electron app in production mode: 1. Build the electron app in production mode:
```bash ```bash
NODE_ENV=prod npm run build:web npm run build:web
npm run build:electron npm run build:electron
npm run build:electron-mac npm run electron:build-mac
``` ```
2. Package the Electron app for macOS: 2. Package the Electron app for macOS:
```bash ```bash
# For Intel Macs # For Intel Macs
npm run build:electron-mac npm run electron:build-mac
# For Universal build (Intel + Apple Silicon) # For Universal build (Intel + Apple Silicon)
npm run build:electron-mac-universal npm run electron:build-mac-universal
``` ```
3. The packaged applications will be in `dist-electron-packages/`: 3. The packaged applications will be in `dist-electron-packages/`:
@@ -254,7 +267,7 @@ For public distribution on macOS, you need to code sign and notarize your app:
2. Build with signing: 2. Build with signing:
```bash ```bash
npm run build:electron-mac npm run electron:build-mac
``` ```
### Running the Packaged App ### Running the Packaged App
@@ -293,10 +306,10 @@ For testing the Electron build before packaging:
```bash ```bash
# Build and run in development mode (includes DevTools) # Build and run in development mode (includes DevTools)
npm run build:electron npm run electron:dev
# Build in production mode and test # Build in production mode and test
npm run build:electron-prod && npm run start:electron npm run build:electron-prod && npm run electron:start
``` ```
## Mobile Builds (Capacitor) ## Mobile Builds (Capacitor)
@@ -351,7 +364,7 @@ Prerequisites: macOS with Xcode installed
4. Bump the version to match Android & package.json: 4. Bump the version to match Android & package.json:
``` ```
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 - cd ios/App && xcrun agvtool new-version 37 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.4;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
``` ```
@@ -374,11 +387,12 @@ Prerequisites: macOS with Xcode installed
* This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly. * This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly.
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`). * If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`).
* Click Distribute -> App Store Connect * Click Distribute -> App Store Connect
* In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build. * In AppStoreConnect, add the build to the distribution. You may have to remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
* May have to go to App Review, click Submission, then hover over the build and click "-". * May have to go to App Review, click Submission, then hover over the build and click "-".
* It can take 15 minutes for the build to show up in the list of builds. * It can take 15 minutes for the build to show up in the list of builds.
* You'll probably have to "Manage" something about encryption, disallowed in France. * You'll probably have to "Manage" something about encryption, disallowed in France.
* Then "Save" and "Add to Review" and "Resubmit to App Review". * Then "Save" and "Add to Review" and "Resubmit to App Review".
* Eventually it'll be "Ready for Distribution" which means
### Android Build ### Android Build
@@ -468,12 +482,3 @@ 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] ... 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

@@ -6,12 +6,17 @@ 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [1.0.4] - 2025.07.20 - 002f2407208d56cc59c0aa7c880535ae4cbace8b
### Fixed ### Fixed
- Set the environment variables correctly (so simulator deep link domain is right). - Deep link for invite-one-accept
## [1.0.3] - 2025.07.12 - a9a8ba217cd6015321911e98e6843e988dc2c4ae
### Changed ### Changed
- Photo is pinned to profile mode. - Photo is pinned to profile mode
- NODE_ENV is now mandatory. ### Fixed
- Deep link URLs (and other prod settings)
- Error in BVC begin view
## [1.0.2] - 2025.06.20 - 276e0a741bc327de3380c4e508cccb7fee58c06d ## [1.0.2] - 2025.06.20 - 276e0a741bc327de3380c4e508cccb7fee58c06d

View File

@@ -3,22 +3,49 @@
[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude [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. 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 ## Roadmap
See [ClickUp](https://sharing.clickup.com/9014278710/l/h/8cmnyhp-174/10573fec74e2ba0) for current priorities. 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.)
## Setup & Building ## Setup & Building
Quick start: 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 & doc/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 & BUILDING.md files.
```bash ```bash
npm install npm install
NODE_ENV=dev npm run start:web npm run dev
``` ```
See [BUILDING.md](doc/BUILDING.md) for more details. See [BUILDING.md](BUILDING.md) for more details.
## Tests ## Tests
@@ -28,7 +55,7 @@ See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
Application icons are in the `assets` directory, processed by the `capacitor-assets` command. Application icons are in the `assets` directory, processed by the `capacitor-assets` command.
To add a Font Awesome icon, add to main.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name. To add a Font Awesome icon, add to fontawesome.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
## Other ## Other
@@ -70,6 +97,9 @@ The application uses a platform-agnostic database layer:
**Development Guidelines**: **Development Guidelines**:
- Always use `PlatformService` for database operations - 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 ### Kudos

View File

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

View File

@@ -27,32 +27,6 @@ The Database Migration feature allows you to compare and migrate data between De
- Clear success and error messaging - Clear success and error messaging
- Export functionality for comparison data - 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 ## Prerequisites
### Enable Dexie Database ### Enable Dexie Database

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

@@ -1,42 +1,49 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.0.3-beta", "version": "1.0.5-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"
}, },
"scripts": { "scripts": {
"build-start:pywebview": "vite build --config vite.config.pywebview.mts && .venv/bin/python src/pywebview/main.py", "dev": "vite --config vite.config.dev.mts --host",
"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", "serve": "vite preview",
"build:capacitor": "vite build --config vite.config.capacitor.mts", "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.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:android": "adb uninstall app.timesafari.app || true",
"clean:electron": "rimraf dist-electron",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix 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", "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: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:android": "node scripts/test-android.js",
"test:ios": "node scripts/test-ios.js", "test:ios": "node scripts/test-ios.js",
"test:mobile": "npm run build:capacitor && npm run test:android && npm run test:ios", "check:android-device": "adb devices | grep -w 'device' || (echo 'No Android device connected' && exit 1)",
"test:prerequisites": "node scripts/check-prerequisites.js", "check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on" "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"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "6.0.2", "@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: * 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 start:web", * command: "cd ../endorser-ch; NODE_ENV=test-local npm run dev",
* url: 'http://localhost:3000', * url: 'http://localhost:3000',
* reuseExistingServer: !process.env.CI, * reuseExistingServer: !process.env.CI,
* }, * },
@@ -112,7 +112,7 @@ export default defineConfig({
*/ */
webServer: { webServer: {
command: command:
"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", "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",
url: "http://localhost:8081", url: "http://localhost:8081",
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

View File

@@ -74,7 +74,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
// webServer: { // webServer: {
// command: // command:
// "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run start:web", // "VITE_PASSKEYS_ENABLED=true VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 npm run dev",
// url: "http://localhost:8080", // url: "http://localhost:8080",
// reuseExistingServer: !process.env.CI, // 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" v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4" class="list-disc list-outside ml-4"
> >
On Android: You will be prompted to choose an app for sharing your On Android: You will be prompted to choose a location to save your
backup file. To save on your phone, you will need a file manager app. backup file.
</li> </li>
</ul> </ul>
</div> </div>
@@ -60,9 +60,11 @@ backup and database export, with platform-specific download instructions. * *
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from "vue-facing-decorator";
import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts"; import { Contact, ContactMaybeWithJsonStrings, ContactMethod } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@@ -72,6 +74,7 @@ import {
PlatformCapabilities, PlatformCapabilities,
} from "../services/PlatformService"; } from "../services/PlatformService";
import { contactsToExportJson } from "../libs/util"; import { contactsToExportJson } from "../libs/util";
import { parseJsonField } from "../db/databaseUtil";
/** /**
* @vue-component * @vue-component
@@ -133,13 +136,13 @@ export default class DataExportSection extends Vue {
*/ */
public async exportDatabase() { public async exportDatabase() {
try { try {
let allContacts: Contact[] = []; let allDbContacts: ContactMaybeWithJsonStrings[] = [];
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`); const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) { if (result) {
allContacts = databaseUtil.mapQueryResultToValues( allDbContacts = databaseUtil.mapQueryResultToValues(
result, result,
) as unknown as Contact[]; ) as unknown as ContactMaybeWithJsonStrings[];
} }
// if (USE_DEXIE_DB) { // if (USE_DEXIE_DB) {
// await db.open(); // await db.open();
@@ -147,6 +150,19 @@ export default class DataExportSection extends Vue {
// } // }
// Convert contacts to export format // Convert contacts to export format
const allContacts: Contact[] = allDbContacts.map((contact) => {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(
["contactMethods"],
contact,
);
// now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods
? parseJsonField(contact.contactMethods, [] as Array<ContactMethod>)
: undefined;
return exContact;
});
const exportData = contactsToExportJson(allContacts); const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2); const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" }); const blob = new Blob([jsonStr], { type: "application/json" });

View File

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

View File

@@ -29,7 +29,6 @@ import { arrayBufferToBase64 } from "@/libs/crypto";
const randomBytes = crypto.getRandomValues(new Uint8Array(32)); const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const secretBase64 = arrayBufferToBase64(randomBytes); 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) // Each migration can include multiple SQL statements (with semicolons)
const MIGRATIONS = [ const MIGRATIONS = [

View File

@@ -30,6 +30,17 @@ export type ContactWithJsonStrings = Omit<Contact, "contactMethods"> & {
contactMethods?: string; contactMethods?: string;
}; };
/**
* This is for those cases (eg. with a DB) where field values may be all primitives or may be JSON values.
* See src/db/databaseUtil.ts parseJsonField for more details.
*
* 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 ContactMaybeWithJsonStrings = Omit<Contact, "contactMethods"> & {
contactMethods?: string | Array<ContactMethod>;
};
export const ContactSchema = { export const ContactSchema = {
contacts: "&did, name", // no need to key by other things contacts: "&did, name", // no need to key by other things
}; };

View File

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

View File

@@ -1,21 +0,0 @@
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

@@ -27,37 +27,8 @@
*/ */
import { z } from "zod"; import { z } from "zod";
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [
// note that similar lists are below in deepLinkSchemas and in src/services/deepLinks.ts
"claim",
"claim-add-raw",
"claim-cert",
"confirm-gift",
"contact-import",
"did",
"invite-one-accept",
"onboard-meeting-setup",
"project",
"user-profile",
] as const;
// Create a type from the array
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
// Update your schema definitions to use this type
export const baseUrlSchema = z.object({
scheme: z.literal("timesafari"),
path: z.string(),
queryParams: z.record(z.string()).optional(),
});
// Use the type to ensure route validation
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type // Parameter validation schemas for each route type
export const deepLinkSchemas = { export const deepLinkSchemas = {
// note that similar lists are above in VALID_DEEP_LINK_ROUTES and in src/services/deepLinks.ts
claim: z.object({ claim: z.object({
id: z.string(), id: z.string(),
}), }),
@@ -72,16 +43,24 @@ export const deepLinkSchemas = {
"confirm-gift": z.object({ "confirm-gift": z.object({
id: z.string(), id: z.string(),
}), }),
"contact-edit": z.object({
did: z.string(),
}),
"contact-import": z.object({ "contact-import": z.object({
jwt: z.string(), jwt: z.string(),
}), }),
contacts: z.object({
contactJwt: z.string().optional(),
inviteJwt: z.string().optional(),
}),
did: z.object({ did: z.object({
did: z.string(), did: z.string(),
}), }),
"invite-one-accept": z.object({ "invite-one-accept": z.object({
jwt: z.string(), // optional because A) it could be a query param, and B) the page displays an input if things go wrong
jwt: z.string().optional(),
}), }),
"onboard-meeting-setup": z.object({ "onboard-meeting-members": z.object({
id: z.string(), id: z.string(),
}), }),
project: z.object({ project: z.object({
@@ -92,6 +71,21 @@ export const deepLinkSchemas = {
}), }),
}; };
// Create a type from the array
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
// Update your schema definitions to use this type
export const baseUrlSchema = z.object({
scheme: z.literal("timesafari"),
path: z.string(),
queryParams: z.record(z.string()).optional(),
});
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = Object.keys(
deepLinkSchemas,
) as readonly (keyof typeof deepLinkSchemas)[];
export type DeepLinkParams = { export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>; [K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
}; };
@@ -100,3 +94,8 @@ export interface DeepLinkError extends Error {
code: string; code: string;
details?: unknown; details?: unknown;
} }
// Use the type to ensure route validation
export const routeSchema = z.enum(
VALID_DEEP_LINK_ROUTES as [string, ...string[]],
);

View File

@@ -60,7 +60,7 @@ import {
KeyMetaMaybeWithPrivate, KeyMetaMaybeWithPrivate,
} from "../interfaces/common"; } from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records"; import { PlanSummaryRecord } from "../interfaces/records";
import { logger, safeStringify } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
/** /**
@@ -437,23 +437,19 @@ export async function getHeaders(
} }
headers["Authorization"] = "Bearer " + token; headers["Authorization"] = "Bearer " + token;
} catch (error) { } catch (error) {
// This rarely happens: we've seen it when they have account info but the // This rarely happens: we've seen it when they have account info but the
// encryption secret got lost. // encryption secret got lost. But in most cases we want users to at
// Replicate this in Chrome: go to Storage and hit 'Clear site data'. // least see their feed -- and anything else that returns results for
// Check the util.ts retrieveFullyDecryptedAccount method where it calls simpleDecrypt. // anonymous users.
// In most cases we want users to at least see their feed -- and anything // We'll continue with an anonymous request... still want to show feed and other things, but ideally let them know.
// 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( logConsoleAndDb(
"Something failed in getHeaders call (will proceed anonymously" + "Something failed in getHeaders call (will proceed anonymously" +
($notify ? " and notify user" : "") + ($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'. // 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 //JSON.stringify(error, getCircularReplacer()), // JSON.stringify(error) on a Dexie error throws another error about: Converting circular structure to JSON
error + " - " + safeStringify(error), error,
true, true,
); );
if ($notify) { if ($notify) {

View File

@@ -17,7 +17,7 @@ import {
updateDefaultSettings, updateDefaultSettings,
} from "../db/index"; } from "../db/index";
import { Account, AccountEncrypted } from "../db/tables/accounts"; import { Account, AccountEncrypted } from "../db/tables/accounts";
import { Contact, ContactWithJsonStrings } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import { import {
@@ -605,7 +605,7 @@ export const retrieveFullyDecryptedAccount = async (
dbAccount.values.length === 0 || dbAccount.values.length === 0 ||
dbAccount.values[0].length === 0 dbAccount.values[0].length === 0
) { ) {
throw new Error("Account not found for did: " + activeDid); throw new Error("Account not found.");
} }
const fullAccountData = databaseUtil.mapQueryResultToValues( const fullAccountData = databaseUtil.mapQueryResultToValues(
dbAccount, dbAccount,
@@ -966,31 +966,19 @@ export interface DatabaseExport {
} }
/** /**
* Converts an array of contacts to the standardized database export JSON format. * Converts an array of contacts to the export JSON format.
* This format is used for data migration and backup purposes. * This format is used for data migration and backup purposes.
* *
* @param contacts - Array of Contact objects to convert * @param contacts - Array of Contact objects to convert
* @returns DatabaseExport object in the standardized format * @returns DatabaseExport object in the standardized format
*/ */
export const contactsToExportJson = (contacts: Contact[]): 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) => {
const exContact: ContactWithJsonStrings = R.omit(
["contactMethods"],
contact,
);
exContact.contactMethods = contact.contactMethods
? JSON.stringify(contact.contactMethods, [])
: undefined;
return exContact;
});
return { return {
data: { data: {
data: [ data: [
{ {
tableName: "contacts", tableName: "contacts",
rows, rows: contacts,
}, },
], ],
}, },

View File

@@ -72,12 +72,12 @@ const handleDeepLink = async (data: { url: string }) => {
await deepLinkHandler.handleDeepLink(data.url); await deepLinkHandler.handleDeepLink(data.url);
} catch (error) { } catch (error) {
logger.error("[DeepLink] Error handling deep link: ", error); logger.error("[DeepLink] Error handling deep link: ", error);
handleApiError( let message: string =
{ error instanceof Error ? error.message : safeStringify(error);
message: error instanceof Error ? error.message : safeStringify(error), if (data.url) {
} as AxiosError, message += `\nURL: ${data.url}`;
"deep-link", }
); handleApiError({ message } as AxiosError, "deep-link");
} }
}; };

View File

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

View File

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

View File

@@ -73,6 +73,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contacts", name: "contacts",
component: () => import("../views/ContactsView.vue"), component: () => import("../views/ContactsView.vue"),
}, },
{
path: "/database-migration",
name: "database-migration",
component: () => import("../views/DatabaseMigration.vue"),
},
{ {
path: "/did/:did?", path: "/did/:did?",
name: "did", name: "did",
@@ -139,8 +144,9 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("../views/InviteOneView.vue"), component: () => import("../views/InviteOneView.vue"),
}, },
{ {
// optional because A) it could be a query param, and B) the page displays an input if things go wrong
path: "/invite-one-accept/:jwt?", path: "/invite-one-accept/:jwt?",
name: "InviteOneAcceptView", name: "invite-one-accept",
component: () => import("../views/InviteOneAcceptView.vue"), component: () => import("../views/InviteOneAcceptView.vue"),
}, },
{ {
@@ -148,11 +154,6 @@ const routes: Array<RouteRecordRaw> = [
name: "logs", name: "logs",
component: () => import("../views/LogView.vue"), component: () => import("../views/LogView.vue"),
}, },
{
path: "/database-migration",
name: "database-migration",
component: () => import("../views/DatabaseMigration.vue"),
},
{ {
path: "/new-activity", path: "/new-activity",
name: "new-activity", name: "new-activity",

View File

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

View File

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

View File

@@ -44,6 +44,8 @@
*/ */
import { Router } from "vue-router"; import { Router } from "vue-router";
import { z } from "zod";
import { import {
deepLinkSchemas, deepLinkSchemas,
baseUrlSchema, baseUrlSchema,
@@ -53,6 +55,38 @@ import {
import { logConsoleAndDb } from "../db/databaseUtil"; import { logConsoleAndDb } from "../db/databaseUtil";
import type { DeepLinkError } from "../interfaces/deepLinks"; import type { DeepLinkError } from "../interfaces/deepLinks";
// Helper function to extract the first key from a Zod object schema
function getFirstKeyFromZodObject(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: z.ZodObject<any>,
): string | undefined {
const shape = schema.shape;
const keys = Object.keys(shape);
return keys.length > 0 ? keys[0] : undefined;
}
/**
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
*
* It's an object where keys are the deep link routes and values are objects with 'name' and 'paramKey'.
*
* The paramKey is used to extract the parameter from the route path,
* because "router.replace" expects the right parameter name for the route.
*/
export const ROUTE_MAP: Record<string, { name: string; paramKey?: string }> =
Object.entries(deepLinkSchemas).reduce(
(acc, [routeName, schema]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject<any>);
acc[routeName] = {
name: routeName,
paramKey,
};
return acc;
},
{} as Record<string, { name: string; paramKey?: string }>,
);
/** /**
* Handles processing and routing of deep links in the application. * Handles processing and routing of deep links in the application.
* Provides validation, error handling, and routing for deep link URLs. * Provides validation, error handling, and routing for deep link URLs.
@@ -69,30 +103,7 @@ export class DeepLinkHandler {
} }
/** /**
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
*
* The paramKey is used to extract the parameter from the route path,
* because "router.replace" expects the right parameter name for the route.
* The default is "id".
*/
private readonly ROUTE_MAP: Record<
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" },
project: { name: "project" },
"user-profile": { name: "user-profile" },
};
/**
* Parses deep link URL into path, params and query components. * Parses deep link URL into path, params and query components.
* Validates URL structure using Zod schemas. * Validates URL structure using Zod schemas.
* *
@@ -115,18 +126,9 @@ export class DeepLinkHandler {
const [path, queryString] = parts[1].split("?"); const [path, queryString] = parts[1].split("?");
const [routePath, ...pathParams] = 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 // Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) { if (!ROUTE_MAP[routePath]) {
throw { throw {
code: "INVALID_ROUTE", code: "INVALID_ROUTE",
message: `Invalid route path: ${routePath}`, message: `Invalid route path: ${routePath}`,
@@ -144,9 +146,14 @@ export class DeepLinkHandler {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (pathParams) { if (pathParams) {
// Now we know routePath exists in ROUTE_MAP // Now we know routePath exists in ROUTE_MAP
const routeConfig = this.ROUTE_MAP[routePath]; const routeConfig = ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = pathParams.join("/"); params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
} }
// logConsoleAndDb(
// `[DeepLink] Debug: Route Path: ${routePath} Path Params: ${JSON.stringify(params)} Query String: ${JSON.stringify(query)}`,
// false,
// );
return { path: routePath, params, query }; return { path: routePath, params, query };
} }
@@ -170,7 +177,7 @@ export class DeepLinkHandler {
try { try {
// Validate route exists // Validate route exists
const validRoute = routeSchema.parse(path) as DeepLinkRoute; const validRoute = routeSchema.parse(path) as DeepLinkRoute;
routeName = this.ROUTE_MAP[validRoute].name; routeName = ROUTE_MAP[validRoute].name;
} catch (error) { } catch (error) {
// Log the invalid route attempt // Log the invalid route attempt
logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true); logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true);
@@ -178,51 +185,69 @@ export class DeepLinkHandler {
// Redirect to error page with information about the invalid link // Redirect to error page with information about the invalid link
await this.router.replace({ await this.router.replace({
name: "deep-link-error", name: "deep-link-error",
params,
query: { query: {
originalPath: path, originalPath: path,
errorCode: "INVALID_ROUTE", errorCode: "INVALID_ROUTE",
message: `The link you followed (${path}) is not supported`, errorMessage: `The link you followed (${path}) is not supported`,
...query,
}, },
}); });
throw { // This previously threw an error but we're redirecting so there's no need.
code: "INVALID_ROUTE", return;
message: `Unsupported route: ${path}`,
};
} }
// Continue with parameter validation as before... // Continue with parameter validation as before...
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas]; const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
let validatedParams, validatedQuery;
try { try {
const validatedParams = await schema.parseAsync({ validatedParams = await schema.parseAsync(params);
...params, validatedQuery = await schema.parseAsync(query);
...query,
});
await this.router.replace({
name: routeName,
params: validatedParams,
query,
});
} catch (error) { } catch (error) {
// For parameter validation errors, provide specific error feedback // For parameter validation errors, provide specific error feedback
logConsoleAndDb(
`[DeepLink] Invalid parameters for route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with params: ${JSON.stringify(params)} ... and query: ${JSON.stringify(query)}`,
true,
);
await this.router.replace({ await this.router.replace({
name: "deep-link-error", name: "deep-link-error",
params,
query: { query: {
originalPath: path, originalPath: path,
errorCode: "INVALID_PARAMETERS", errorCode: "INVALID_PARAMETERS",
message: `The link parameters are invalid: ${(error as Error).message}`, errorMessage: `The link parameters are invalid: ${(error as Error).message}`,
...query,
}, },
}); });
throw { // This previously threw an error but we're redirecting so there's no need.
code: "INVALID_PARAMETERS", return;
message: (error as Error).message, }
details: error,
params: params, try {
query: query, await this.router.replace({
}; name: routeName,
params: validatedParams,
query: validatedQuery,
});
} catch (error) {
logConsoleAndDb(
`[DeepLink] Error routing to route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with validated params: ${JSON.stringify(validatedParams)} ... and validated query: ${JSON.stringify(validatedQuery)}`,
true,
);
// For parameter validation errors, provide specific error feedback
await this.router.replace({
name: "deep-link-error",
params: validatedParams,
query: {
originalPath: path,
errorCode: "ROUTING_ERROR",
errorMessage: `Error routing to ${routeName}: ${JSON.stringify(error)}`,
...validatedQuery,
},
});
} }
} }
@@ -235,7 +260,6 @@ export class DeepLinkHandler {
*/ */
async handleDeepLink(url: string): Promise<void> { async handleDeepLink(url: string): Promise<void> {
try { try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url); const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string // Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries( const sanitizedParams = Object.fromEntries(
@@ -245,7 +269,7 @@ export class DeepLinkHandler {
} catch (error) { } catch (error) {
const deepLinkError = error as DeepLinkError; const deepLinkError = error as DeepLinkError;
logConsoleAndDb( logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`, `[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.details}`,
true, true,
); );

View File

@@ -1,5 +1,4 @@
import { logToDb } from "../db/databaseUtil"; import { logToDb } from "../db/databaseUtil";
import { BuildPlatform, NodeEnv } from "@/interfaces/build";
export function safeStringify(obj: unknown) { export function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
@@ -22,7 +21,7 @@ export function safeStringify(obj: unknown) {
export const logger = { export const logger = {
debug: (message: string, ...args: unknown[]) => { debug: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== NodeEnv.Prod) { if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.debug(message, ...args); console.debug(message, ...args);
// const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; // const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
@@ -31,8 +30,8 @@ export const logger = {
}, },
log: (message: string, ...args: unknown[]) => { log: (message: string, ...args: unknown[]) => {
if ( if (
process.env.NODE_ENV !== NodeEnv.Prod || process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === BuildPlatform.Capacitor process.env.VITE_PLATFORM === "capacitor"
) { ) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(message, ...args); console.log(message, ...args);
@@ -42,9 +41,9 @@ export const logger = {
}, },
info: (message: string, ...args: unknown[]) => { info: (message: string, ...args: unknown[]) => {
if ( if (
process.env.NODE_ENV !== NodeEnv.Prod || process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === BuildPlatform.Capacitor || process.env.VITE_PLATFORM === "capacitor" ||
process.env.VITE_PLATFORM === BuildPlatform.Electron process.env.VITE_PLATFORM === "electron"
) { ) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.info(message, ...args); console.info(message, ...args);

View File

@@ -126,7 +126,7 @@
<div class="flex justify-center text-center text-sm leading-tight mb-1"> <div class="flex justify-center text-center text-sm leading-tight mb-1">
People {{ profileImageUrl ? "without your image" : "" }} see this People {{ profileImageUrl ? "without your image" : "" }} see this
<br /> <br />
(if you've let them see which posts are yours): (if you've let them see your activity):
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<EntityIcon <EntityIcon
@@ -1573,25 +1573,24 @@ export default class AccountViewView extends Vue {
* @throws Will notify the user if there is an export error. * @throws Will notify the user if there is an export error.
*/ */
public async exportDatabase() { public async exportDatabase() {
throw new Error("Not implemented"); try {
// try { // Generate the blob from the database
// // Generate the blob from the database const blob = await this.generateDatabaseBlob();
// const blob = await this.generateDatabaseBlob();
// // Create a temporary URL for the blob // Create a temporary URL for the blob
// this.downloadUrl = this.createBlobURL(blob); this.downloadUrl = this.createBlobURL(blob);
// // Trigger the download // Trigger the download
// this.downloadDatabaseBackup(this.downloadUrl); this.downloadDatabaseBackup(this.downloadUrl);
// // Notify the user that the download has started // Notify the user that the download has started
// this.notifyDownloadStarted(); this.notifyDownloadStarted();
// // Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure // Revoke the temporary URL -- after a pause to avoid DuckDuckGo download failure
// setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
// } catch (error) { } catch (error) {
// this.handleExportError(error); this.handleExportError(error);
// } }
} }
/** /**

View File

@@ -31,7 +31,11 @@
<h2>Supported Deep Links</h2> <h2>Supported Deep Links</h2>
<ul> <ul>
<li v-for="(routeItem, index) in validRoutes" :key="index"> <li v-for="(routeItem, index) in validRoutes" :key="index">
<code>timesafari://{{ routeItem }}/:id</code> <code
>timesafari://{{ routeItem }}/:{{
deepLinkSchemaKeys[routeItem]
}}</code
>
</li> </li>
</ul> </ul>
</div> </div>
@@ -41,12 +45,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from "vue"; import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { VALID_DEEP_LINK_ROUTES } from "../interfaces/deepLinks"; import {
VALID_DEEP_LINK_ROUTES,
deepLinkSchemas,
} from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db/databaseUtil"; import { logConsoleAndDb } from "../db/databaseUtil";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
// an object with the route as the key and the first param name as the value
const deepLinkSchemaKeys = Object.fromEntries(
Object.entries(deepLinkSchemas).map(([route, schema]) => {
const param = Object.keys(schema.shape)[0];
return [route, param];
}),
);
// Extract error information from query params // Extract error information from query params
const errorCode = computed( const errorCode = computed(
@@ -54,7 +68,7 @@ const errorCode = computed(
); );
const errorMessage = computed( const errorMessage = computed(
() => () =>
(route.query.message as string) || (route.query.errorMessage as string) ||
"The deep link you followed is invalid or not supported.", "The deep link you followed is invalid or not supported.",
); );
const originalPath = computed(() => route.query.originalPath as string); const originalPath = computed(() => route.query.originalPath as string);
@@ -93,7 +107,7 @@ const reportIssue = () => {
// Log the error for analytics // Log the error for analytics
onMounted(() => { onMounted(() => {
logConsoleAndDb( logConsoleAndDb(
`[DeepLink] Error page displayed for path: ${originalPath.value}, code: ${errorCode.value}, params: ${JSON.stringify(route.params)}`, `[DeepLinkError] Error page displayed for path: ${originalPath.value}, code: ${errorCode.value}, params: ${JSON.stringify(route.params)}, query: ${JSON.stringify(route.query)}`,
true, true,
); );
}); });

View File

@@ -100,7 +100,6 @@ import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { APP_SERVER } from "@/constants/app"; import { APP_SERVER } from "@/constants/app";
import { NodeEnv } from "@/interfaces/build";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { errorStringForLog } from "@/libs/endorserServer"; import { errorStringForLog } from "@/libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@@ -149,7 +148,7 @@ export default class DeepLinkRedirectView extends Vue {
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`; this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`; this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
this.isDevelopment = process.env.NODE_ENV !== NodeEnv.Prod; this.isDevelopment = process.env.NODE_ENV !== "production";
this.userAgent = navigator.userAgent; this.userAgent = navigator.userAgent;
this.openDeepLink(); this.openDeepLink();

View File

@@ -102,7 +102,8 @@ 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" 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()" @click="showNameThenIdDialog()"
> >
Show them your identification info Show them {{ PASSKEYS_ENABLED ? "default" : "your" }} identifier
info
</button> </button>
</div> </div>
<UserNameDialog ref="userNameDialog" /> <UserNameDialog ref="userNameDialog" />
@@ -682,7 +683,7 @@ export default class HomeView extends Vue {
group: "alert", group: "alert",
type: "warning", type: "warning",
title: "Feed Loading Issue", title: "Feed Loading Issue",
text: "Some feed data may be unavailable. Try refreshing the page.", text: "Some feed data may be unavailable. Pull to refresh.",
}, },
5000, 5000,
); );

View File

@@ -128,7 +128,10 @@ export default class InviteOneAcceptView extends Vue {
} }
// Extract JWT from route path // Extract JWT from route path
const jwt = (this.$route.params.jwt as string) || ""; const jwt =
(this.$route.params.jwt as string) ||
(this.$route.query.jwt as string) ||
"";
await this.processInvite(jwt, false); await this.processInvite(jwt, false);
this.checkingInvite = false; this.checkingInvite = false;

View File

@@ -153,9 +153,7 @@ export default class QuickActionBvcBeginView extends Vue {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: text: timeResult?.error || "There was an error sending the time.",
timeResult?.error ||
"There was an error sending the time.",
}, },
5000, 5000,
); );

55
src/vite.config.utils.js Normal file
View File

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

View File

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

View File

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

View File

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

4
vite.config.dev.mts Normal file
View File

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

View File

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

71
vite.config.mts Normal file
View File

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

53
vite.config.ts Normal file
View File

@@ -0,0 +1,53 @@
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

@@ -45,73 +45,71 @@ interface PWAConfig {
} }
interface AppConfig { interface AppConfig {
pwaConfig: PWAConfig;
aliasConfig: { aliasConfig: {
[key: string]: string; [key: string]: string;
}; };
} }
export async function loadAppConfig(): Promise<AppConfig> { export async function loadAppConfig(): Promise<AppConfig> {
const packageJson = await loadPackageJson();
const appName = process.env.TIME_SAFARI_APP_TITLE || packageJson.name;
return { 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: { aliasConfig: {
"@": path.resolve(__dirname, "src"), "@": path.resolve(__dirname, "src"),
buffer: path.resolve(__dirname, "node_modules", "buffer"), buffer: path.resolve(__dirname, "node_modules", "buffer"),
"dexie-export-import/dist/import": "dexie-export-import/dist/import":
"dexie-export-import/dist/import/index.js", "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,18 +1,17 @@
import { defineConfig, mergeConfig } from "vite"; import { defineConfig, mergeConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import { createBuildConfig } from "./vite.config.common.mts"; import { createBuildConfig } from "./vite.config.common.mts";
import { loadPwaConfig } from "./vite.config.common-utils.mts"; import { loadAppConfig } from "./vite.config.utils.mts";
import { BuildPlatform } from "./src/interfaces/build.ts";
export default defineConfig(async () => { export default defineConfig(async () => {
const baseConfig = await createBuildConfig(BuildPlatform.Web); const baseConfig = await createBuildConfig('web');
const pwaConfig = await loadPwaConfig(); const appConfig = await loadAppConfig();
return mergeConfig(baseConfig, { return mergeConfig(baseConfig, {
plugins: [ plugins: [
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
manifest: pwaConfig.manifest, manifest: appConfig.pwaConfig?.manifest,
devOptions: { devOptions: {
enabled: false enabled: false
}, },