Merge branch 'master' into firebase-build-command
This commit is contained in:
+18
-18
@@ -10,7 +10,7 @@ This guide explains how to build TimeSafari for different platforms using the co
|
|||||||
# 🖥️ Web Development
|
# 🖥️ Web Development
|
||||||
npm install # setup -- and pkgx.dev `dev` command before this will set environment with npm, etc
|
npm install # setup -- and pkgx.dev `dev` command before this will set environment with npm, etc
|
||||||
npm run build:web:serve -- --test # Start with test endorser server
|
npm run build:web:serve -- --test # Start with test endorser server
|
||||||
npm run build:web:dev # Start development server with hot reload with local endorser server
|
npm run dev # Start development server with hot reload with local endorser server
|
||||||
npm run build:web:prod # Production build
|
npm run build:web:prod # Production build
|
||||||
|
|
||||||
# 📱 Mobile Development
|
# 📱 Mobile Development
|
||||||
@@ -44,7 +44,7 @@ npm run test:web # Run web tests to verify setup
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start web development server
|
# Start web development server
|
||||||
npm run build:web:dev # Opens http://localhost:8080
|
npm run dev # Opens http://localhost:8080
|
||||||
|
|
||||||
# For mobile development
|
# For mobile development
|
||||||
npm run build:ios # Opens Xcode with iOS project
|
npm run build:ios # Opens Xcode with iOS project
|
||||||
@@ -219,13 +219,13 @@ Install dependencies:
|
|||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Web Development
|
## Web Development & Deployment
|
||||||
|
|
||||||
### Web Development Commands
|
### Web Development Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Local development
|
# Local development
|
||||||
npm run build:web:dev # Start development server with hot reload
|
npm run dev # Start development server with hot reload (shortcut for `build:web:dev`)
|
||||||
|
|
||||||
# Web builds
|
# Web builds
|
||||||
npm run build:web # Development build (starts dev server with hot reload)
|
npm run build:web # Development build (starts dev server with hot reload)
|
||||||
@@ -241,7 +241,7 @@ npm run build:web:docker:prod # Production build with Docker containerizat
|
|||||||
|
|
||||||
### Local Development
|
### Local Development
|
||||||
|
|
||||||
Start the development server using `npm run build:web:dev` or `npm run build:web` (both start the development server with hot reload).
|
Start the development server using `npm run dev` (a shortcut for `npm run build:web:dev`) or `npm run build:web` (both start the development server with hot reload).
|
||||||
|
|
||||||
### Web Build for Server
|
### Web Build for Server
|
||||||
|
|
||||||
@@ -1146,7 +1146,7 @@ export GEM_PATH=$shortened_path
|
|||||||
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
##### 1. Bump the version in package.json & CHANGELOG.md for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version here:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ios/App && xcrun agvtool new-version 72 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.6;/g" App.xcodeproj/project.pbxproj && cd -
|
cd ios/App && xcrun agvtool new-version 75 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.4.9;/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
|
||||||
```
|
```
|
||||||
@@ -1434,8 +1434,8 @@ The recommended way to build for Android is using the automated build script:
|
|||||||
##### 1. Bump the version in package.json, then update these versions & run:
|
##### 1. Bump the version in package.json, then update these versions & run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
perl -p -i -e 's/versionCode .*/versionCode 72/g' android/app/build.gradle
|
perl -p -i -e 's/versionCode .*/versionCode 75/g' android/app/build.gradle
|
||||||
perl -p -i -e 's/versionName .*/versionName "1.4.6"/g' android/app/build.gradle
|
perl -p -i -e 's/versionName .*/versionName "1.4.9"/g' android/app/build.gradle
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 2. Build
|
##### 2. Build
|
||||||
@@ -1446,13 +1446,13 @@ perl -p -i -e 's/versionName .*/versionName "1.4.6"/g' android/app/build.gradle
|
|||||||
npm run build:android:prod
|
npm run build:android:prod
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 3. Open the project in Android Studio
|
##### 2.1. (Optional) Open the project in Android Studio
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx cap open android
|
npx cap open android
|
||||||
```
|
```
|
||||||
|
|
||||||
##### 4. Use Android Studio to build and run on emulator or device
|
##### 3. Use Android Studio to build and run on emulator or device
|
||||||
|
|
||||||
#### Android Build from the console
|
#### Android Build from the console
|
||||||
|
|
||||||
@@ -1460,13 +1460,7 @@ npx cap open android
|
|||||||
|
|
||||||
* Note that this currently fails: `./gradlew build`
|
* Note that this currently fails: `./gradlew build`
|
||||||
|
|
||||||
To create the `aab` file, `bundle` instead of `build` (but not signed and not usable in Play Store):
|
To create a signed `aab` release:
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew bundleDebug -Dlint.baselines.continue=true
|
|
||||||
```
|
|
||||||
|
|
||||||
So, to create a signed `aab` release:
|
|
||||||
|
|
||||||
- Setup by adding the app/gradle.properties.secrets file (see properties at top
|
- Setup by adding the app/gradle.properties.secrets file (see properties at top
|
||||||
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
|
of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
|
||||||
@@ -1481,6 +1475,12 @@ Firebase is on by default (`firebaseEnabled=true` in `android/gradle.properties`
|
|||||||
|
|
||||||
... and find your `aab` file at app/build/outputs/bundle/release
|
... and find your `aab` file at app/build/outputs/bundle/release
|
||||||
|
|
||||||
|
To create an unsigned debugging bundle (not usable in Play Store):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew bundleDebug -Dlint.baselines.continue=true
|
||||||
|
```
|
||||||
|
|
||||||
* Gradle packages whatever web bundle was last synced into
|
* Gradle packages whatever web bundle was last synced into
|
||||||
`android/app/src/main/assets/public`, even one from a `--test` build. Before
|
`android/app/src/main/assets/public`, even one from a `--test` build. Before
|
||||||
uploading, confirm it has production server defaults:
|
uploading, confirm it has production server defaults:
|
||||||
@@ -2407,7 +2407,7 @@ npm run clean:electron # Electron only
|
|||||||
# Clean and rebuild
|
# Clean and rebuild
|
||||||
npm run clean:all
|
npm run clean:all
|
||||||
npm install
|
npm install
|
||||||
npm run build:web:dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Appendix B: Vite Configuration Files Reference
|
## Appendix B: Vite Configuration Files Reference
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ 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).
|
||||||
|
|
||||||
|
|
||||||
|
## [1.4.9] - 2026.10.11
|
||||||
|
### Fixed
|
||||||
|
- Startup DB error for new installations
|
||||||
|
### Changed
|
||||||
|
- Allow in-app notifications on production (as an advanced feature)
|
||||||
|
- Allow modification of image server URL
|
||||||
|
- Terms & conditions and privacy policy
|
||||||
|
|
||||||
|
|
||||||
## [1.4.6] - 2026.09.27
|
## [1.4.6] - 2026.09.27
|
||||||
### Fixed
|
### Fixed
|
||||||
- Incorrect 'test' server URLs on production.
|
- Incorrect 'test' server URLs on production.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ npm install
|
|||||||
### Web
|
### Web
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build:web:dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
|
Then go to [the test page](http://localhost:8080/test) and click "Become User 0" to take action on the platform.
|
||||||
@@ -98,16 +98,16 @@ TimeSafari supports configurable logging levels via the `VITE_LOG_LEVEL` environ
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Show only errors
|
# Show only errors
|
||||||
VITE_LOG_LEVEL=error npm run build:web:dev
|
VITE_LOG_LEVEL=error npm run dev
|
||||||
|
|
||||||
# Show warnings and errors
|
# Show warnings and errors
|
||||||
VITE_LOG_LEVEL=warn npm run build:web:dev
|
VITE_LOG_LEVEL=warn npm run dev
|
||||||
|
|
||||||
# Show info, warnings, and errors (default)
|
# Show info, warnings, and errors (default)
|
||||||
VITE_LOG_LEVEL=info npm run build:web:dev
|
VITE_LOG_LEVEL=info npm run dev
|
||||||
|
|
||||||
# Show all log levels including debug
|
# Show all log levels including debug
|
||||||
VITE_LOG_LEVEL=debug npm run build:web:dev
|
VITE_LOG_LEVEL=debug npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Levels
|
### Available Levels
|
||||||
@@ -147,7 +147,7 @@ Platform-specific end-to-end guides:
|
|||||||
|
|
||||||
# Then restart your development server
|
# Then restart your development server
|
||||||
npm run build:electron:dev # For Electron
|
npm run build:electron:dev # For Electron
|
||||||
npm run build:web:dev # For Web
|
npm run dev # For Web
|
||||||
```
|
```
|
||||||
|
|
||||||
### What It Does
|
### What It Does
|
||||||
@@ -339,7 +339,7 @@ The application uses a platform-agnostic database layer with Vue mixins for serv
|
|||||||
|
|
||||||
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
|
**Architecture Decision**: The project uses Vue mixins over Composition API composables for platform service access. See [Architecture Decisions](doc/architecture-decisions.md) for detailed rationale.
|
||||||
|
|
||||||
## 📁 Project Structure
|
### 📁 Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
timesafari/
|
timesafari/
|
||||||
@@ -354,6 +354,45 @@ timesafari/
|
|||||||
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
|
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Building an APK for Android 6 (API 23)
|
||||||
|
|
||||||
|
App versions up to 1.4.6 support Android 6.0 (API 23); later versions require Android 7.0 (API 24) or newer. To run the app on an Android 6 device, build your own APK from the 1.4.6 release commit (`6183afc2`, "bump version to 1.4.6").
|
||||||
|
|
||||||
|
Prerequisites: git, Node.js 20 (see `.nvmrc`), Java 21, and the Android SDK with `ANDROID_HOME` set (see [Android Emulator Without Android Studio](BUILDING.md#android-emulator-without-android-studio-command-line-only) for an SDK setup that skips Android Studio). With [pkgx](https://pkgx.dev), prefix the commands with `pkgx`, e.g. `pkgx npm ci`.
|
||||||
|
|
||||||
|
1. Get the 1.4.6 source:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa.git
|
||||||
|
cd crowd-funder-for-time-pwa
|
||||||
|
git checkout 6183afc2
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Confirm the minimum SDK is 23:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep minSdkVersion android/variables.gradle # expect: minSdkVersion = 23
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies and build a production APK:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build:android:prod -- --apk
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm ci` installs the exact dependency versions locked for 1.4.6. `build:android:prod -- --apk` builds the web app with production server settings, syncs it into the Android project, and runs `./gradlew assembleDebug`. The result is signed with your local Android debug key, so it installs on any device without a release keystore.
|
||||||
|
|
||||||
|
4. Find the APK at `android/app/build/outputs/apk/debug/app-debug.apk` and install it, either with `adb install android/app/build/outputs/apk/debug/app-debug.apk` (USB debugging enabled on the phone) or by copying the file to the phone and opening it (allow "Install unknown apps" for the file manager).
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
* **Back up your identity first.** A self-built APK has a different signature from the Play Store app, so Android refuses to install it over the store version. Uninstalling the store version deletes the app's local data, including your identifier keys. Back up your seed phrase (Profile page → "Backup Identifier Seed") before uninstalling, then import it into the self-built app.
|
||||||
|
* This build has Firebase disabled, so push notifications through Google's FCM service are off.
|
||||||
|
* The daily notification plugin calls `java.time`, which Android provides only from 8.0 (API 26). On Android 6, scheduling a daily reminder can crash the app; leave daily reminders off. See [doc/plugin-feedback-android-6-api23-zoneid-fix.md](doc/plugin-feedback-android-6-api23-zoneid-fix.md).
|
||||||
|
* Barcode scanning uses Google Play Services; on devices without them, scanning fails while the rest of the app works.
|
||||||
|
* A self-built APK does not auto-update.
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
|
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ android {
|
|||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
// Backports java.time (API 26+) to older Android; the daily notification plugin uses it.
|
||||||
|
coreLibraryDesugaringEnabled true
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
targetCompatibility JavaVersion.VERSION_21
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
@@ -37,8 +39,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 72
|
versionCode 75
|
||||||
versionName "1.4.6"
|
versionName "1.4.9"
|
||||||
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.
|
||||||
@@ -101,6 +103,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5"
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".TimeSafariApplication"
|
android:name=".TimeSafariApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
|||||||
@@ -36,8 +36,8 @@
|
|||||||
"splashFullScreen": true,
|
"splashFullScreen": true,
|
||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup rules for Android 11 and lower; data_extraction_rules.xml has the same
|
||||||
|
exclusions for Android 12 and higher. Everything not excluded is backed up.
|
||||||
|
|
||||||
|
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||||
|
and Keystore keys are never backed up. Restoring the preferences without the
|
||||||
|
key makes the plugin fail to load on every launch.
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</full-backup-content>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Backup and device-transfer rules for Android 12 and higher; backup_rules.xml
|
||||||
|
has the same exclusions for Android 11 and lower. Everything not excluded is
|
||||||
|
backed up and transferred, including the database (which holds the user's keys).
|
||||||
|
|
||||||
|
The SQLite plugin's encrypted preferences are tied to an Android Keystore key,
|
||||||
|
and Keystore keys never leave the device. Restoring the preferences without
|
||||||
|
the key makes the plugin fail to load on every launch.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</cloud-backup>
|
||||||
|
<device-transfer>
|
||||||
|
<exclude domain="sharedpref" path="sqlite_encrypted_shared_prefs.xml" />
|
||||||
|
<exclude domain="sharedpref" path="__androidx_security_crypto_encrypted_prefs_key_keyset__.xml" />
|
||||||
|
</device-transfer>
|
||||||
|
</data-extraction-rules>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
ext {
|
ext {
|
||||||
minSdkVersion = 23
|
minSdkVersion = 24
|
||||||
compileSdkVersion = 36
|
compileSdkVersion = 36
|
||||||
targetSdkVersion = 36
|
targetSdkVersion = 36
|
||||||
androidxActivityVersion = '1.8.0'
|
androidxActivityVersion = '1.8.0'
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapacitorSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
+4
-2
@@ -34,8 +34,10 @@ const config: CapacitorConfig = {
|
|||||||
splashFullScreen: true,
|
splashFullScreen: true,
|
||||||
splashImmersive: true
|
splashImmersive: true
|
||||||
},
|
},
|
||||||
CapSQLite: {
|
// The key must match the plugin's registered name or every setting here is ignored.
|
||||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
CapacitorSQLite: {
|
||||||
|
// Installed iOS apps keep their database in Documents; changing this strands existing data.
|
||||||
|
iosDatabaseLocation: 'Documents',
|
||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
# Plan: Contacts Without a DID ("Placeholder Contacts")
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a user record a gift (or offer) given by or to a person who has not
|
||||||
|
installed the app, under that person's name, and later swap that person's real
|
||||||
|
DID into those claims once they join.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. A locally saved name for a person with no DID, plus everything a real
|
||||||
|
contact can hold locally: phone numbers and other contact methods, notes,
|
||||||
|
and labels, edited through the same screens.
|
||||||
|
2. That person can be picked as the giver or recipient in the gift dialogs,
|
||||||
|
the same way a real contact is.
|
||||||
|
3. The app keeps a local list of every claim that names the person, so the
|
||||||
|
claims can be found again without a server search.
|
||||||
|
4. When the person joins, the user links the placeholder to the person's real
|
||||||
|
contact, and the app edits each linked claim so the real DID appears
|
||||||
|
where the placeholder was.
|
||||||
|
5. Nothing that identifies a placeholder — its name or its local key — is
|
||||||
|
sent to the endorser server or shared with other users.
|
||||||
|
6. When the app links contacts to the device's address book, a placeholder
|
||||||
|
links the same way a real contact does, and the link survives the
|
||||||
|
placeholder becoming a real contact.
|
||||||
|
|
||||||
|
## Relevant constraints in the current schema
|
||||||
|
|
||||||
|
These come from `src/db-sql/migration.ts` and
|
||||||
|
`src/utils/PlatformServiceMixin.ts`.
|
||||||
|
|
||||||
|
| Constraint | Where | Effect on this feature |
|
||||||
|
|---|---|---|
|
||||||
|
| `contacts.did TEXT NOT NULL` | migration 008 | A contact row must have some DID value. |
|
||||||
|
| `UNIQUE INDEX idx_contacts_did ON contacts(did)` | migration 008 | Every contact's DID must be distinct. (SQLite allows multiple NULLs in a unique index, so the index alone would not block NULL DIDs; `NOT NULL` does.) |
|
||||||
|
| `contact_labels.did ... REFERENCES contacts(did) ON DELETE CASCADE` | migration 008 | Labels are keyed by DID. There is no `ON UPDATE` action, so `UPDATE contacts SET did = ...` fails with `FOREIGN KEY constraint failed` while the contact has labels (verified with the `sqlite3` CLI), when foreign keys are enforced on the connection. |
|
||||||
|
| `$insertContact` uses `INSERT OR REPLACE` | `PlatformServiceMixin.ts` | REPLACE deletes the conflicting row before inserting. With foreign keys enforced, that delete cascades and wipes the contact's labels. The new row also gets a new `id`, because `id` is not in the insert's column list (both verified with the `sqlite3` CLI). Any table that references `contacts(did)` with `ON DELETE CASCADE` inherits the first hazard, and anything that references `contacts.id` inherits the second. Callers: `ContactsView`, `InviteOneView`, `MeetingMembersList`, `BulkMembersDialog`. |
|
||||||
|
| `Contact.did: string` (required) | `src/db/tables/contacts.ts` | Every view, lookup (`contactForDid`, `didInfo`), and mixin method (`$getContact`, `$updateContact`, `$deleteContact`, label methods) is keyed by DID string. |
|
||||||
|
| `isDid(s)` is `s.startsWith("did:")` | `src/libs/endorserServer.ts` | Any string starting with `did:` is treated as a real DID by import validation, display logic, and server calls. |
|
||||||
|
|
||||||
|
Whether foreign keys are enforced at runtime depends on whether a
|
||||||
|
`PRAGMA foreign_keys = ON` took effect on the current connection; nothing
|
||||||
|
outside the migrations sets it. The plan below assumes they may be enforced
|
||||||
|
and must work either way.
|
||||||
|
|
||||||
|
## Recommended: local placeholder key in `contacts`, no DID in the claim
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
- A placeholder is an ordinary row in `contacts` whose `did` column holds a
|
||||||
|
local-only key: `local:` followed by a random UUID, e.g.
|
||||||
|
`local:3f0c9a8e-5d2b-4c1e-9b7a-2e6f1d0c4a11`.
|
||||||
|
- The key deliberately does **not** start with `did:`, so `isDid()` is false
|
||||||
|
for it and every existing DID-only code path rejects it instead of treating
|
||||||
|
it as real.
|
||||||
|
- Claims naming a placeholder carry **no** agent/recipient DID in that slot —
|
||||||
|
the same shape the gift dialog already sends for "Unnamed".
|
||||||
|
- A new table records which claims involve which placeholder, so they can be
|
||||||
|
edited when the real DID is known.
|
||||||
|
|
||||||
|
The only schema change is one additive `CREATE TABLE`. The `contacts` and
|
||||||
|
`contact_labels` tables are untouched, so no table rebuild is needed (a
|
||||||
|
rebuild is the risky part of migration 008).
|
||||||
|
|
||||||
|
### Schema change (migration 012)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Claims that name a placeholder contact (contacts.did LIKE 'local:%') in place of a DID,
|
||||||
|
-- kept so they can be edited to carry the real DID once that person joins.
|
||||||
|
CREATE TABLE placeholder_claims (
|
||||||
|
contactKey TEXT NOT NULL, -- contacts.did: the placeholder key, or the real DID after linking
|
||||||
|
claimHandleId TEXT NOT NULL, -- handleId from the server; stays the same across edits
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('agent', 'recipient')),
|
||||||
|
claimType TEXT NOT NULL, -- eg. 'GiveAction', 'Offer'
|
||||||
|
issuerDid TEXT NOT NULL, -- only this identity can edit the claim
|
||||||
|
apiServer TEXT NOT NULL, -- the server the claim was sent to
|
||||||
|
dateCreated TEXT NOT NULL,
|
||||||
|
dateAssigned TEXT, -- set when the claim has been edited to carry the real DID
|
||||||
|
PRIMARY KEY (contactKey, claimHandleId, role)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_placeholder_claims_key ON placeholder_claims(contactKey);
|
||||||
|
```
|
||||||
|
|
||||||
|
The column is named for the contact rather than the placeholder because
|
||||||
|
linking rekeys it to the real DID along with every other contact-keyed column (see
|
||||||
|
*Linking when the person joins*). A row with a real DID and
|
||||||
|
`dateAssigned IS NULL` is a claim still waiting to be edited.
|
||||||
|
|
||||||
|
No foreign key to `contacts`: a cascade would let any `INSERT OR REPLACE` of
|
||||||
|
the placeholder contact silently delete its claim links (see the
|
||||||
|
`$insertContact` row above), and those links are the only record of which
|
||||||
|
claims to fix. Orphan cleanup happens in code when a placeholder is deleted
|
||||||
|
on purpose.
|
||||||
|
|
||||||
|
The `role` column is part of the key because one claim can name the same
|
||||||
|
placeholder as both giver and recipient.
|
||||||
|
|
||||||
|
### Code changes
|
||||||
|
|
||||||
|
**Prerequisite: make `$insertContact` an upsert that keeps the row**
|
||||||
|
|
||||||
|
Change `INSERT OR REPLACE` to an `UPDATE ... WHERE did = ?`, followed by an
|
||||||
|
`INSERT` only when no row was updated. That keeps the row's `id` and leaves
|
||||||
|
its labels and other contact-keyed rows alone. An `INSERT ... ON CONFLICT(did)
|
||||||
|
DO UPDATE` does the same in one statement, but needs SQLite 3.24+ on every
|
||||||
|
platform's driver, and that hasn't been checked. This fixes an existing
|
||||||
|
label-loss hazard and is worth shipping first, on its own.
|
||||||
|
|
||||||
|
**Identity helpers** (`src/libs/endorserServer.ts`)
|
||||||
|
|
||||||
|
- `PLACEHOLDER_KEY_PREFIX = "local:"`, `isPlaceholderKey(s)`, and
|
||||||
|
`newPlaceholderKey()` (uses `crypto.randomUUID()`).
|
||||||
|
- `didInfoForContact`: a placeholder contact displays its name, with a marker
|
||||||
|
such as "(not on Time Safari yet)", and `known: true`.
|
||||||
|
|
||||||
|
**Creating a placeholder**
|
||||||
|
|
||||||
|
- Contacts view: an "Add someone without the app" action that asks only for
|
||||||
|
a name (notes and contact methods optional) and saves a contact with a new
|
||||||
|
placeholder key.
|
||||||
|
- Gift dialog entity picker: the same action inline, so a user can create a
|
||||||
|
placeholder while recording a gift.
|
||||||
|
- Device address book (once that integration exists): pick a person from the
|
||||||
|
phone's contacts. The placeholder is created with that name and phone
|
||||||
|
number filled in and already linked to the device contact.
|
||||||
|
|
||||||
|
**Phone numbers, notes, and labels**
|
||||||
|
|
||||||
|
These need no new storage. `ContactEditView` (`/contact-edit/:did`) edits
|
||||||
|
`contactMethods` (the JSON column holding phone numbers, emails, and so
|
||||||
|
on), `notes`, and labels through `contact_labels`, all keyed by the
|
||||||
|
`contacts.did` value and with no server calls. A placeholder key in that
|
||||||
|
route parameter works as-is. Changes needed:
|
||||||
|
|
||||||
|
- Hide the server-backed parts of contact pages (visibility, registration,
|
||||||
|
the DID/key display) for placeholders. The edit form itself stays as it
|
||||||
|
is.
|
||||||
|
- Store phone numbers in a normalized form (E.164, e.g. `+18015550100`)
|
||||||
|
alongside what the user typed, so they can be compared for matching when
|
||||||
|
the person joins (see *Linking*). Normalizing on save is cheaper than
|
||||||
|
normalizing on every comparison.
|
||||||
|
|
||||||
|
**Device address-book links**
|
||||||
|
|
||||||
|
Whatever table holds the link between an app contact and a device contact
|
||||||
|
(Android lookup key / iOS `CNContact` identifier) is keyed by the
|
||||||
|
`contacts.did` value, the same as `contact_labels`. That gives real contacts
|
||||||
|
and placeholders one shared mechanism, and linking rekeys the link along
|
||||||
|
with everything else. Two rules for that table:
|
||||||
|
|
||||||
|
- No `ON DELETE CASCADE` foreign key to `contacts`, for the same reason as
|
||||||
|
`placeholder_claims`. Code cleans up these rows when a contact is
|
||||||
|
deleted on purpose.
|
||||||
|
- Device identifiers mean nothing on another device, so a backup restored
|
||||||
|
elsewhere either leaves these rows out or re-checks them against that
|
||||||
|
device's address book.
|
||||||
|
|
||||||
|
**One contact-keyed registry**
|
||||||
|
|
||||||
|
Every table with a column holding a `contacts.did` value is listed in one
|
||||||
|
constant, e.g. `CONTACT_KEYED_COLUMNS = [["contact_labels", "did"],
|
||||||
|
["placeholder_claims", "contactKey"], ...]`, which `rekeyContact` and
|
||||||
|
`deleteContact` iterate over. A test compares that list with the live
|
||||||
|
schema: every foreign key to `contacts(did)` (from `PRAGMA
|
||||||
|
foreign_key_list`), plus any column named `contactKey`. A table added later
|
||||||
|
and left out of the list fails the test, so it can't be orphaned by a
|
||||||
|
rekey.
|
||||||
|
|
||||||
|
**Recording a claim** (`GiftedDialog`, offer dialog, `hydrateGive`, `hydrateOffer` callers)
|
||||||
|
|
||||||
|
- When the chosen giver or recipient is a placeholder, pass `undefined` for
|
||||||
|
that DID to `createAndSubmitGive` / `createAndSubmitOffer`.
|
||||||
|
- On success, insert a `placeholder_claims` row with the returned `handleId`,
|
||||||
|
the role, the active DID as issuer, and the API server.
|
||||||
|
- If the submission succeeds but the local insert fails, show an error that
|
||||||
|
names the claim, because the link would otherwise be lost silently.
|
||||||
|
- A placeholder on both sides is allowed: the server accepts a give with no
|
||||||
|
agent, no recipient, and no project.
|
||||||
|
|
||||||
|
**Choosing a giver or recipient** (`EntityGrid`, `EntitySelectionStep`, `PersonCard`)
|
||||||
|
|
||||||
|
Placeholders appear in the picker in alphabetical order, searchable by name,
|
||||||
|
mixed in with real contacts. Because a placeholder is a row in `contacts`,
|
||||||
|
this needs almost no list-merging work:
|
||||||
|
|
||||||
|
- `GiftedDialog` passes `$contactsByDateAdded()` to `EntityGrid`, which
|
||||||
|
already builds both sections from that one array: "Recently Added" (the
|
||||||
|
first rows, newest by `id`) and "Everyone" (sorted by
|
||||||
|
`(name || did).toLowerCase()` with `localeCompare`). Placeholders land in
|
||||||
|
both sections with no extra query and no merge step.
|
||||||
|
- `performContactSearch` filters the same array by name and DID substring
|
||||||
|
and sorts it the same way, so placeholders are searchable automatically.
|
||||||
|
|
||||||
|
What does need care:
|
||||||
|
|
||||||
|
- **Require a name.** Both sorts fall back to the DID when the name is blank,
|
||||||
|
which would put a nameless placeholder under "L" for `local:`. Saving a
|
||||||
|
placeholder requires a non-blank, trimmed name.
|
||||||
|
- **Don't match on the key.** Searching "local" or a UUID fragment would
|
||||||
|
match every placeholder. In `performContactSearch`, compare against the
|
||||||
|
DID only when `!isPlaceholderKey(contact.did)`.
|
||||||
|
- **One comparator.** The sort is written out twice (`alphabeticalContacts`
|
||||||
|
and `performContactSearch`). Pull it into one exported function, e.g.
|
||||||
|
`compareContactsByName`, so a placeholder tiebreak only has to be added
|
||||||
|
once. For a tie, put real contacts before placeholders and then compare
|
||||||
|
keys, so a real "Bob" and a placeholder "Bob" always come out in the same
|
||||||
|
order.
|
||||||
|
- **Mark placeholders on the card.** `PersonCard` shows a small badge or
|
||||||
|
subtitle ("not on Time Safari yet"), and `EntityIcon` shows a fixed
|
||||||
|
"person without the app" icon rather than an identicon built from the
|
||||||
|
key. This matters most when two people share a name.
|
||||||
|
- **Create from search.** When a contact search finds nothing (the
|
||||||
|
`filteredEntities.length === 0` message), offer "Add “<term>” as someone
|
||||||
|
without the app". The new placeholder is selected immediately and the
|
||||||
|
dialog moves to the next step. Next to the "Unnamed" `SpecialEntityCard`,
|
||||||
|
an "Add someone without the app" card covers the case where nothing has
|
||||||
|
been typed yet.
|
||||||
|
- **Keep the selection key-based.** `handlePersonSelected` and
|
||||||
|
`isPersonConflicted` compare `did` strings, and the placeholder key works
|
||||||
|
there unchanged. Convert key → `undefined` only at submission time (see
|
||||||
|
*Recording a claim*), never in the picker, or two placeholders would
|
||||||
|
look identical to the conflict check.
|
||||||
|
- **Filter where a real DID is required.** `ProjectRepresentativeDialog`
|
||||||
|
uses the same grid to pick someone who will act on the server, so it gets
|
||||||
|
`allContacts.filter(c => !isPlaceholderKey(c.did))`. The same goes for any
|
||||||
|
other picker whose choice is sent to the server as a DID.
|
||||||
|
|
||||||
|
The `EntityGrid` doc comment says arrays must be sorted by date added
|
||||||
|
(newest first). That holds for placeholders because they get ordinary
|
||||||
|
autoincrement `id`s.
|
||||||
|
|
||||||
|
**Guards: a placeholder key must never reach the server or another user**
|
||||||
|
|
||||||
|
Every place that sends a contact's DID somewhere needs to skip or refuse
|
||||||
|
placeholders. From the current code, that includes:
|
||||||
|
|
||||||
|
- Visibility toggles and checks (`setVisibilityUtil`, "sees me" refresh)
|
||||||
|
- Registration (`register`) and invite flows
|
||||||
|
- Contact sharing: QR/JWT URL generation, CSV export (`CONTACT_CSV_HEADER`),
|
||||||
|
"copy selected contacts", `contactImportPayload`
|
||||||
|
- Server-side give/offer totals per contact in `ContactsView` (`loadGives`)
|
||||||
|
- The `/did/:did` route (`DIDView`), which queries the server: placeholders
|
||||||
|
open a local-only detail page instead
|
||||||
|
- Meeting member flows (`MeetingMembersList`, `BulkMembersDialog`)
|
||||||
|
|
||||||
|
As a backstop, `createAndSubmitClaim` throws if the serialized claim contains
|
||||||
|
any `local:` key, so a missed guard fails loudly rather than publishing the
|
||||||
|
key.
|
||||||
|
|
||||||
|
**Backups**: the full data export (DataExportSection / backup) **includes**
|
||||||
|
placeholders and the `placeholder_claims` table, since they exist only on
|
||||||
|
this device. Contact sharing with other users **excludes** them.
|
||||||
|
|
||||||
|
**Linking when the person joins**
|
||||||
|
|
||||||
|
The placeholder's row **becomes** the real contact: its `did` value is
|
||||||
|
rekeyed from the `local:` key to the real DID. Its `id`, phone numbers,
|
||||||
|
notes, labels, device link, and claim rows all stay with it. Two contact
|
||||||
|
rows are never kept for one person.
|
||||||
|
|
||||||
|
1. The person's DID arrives the usual way (QR scan, contact link). Before
|
||||||
|
inserting it, the app checks for placeholders that match: the same
|
||||||
|
normalized phone number, the same device-contact link, or the same name.
|
||||||
|
It asks "Is this <name>, whom you recorded without the app?" The user can
|
||||||
|
also start from the placeholder's page ("This person joined") and pick a
|
||||||
|
contact that already exists.
|
||||||
|
2. `rekeyContact(fromKey, toDid)`, in one transaction:
|
||||||
|
1. Read every contact-keyed row (from the registry) for both `fromKey`
|
||||||
|
and `toDid`.
|
||||||
|
2. If a contact row with `toDid` already exists, copy its server-derived
|
||||||
|
fields (`publicKeyBase64`, `nextPubKeyHashB64`, `seesMe`,
|
||||||
|
`registered`, `profileImageUrl`) onto the placeholder row, and fill
|
||||||
|
any other field the placeholder left empty. Then delete that row's
|
||||||
|
dependent rows and the row itself.
|
||||||
|
3. Delete the dependent rows for `fromKey`.
|
||||||
|
4. `UPDATE contacts SET did = toDid WHERE did = fromKey`.
|
||||||
|
5. Re-insert the union of both sets of dependent rows under `toDid`
|
||||||
|
(`INSERT OR IGNORE`, so a label both rows had is kept once).
|
||||||
|
|
||||||
|
Dependent rows are deleted before the update and re-inserted after it
|
||||||
|
because `UPDATE contacts SET did` fails while `contact_labels` rows
|
||||||
|
reference the old value and foreign keys are enforced. This order works
|
||||||
|
whether or not they are enforced, so it doesn't rely on
|
||||||
|
`PRAGMA defer_foreign_keys`. (That pragma also works, verified with the
|
||||||
|
`sqlite3` CLI, but only inside an explicit transaction.)
|
||||||
|
3. Before any edits, warn about confirmations. Editing a claim does not
|
||||||
|
carry its confirmations over to the edited version, so the app lists the
|
||||||
|
linked claims that have confirmations (from the same data the claim
|
||||||
|
detail view shows) and says those confirmations will have to be given
|
||||||
|
again. The user can go ahead or cancel.
|
||||||
|
4. For each `placeholder_claims` row for this contact with
|
||||||
|
`dateAssigned IS NULL`:
|
||||||
|
- skip it, with a message, if `issuerDid` is not the active identity
|
||||||
|
(the server accepts edits only from the claim's issuer; the message
|
||||||
|
names which of this device's accounts to switch to, if any), or if its
|
||||||
|
`apiServer` differs from the active one;
|
||||||
|
- fetch the current claim by handle ID, call `editAndSubmitGive` /
|
||||||
|
`editAndSubmitOffer` with the real DID in the row's role, keeping every
|
||||||
|
other field;
|
||||||
|
- set `dateAssigned` on success.
|
||||||
|
5. When every row is assigned, its `placeholder_claims` rows can be
|
||||||
|
deleted; the contact itself stays, as an ordinary real contact. Any
|
||||||
|
unassigned rows stay listed on the contact's page with a retry button,
|
||||||
|
since some edits may need another account or a network connection.
|
||||||
|
6. Prompt the user to tell the person. Once at least one claim is assigned,
|
||||||
|
offer to share a message (through the share sheet, or a contact method
|
||||||
|
saved on the placeholder) saying their earlier gifts are recorded under
|
||||||
|
their identity, with a link to their gifts.
|
||||||
|
|
||||||
|
`hydrateGive` / `hydrateOffer` only set agent/recipient when a DID is passed
|
||||||
|
and never clear them. That is correct for this flow (it only ever adds a
|
||||||
|
DID), but a test should pin it so a later "clear when blank" change does not
|
||||||
|
break assignment.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- Migration test (`src/test/migrations.test.ts`): 012 applies on a database
|
||||||
|
that already has contacts and labels, and leaves them unchanged.
|
||||||
|
- `createAndSubmitClaim` rejects a claim containing a `local:` key.
|
||||||
|
- `$insertContact` on an existing DID keeps the row's `id` and its labels,
|
||||||
|
with foreign keys both on and off.
|
||||||
|
- `rekeyContact`, with foreign keys both on and off: labels, claim rows,
|
||||||
|
and device links move to the real DID; the row's `id` is unchanged; when
|
||||||
|
a row for the real DID already existed, it is folded in and its labels
|
||||||
|
are kept.
|
||||||
|
- Registry coverage: every foreign key to `contacts(did)` and every
|
||||||
|
`contactKey` column in the migrated schema appears in
|
||||||
|
`CONTACT_KEYED_COLUMNS`.
|
||||||
|
- Each guard listed above returns early (or hides the control) for a
|
||||||
|
placeholder.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
### Make `contacts.did` nullable and key contacts by `contacts.id`
|
||||||
|
|
||||||
|
The relationally clean model: a contact without a DID is just a row with
|
||||||
|
`did IS NULL`.
|
||||||
|
|
||||||
|
- Requires rebuilding `contacts` (dropping `NOT NULL` is not possible with
|
||||||
|
`ALTER TABLE` in SQLite) and rebuilding `contact_labels` to reference
|
||||||
|
`contacts.id` — the same backup/drop/recreate pattern that made migration
|
||||||
|
008 fail on every startup for some users.
|
||||||
|
- `Contact.did` becomes optional, which breaks type-checking at every lookup,
|
||||||
|
route, and mixin method keyed by DID across roughly 70 files, and every
|
||||||
|
`WHERE did = ?` method needs an `id` variant.
|
||||||
|
- Gains nothing functional over the placeholder key: the key is a surrogate
|
||||||
|
ID that happens to live in the existing column.
|
||||||
|
- Its one real advantage is that linking a DID never rekeys child rows. That
|
||||||
|
advantage depends on `id` being stable, and it isn't: `INSERT OR REPLACE`
|
||||||
|
gives the row a new `id`, and contact export/import doesn't keep `id`, so
|
||||||
|
ids don't match across devices. The `rekeyContact` registry gets the same
|
||||||
|
safety without a table rebuild.
|
||||||
|
|
||||||
|
Worth revisiting only if contacts get rekeyed by `id` for some other reason.
|
||||||
|
|
||||||
|
### A separate, permanent `contactKey` column on `contacts`
|
||||||
|
|
||||||
|
Add `contacts.contactKey TEXT UNIQUE` that never changes (the `local:` key
|
||||||
|
for placeholders, the DID for everyone else), and key every child table by
|
||||||
|
it, so linking only sets `did`. It is additive for new tables, but
|
||||||
|
`contact_labels` references `contacts(did)`, so moving it to the new key is
|
||||||
|
a table rebuild. Every DID-keyed lookup would also need to decide which key
|
||||||
|
it means. It costs more than the rekey function it replaces.
|
||||||
|
|
||||||
|
### Separate `placeholder_people` table outside `contacts`
|
||||||
|
|
||||||
|
No risk to existing tables, but every picker and list (gift dialog entity
|
||||||
|
grid, contacts list, labels, search) must merge two sources of different
|
||||||
|
types, and labels need a second mapping table. The recommended option gets
|
||||||
|
those UIs for free because a placeholder is a contact.
|
||||||
|
|
||||||
|
### Fake DID written into the published claim
|
||||||
|
|
||||||
|
E.g. `did:none:<uuid>` or a random `did:ethr:` with no key behind it, sent
|
||||||
|
to the server as the agent/recipient.
|
||||||
|
|
||||||
|
- Publishes a dangling identifier that other users see as "Someone Visible
|
||||||
|
But Not In Your Contact List"; it never resolves.
|
||||||
|
- `did:none:` is the server's hidden-DID namespace (`did:none:HIDDEN`);
|
||||||
|
reusing the prefix risks the server treating it as hidden or rejecting it.
|
||||||
|
- The claim still has to be edited when the person joins, so it saves no
|
||||||
|
work; the only gain is finding claims by server search, which the local
|
||||||
|
table already provides.
|
||||||
|
|
||||||
|
### Custodial key: generate a real identity for the person
|
||||||
|
|
||||||
|
The app creates a real keypair for the placeholder and uses its DID in claims;
|
||||||
|
when the person joins, they import that key, so no claims need editing.
|
||||||
|
|
||||||
|
- The user's device holds another person's private key, and every claim
|
||||||
|
"about" them was made while someone else held their identity.
|
||||||
|
- The person may already have an identity, or may install on a device
|
||||||
|
without a way to receive the key; then the claims need editing anyway.
|
||||||
|
- Handing over a seed phrase is a security-sensitive step this app's
|
||||||
|
onboarding does not have.
|
||||||
|
|
||||||
|
### Put the person's name in the claim (`agent: { name: "..." }`)
|
||||||
|
|
||||||
|
- Publishes a name for someone who has not consented to being on the
|
||||||
|
platform; server visibility rules hide DIDs, not free-text names.
|
||||||
|
- Still needs the local record to assign the DID later.
|
||||||
|
- Server acceptance of an agent without `identifier` is unverified.
|
||||||
|
|
||||||
|
### Store linked claim IDs as a JSON column on `contacts`
|
||||||
|
|
||||||
|
An `ALTER TABLE contacts ADD COLUMN placeholderClaims TEXT` is also additive,
|
||||||
|
but role, issuer, server, and assignment status per claim fit a table
|
||||||
|
better, and any whole-row rewrite of the contact (such as `INSERT OR
|
||||||
|
REPLACE` from a stale copy) could overwrite the list.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Plan: Startup Database Failure — Diagnostics and Degraded Mode
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When the local database cannot be opened, a user should (1) get a useful
|
||||||
|
report to the developers with one tap, and (2) keep using whatever
|
||||||
|
parts of the app do not need local storage, with a clear path to recovery.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
1. A failure report reaches the developers without the user composing an email,
|
||||||
|
and it carries enough to identify the cause on the first report.
|
||||||
|
2. The report is sent only with the user's consent and holds no keys, seed
|
||||||
|
phrases, or contact data.
|
||||||
|
3. A user whose database is down can browse public content (feed, projects,
|
||||||
|
claims) instead of being held on the recovery screen.
|
||||||
|
4. Every action that needs the database or an identity is visibly disabled with
|
||||||
|
the real reason, and points to the recovery screen.
|
||||||
|
5. Recovery steps never delete the database without keeping a copy.
|
||||||
|
|
||||||
|
## Startup failure behavior
|
||||||
|
|
||||||
|
- `CapacitorPlatformService._initialize()`
|
||||||
|
(`src/services/platforms/CapacitorPlatformService.ts`) opens the database in
|
||||||
|
three steps: "create connection", "open database", "run migrations". A
|
||||||
|
failure throws `Failed to initialize database (step: <step>): <cause>`.
|
||||||
|
- `src/utils/startupError.ts` recognizes that error by its message and hands it
|
||||||
|
to `StartView` through `sessionStorage`.
|
||||||
|
- Three places catch it and redirect to `/start`: the router `errorHandler`
|
||||||
|
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
|
||||||
|
global error handler (`src/main.common.ts`).
|
||||||
|
- The guard calls `retrieveAccountDids()` for every route except the
|
||||||
|
identity-setup routes, so with the database down every route except
|
||||||
|
`/account`, `/test` and `/logs` lands on `/start`.
|
||||||
|
- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
|
||||||
|
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
|
||||||
|
(`resetDatabase()`, which copies the file aside before deleting it).
|
||||||
|
- API reads work without an identity: `getHeaders()`
|
||||||
|
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there
|
||||||
|
is no DID.
|
||||||
|
- Every path that creates, saves, or signs with an identity reads the `secret`
|
||||||
|
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
|
||||||
|
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
|
||||||
|
the database is down.
|
||||||
|
|
||||||
|
## Part A: One-tap diagnostics report
|
||||||
|
|
||||||
|
Contents of the report:
|
||||||
|
|
||||||
|
- the startup error message (step and cause) and stack
|
||||||
|
- app version, platform, OS version, device model
|
||||||
|
- Android: WebView version and installer package (`getInstallerPackageName`)
|
||||||
|
- whether a database file existed before the first connection attempt of this
|
||||||
|
process, which shows whether Android restored a backup at install time
|
||||||
|
- whether the SQLite plugin loaded (a plugin load failure surfaces as a
|
||||||
|
`CapacitorSQLitePlugin:` prefix on the cause)
|
||||||
|
- the `migrations` table contents, when the database opens far enough to read it
|
||||||
|
|
||||||
|
Delivery: a POST to an endpoint on a server the project runs. The recovery
|
||||||
|
screen shows the exact payload and a Send button; the email option stays for
|
||||||
|
users who decline.
|
||||||
|
|
||||||
|
The database-existed flag needs a native-side or `Filesystem.stat` check before
|
||||||
|
`createConnection`; `databaseDirectoryUri()` already resolves the directory on
|
||||||
|
Android and iOS.
|
||||||
|
|
||||||
|
## Part B: Degraded mode
|
||||||
|
|
||||||
|
- Change the router guard so that a database startup error records the error
|
||||||
|
and lets read-only routes through: home feed, projects, project and claim
|
||||||
|
views, and discovery. Identity-setup routes and anything that writes stay
|
||||||
|
redirected to `/start`.
|
||||||
|
- Show a persistent banner on every page while in degraded mode: "Your
|
||||||
|
device's storage isn't working. You can look around, but you can't act yet.
|
||||||
|
Fix it →", linking to the recovery screen.
|
||||||
|
- Disable buttons that need a DID or the database (give, offer, create project,
|
||||||
|
confirm, invite, contacts) with that same reason, instead of the
|
||||||
|
"someone must register you" text an unregistered user sees.
|
||||||
|
- Default `apiServer` in every settings fallback. `$accountSettings()`
|
||||||
|
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
|
||||||
|
the caller's `defaults` (often `{}`) on its early-return and error paths, so
|
||||||
|
`apiServer` can be undefined; `HomeView` works around this on its own.
|
||||||
|
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
|
||||||
|
Copy → Start Over.
|
||||||
|
|
||||||
|
## Part C (optional): An identity that does not need SQLite
|
||||||
|
|
||||||
|
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
|
||||||
|
In degraded mode the app could generate an identity, require the user to write
|
||||||
|
down the seed phrase, and keep the mnemonic in `@capacitor/preferences`
|
||||||
|
(installed, and independent of SQLite). The user would then have a DID someone
|
||||||
|
can register. When the database opens again, the app imports the identity from
|
||||||
|
Preferences and deletes the Preferences copy.
|
||||||
|
|
||||||
|
Trade-off: the mnemonic sits unencrypted in Preferences, about the same
|
||||||
|
protection as the database's secret stored next to the data it encrypts.
|
||||||
|
Worth building only if startup failures keep being reported with the plugin's
|
||||||
|
encryption setting (`androidIsEncryption: false`) in effect.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Which server and endpoint receive diagnostics, and how long reports are kept.
|
||||||
|
- Whether to quarantine a corrupt database automatically ("file is not a
|
||||||
|
database", "malformed") and start fresh. Automatic quarantine trades a
|
||||||
|
visible failure for a silent switch to a new identity, and some "malformed"
|
||||||
|
databases are recoverable with `REINDEX` or `.recover`; the manual Start Over
|
||||||
|
button covers this case.
|
||||||
|
- Whether Android backup should include `databases/`. Including it restores a
|
||||||
|
user's identity on a new phone; it also gives the Google backup a copy of the
|
||||||
|
keys together with the secret that decrypts them. The backup rules
|
||||||
|
(`android/app/src/main/res/xml/backup_rules.xml`,
|
||||||
|
`data_extraction_rules.xml`) include it.
|
||||||
|
|
||||||
|
## Rejected
|
||||||
|
|
||||||
|
- An IndexedDB (absurd-sql) database as a fallback inside the Capacitor
|
||||||
|
WebView: untested there, and two stores that both hold keys risk losing an
|
||||||
|
identity when they disagree.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] A1. Decide the diagnostics endpoint and payload retention.
|
||||||
|
- [ ] A2. Capture "database file existed before first connection" during startup.
|
||||||
|
- [ ] A3. Build the report payload and the Send Diagnostics button with preview.
|
||||||
|
- [ ] B1. Let read-only routes through the router guard on a database startup error.
|
||||||
|
- [ ] B2. Degraded-mode banner and disabled-action reasons.
|
||||||
|
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
|
||||||
|
- [ ] B4. Reorder the recovery screen.
|
||||||
|
- [ ] C1. (Optional) Preferences-backed identity with import on recovery.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Fix: Startup Database Error on Android
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
On some Android installs from the Play Store, the app showed a "Startup
|
||||||
|
Error" on every launch:
|
||||||
|
|
||||||
|
```
|
||||||
|
[CapacitorPlatformService] Failed to initialize database
|
||||||
|
```
|
||||||
|
|
||||||
|
Reinstalling did not help. The failure never appeared on developer devices
|
||||||
|
installed through adb or Android Studio. Full symptom report:
|
||||||
|
`startup-db-error-symptoms.md`.
|
||||||
|
|
||||||
|
## Causes
|
||||||
|
|
||||||
|
### 1. The SQLite plugin ignored its configuration (main cause)
|
||||||
|
|
||||||
|
`capacitor.config.ts` listed the SQLite settings under the key `CapSQLite`.
|
||||||
|
The plugin registers as `CapacitorSQLite` and reads only that key, so every
|
||||||
|
setting under `CapSQLite` was ignored and the plugin defaults applied:
|
||||||
|
|
||||||
|
- **Android:** `isEncryption` defaults to true, so on every launch the plugin
|
||||||
|
created an Android Keystore master key and opened an
|
||||||
|
`EncryptedSharedPreferences` file (`sqlite_encrypted_shared_prefs`) to hold a
|
||||||
|
database passphrase. If that step throws, the plugin fails to load and every
|
||||||
|
`createConnection` call is rejected.
|
||||||
|
- **iOS:** the database location defaults to `Documents`, not the configured
|
||||||
|
`Library/CapacitorDatabase`.
|
||||||
|
- **Electron:** the plugin reads `plugins.CapacitorSQLite` directly, so a
|
||||||
|
bundled config with the wrong key would throw at plugin startup.
|
||||||
|
|
||||||
|
The Keystore step fails on Android in two ways:
|
||||||
|
|
||||||
|
- **Restore without the key.** Android Auto Backup and device-to-device
|
||||||
|
transfer restore the encrypted preferences file, but Keystore keys never
|
||||||
|
leave the device. The restored file can't be decrypted, so the plugin fails on
|
||||||
|
every launch, and a reinstall from Play restores the same file again.
|
||||||
|
- **Device Keystore bugs.** Some manufacturers' Keystore implementations fail
|
||||||
|
without any restore.
|
||||||
|
|
||||||
|
The app never used plugin encryption: it opens the database with
|
||||||
|
`encrypted = false` and mode `"no-encryption"`, and never stores a passphrase.
|
||||||
|
The Keystore setup protected nothing.
|
||||||
|
|
||||||
|
### 2. A migration could be applied but not recorded
|
||||||
|
|
||||||
|
The migration runner ran each migration's SQL in one transaction and recorded
|
||||||
|
its name in the `migrations` table in a second one. If the app died between the
|
||||||
|
two, the migration ran again on the next launch. Migration 001's
|
||||||
|
`INSERT INTO secret (id, ...) VALUES (1, ...)` then failed with
|
||||||
|
`UNIQUE constraint failed: secret.id` on every launch. The window is
|
||||||
|
milliseconds, but once hit the failure is permanent, and Auto Backup carries it
|
||||||
|
to a reinstall.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|---|---|
|
||||||
|
| `capacitor.config.ts`, `electron/capacitor.config.ts`, `android/app/src/main/assets/capacitor.config.json` | Key renamed to `CapacitorSQLite`, so `androidIsEncryption: false` takes effect and the Keystore step never runs. `iosDatabaseLocation` set to `Documents`, the location every iOS install uses. |
|
||||||
|
| `capacitor.config.json`, `electron/capacitor.config.json` | `iosDatabaseLocation` set to `Documents` to match. These files already used the correct key. |
|
||||||
|
| `src/services/platforms/CapacitorPlatformService.ts` | `databaseDirectoryUri()` points at `Documents` on iOS, so "Save a Copy of My Data" and "Start Over" find the real database file. |
|
||||||
|
| `src/services/migrationService.ts` | Each migration's `INSERT INTO migrations` is appended to its own statement batch, which the native plugin runs as one transaction. |
|
||||||
|
| `src/db-sql/migration.ts` | Migration 001 uses `INSERT OR IGNORE` for the `secret` and `settings` rows, so a re-run never replaces the existing secret. |
|
||||||
|
| `android/app/src/main/AndroidManifest.xml`, `res/xml/backup_rules.xml`, `res/xml/data_extraction_rules.xml` | Cloud backup and device transfer exclude the plugin's encrypted preference files. Everything else is backed up, including the database. |
|
||||||
|
| `src/test/migrations.test.ts` | Tests for a crash right after a migration commits, and for a re-run of 001 keeping the secret. |
|
||||||
|
| `src/test/capacitorConfig.test.ts` | Fails if the plugin config key or the iOS location is wrong. |
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Encryption of user keys is unchanged. The identity and seed phrase are encrypted
|
||||||
|
by the app (`simpleEncrypt` with the random secret from migration 001) and stored
|
||||||
|
in `accounts`. The plugin's encryption flag only controlled whole-database
|
||||||
|
SQLCipher encryption, which the app never used; the database file was plain
|
||||||
|
SQLite before and after this change. App sandboxing keeps the file private to
|
||||||
|
the app on both platforms.
|
||||||
|
|
||||||
|
## Compatibility with existing installs
|
||||||
|
|
||||||
|
- **Android:** the database has no password whether plugin encryption is on or
|
||||||
|
off, so it opens the same way. The leftover encrypted preferences file is
|
||||||
|
never read again.
|
||||||
|
- **iOS:** the database stays in `Documents`. Setting the location back to
|
||||||
|
`Library/CapacitorDatabase` would not be safe: the plugin moves databases into
|
||||||
|
a custom location only when that folder does not exist yet, and installs from
|
||||||
|
between May and August 2025 have that folder with an outdated database in it.
|
||||||
|
- **Migrations:** existing installs already have 001 recorded, so its edit
|
||||||
|
never runs for them. The recording change affects only migrations that have
|
||||||
|
not run.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Reproduced before the fix** on an Android emulator: run the app once, copy
|
||||||
|
`shared_prefs` out with `run-as app.timesafari.app`, uninstall (which deletes
|
||||||
|
the Keystore key), reinstall, put the copied `shared_prefs` back, and launch.
|
||||||
|
The startup error appeared.
|
||||||
|
- **Unit tests:** `pkgx npm run test:unit -- src/test/migrations.test.ts
|
||||||
|
src/test/capacitorConfig.test.ts`. The two migration tests fail on the
|
||||||
|
earlier code and pass on the fixed code.
|
||||||
|
- **Android resources:** `./gradlew :app:processDebugResources` builds, and
|
||||||
|
the merged manifest carries both backup-rule attributes.
|
||||||
|
- **After the fix:** run the same emulator steps on a fixed build, and also
|
||||||
|
restore `shared_prefs` captured from a pre-fix build. The app should start
|
||||||
|
normally either way.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
The one-tap diagnostics report and a degraded mode for users without a working
|
||||||
|
database are planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
- 2025-05-27: `capacitor.config.json` set `iosDatabaseLocation` to
|
||||||
|
`Library/CapacitorDatabase` under the correct `CapacitorSQLite` key
|
||||||
|
(commit 7b4f9d65).
|
||||||
|
- 2025-08-14: the config moved to `capacitor.config.ts` under the key
|
||||||
|
`CapSQLite`, which the plugin does not read (commit a2840675). Plugin
|
||||||
|
encryption setup ran on Android from then on, and iOS databases moved to the
|
||||||
|
default `Documents` location.
|
||||||
|
- 2026-10-10: commit d6ccf0e4 added the failed step and underlying cause to the
|
||||||
|
startup error, made migration 008 tolerate duplicate contact DIDs and labels
|
||||||
|
pointing at deleted contacts, and added the recovery screen.
|
||||||
|
- 2026-10-10: this fix (config key, atomic migration records, backup rules).
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"splashImmersive": true
|
"splashImmersive": true
|
||||||
},
|
},
|
||||||
"CapacitorSQLite": {
|
"CapacitorSQLite": {
|
||||||
"iosDatabaseLocation": "Library/CapacitorDatabase",
|
"iosDatabaseLocation": "Documents",
|
||||||
"iosIsEncryption": false,
|
"iosIsEncryption": false,
|
||||||
"iosBiometric": {
|
"iosBiometric": {
|
||||||
"biometricAuth": false,
|
"biometricAuth": false,
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ const config: CapacitorConfig = {
|
|||||||
splashFullScreen: true,
|
splashFullScreen: true,
|
||||||
splashImmersive: true
|
splashImmersive: true
|
||||||
},
|
},
|
||||||
CapSQLite: {
|
// The key must match the plugin's registered name or every setting here is ignored.
|
||||||
iosDatabaseLocation: 'Library/CapacitorDatabase',
|
CapacitorSQLite: {
|
||||||
|
iosDatabaseLocation: 'Documents',
|
||||||
iosIsEncryption: false,
|
iosIsEncryption: false,
|
||||||
iosBiometric: {
|
iosBiometric: {
|
||||||
biometricAuth: false,
|
biometricAuth: false,
|
||||||
|
|||||||
@@ -581,7 +581,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/AppDebug.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 75;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -592,7 +592,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.9;
|
||||||
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)";
|
||||||
@@ -611,7 +611,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 75;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.9;
|
||||||
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 = "";
|
||||||
@@ -640,7 +640,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 75;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -654,7 +654,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.9;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
@@ -678,7 +678,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = TimeSafariShareExtension/TimeSafariShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 72;
|
CURRENT_PROJECT_VERSION = 75;
|
||||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -692,7 +692,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.4.6;
|
MARKETING_VERSION = 1.4.9;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari.TimeSafariShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "giftopia",
|
"name": "giftopia",
|
||||||
"version": "1.4.8-beta",
|
"version": "1.4.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "giftopia",
|
"name": "giftopia",
|
||||||
"version": "1.4.8-beta",
|
"version": "1.4.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/electron": "^5.0.1",
|
"@capacitor-community/electron": "^5.0.1",
|
||||||
"@capacitor-community/sqlite": "^7.0.3",
|
"@capacitor-community/sqlite": "^7.0.3",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "giftopia",
|
"name": "giftopia",
|
||||||
"version": "1.4.8-beta",
|
"version": "1.4.9",
|
||||||
"description": "Giftopia App",
|
"description": "Giftopia App",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Gift Economies Team"
|
"name": "Gift Economies Team"
|
||||||
@@ -110,6 +110,7 @@
|
|||||||
"clean:electron": "./scripts/build-electron.sh --clean",
|
"clean:electron": "./scripts/build-electron.sh --clean",
|
||||||
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
|
||||||
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
"deps:update-daily-notification-plugin": "npm install @timesafari/daily-notification-plugin@git+https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin.git#master",
|
||||||
|
"dev": "npm run build:web:dev",
|
||||||
"docker:down": "docker-compose down",
|
"docker:down": "docker-compose down",
|
||||||
"docker:logs": "docker-compose logs -f",
|
"docker:logs": "docker-compose logs -f",
|
||||||
"docker:up": "docker-compose up",
|
"docker:up": "docker-compose up",
|
||||||
|
|||||||
@@ -854,19 +854,19 @@ export default class ImageMethodDialog extends Vue {
|
|||||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||||
formData.append("claimType", this.claimType);
|
formData.append("claimType", this.claimType);
|
||||||
try {
|
try {
|
||||||
|
const imageServer =
|
||||||
|
(await this.$accountSettings()).imageServer || DEFAULT_IMAGE_API_SERVER;
|
||||||
if (
|
if (
|
||||||
window.location.hostname === "localhost" &&
|
window.location.hostname === "localhost" &&
|
||||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
!imageServer.includes("localhost")
|
||||||
) {
|
) {
|
||||||
logger.log(
|
logger.log(
|
||||||
"Using shared image API server, so only users on that server can play with images.",
|
"Using shared image API server, so only users on that server can play with images.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await axios.post(
|
const response = await axios.post(imageServer + "/image", formData, {
|
||||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
headers,
|
||||||
formData,
|
});
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
this.uploading = false;
|
this.uploading = false;
|
||||||
|
|
||||||
this.close();
|
this.close();
|
||||||
|
|||||||
@@ -556,19 +556,19 @@ export default class PhotoDialog extends Vue {
|
|||||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||||
formData.append("claimType", this.claimType);
|
formData.append("claimType", this.claimType);
|
||||||
try {
|
try {
|
||||||
|
const imageServer =
|
||||||
|
(await this.$accountSettings()).imageServer || DEFAULT_IMAGE_API_SERVER;
|
||||||
if (
|
if (
|
||||||
window.location.hostname === "localhost" &&
|
window.location.hostname === "localhost" &&
|
||||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
!imageServer.includes("localhost")
|
||||||
) {
|
) {
|
||||||
logger.log(
|
logger.log(
|
||||||
"Using shared image API server, so only users on that server can play with images.",
|
"Using shared image API server, so only users on that server can play with images.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await axios.post(
|
const response = await axios.post(imageServer + "/image", formData, {
|
||||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
headers,
|
||||||
formData,
|
});
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
this.uploading = false;
|
this.uploading = false;
|
||||||
|
|
||||||
this.close();
|
this.close();
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ export enum AppString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const APP_SERVER =
|
export const APP_SERVER =
|
||||||
import.meta.env.VITE_APP_SERVER || "https://timesafari.app";
|
import.meta.env.VITE_APP_SERVER || "https://giftopia.me";
|
||||||
|
|
||||||
|
export const ORG_SERVER =
|
||||||
|
import.meta.env.VITE_ORG_SERVER || "GiftEconomies.org";
|
||||||
|
|
||||||
export const DEFAULT_ENDORSER_API_SERVER =
|
export const DEFAULT_ENDORSER_API_SERVER =
|
||||||
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
import.meta.env.VITE_DEFAULT_ENDORSER_API_SERVER ||
|
||||||
@@ -65,7 +68,7 @@ export const IMAGE_TYPE_PROFILE = "profile";
|
|||||||
export function isNotProdServer(apiServer: string): boolean {
|
export function isNotProdServer(apiServer: string): boolean {
|
||||||
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
return apiServer !== AppString.PROD_ENDORSER_API_SERVER;
|
||||||
}
|
}
|
||||||
export const SUPPORT_EMAIL = "info@TimeSafari.app";
|
export const SUPPORT_EMAIL = "info@giftopia.me";
|
||||||
|
|
||||||
export const PASSKEYS_ENABLED =
|
export const PASSKEYS_ENABLED =
|
||||||
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
!!import.meta.env.VITE_PASSKEYS_ENABLED || false;
|
||||||
|
|||||||
@@ -1401,6 +1401,22 @@ export const NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR = {
|
|||||||
message: "There was an error deleting the image.",
|
message: "There was an error deleting the image.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Used in: AccountViewView.vue, GiftedDetailsView.vue, NewEditProjectView.vue
|
||||||
|
// (deleteImage - the image server returned 404). The server looks an image up
|
||||||
|
// by URL and uploader, so this also happens when the image was uploaded to a
|
||||||
|
// different image server than the one in settings.
|
||||||
|
export const NOTIFY_IMAGE_DELETE_NOT_FOUND = {
|
||||||
|
title: "Image Not Deleted",
|
||||||
|
message:
|
||||||
|
"The image server in your settings has no record of that image, so it was not removed from storage. It may already be gone, or it may have been uploaded through a different image server.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Used in: GiftedDetailsView.vue (deleteImage method - image deletion error)
|
||||||
|
export const NOTIFY_IMAGE_DELETE_ERROR = {
|
||||||
|
title: "Image Deletion Error",
|
||||||
|
message: "There was an error deleting the image from the image server.",
|
||||||
|
};
|
||||||
|
|
||||||
// Used in: NewEditProjectView.vue (validateLocation method - invalid location error)
|
// Used in: NewEditProjectView.vue (validateLocation method - invalid location error)
|
||||||
export const NOTIFY_PROJECT_INVALID_LOCATION = {
|
export const NOTIFY_PROJECT_INVALID_LOCATION = {
|
||||||
title: "Invalid Location",
|
title: "Invalid Location",
|
||||||
|
|||||||
+26
-7
@@ -108,7 +108,7 @@ const MIGRATIONS = [
|
|||||||
secretBase64 TEXT NOT NULL
|
secretBase64 TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
INSERT OR IGNORE INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -144,7 +144,7 @@ const MIGRATIONS = [
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
|
||||||
|
|
||||||
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
INSERT OR IGNORE INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -245,8 +245,11 @@ const MIGRATIONS = [
|
|||||||
registered BOOLEAN,
|
registered BOOLEAN,
|
||||||
hideTheirContent BOOLEAN DEFAULT 0
|
hideTheirContent BOOLEAN DEFAULT 0
|
||||||
);
|
);
|
||||||
|
-- Older versions could save the same DID more than once (nothing was unique on contacts.did),
|
||||||
|
-- which made the unique index below fail on every startup. Keep only the newest row per DID.
|
||||||
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
|
INSERT INTO _contacts_backup_008 (id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, hideTheirContent)
|
||||||
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts;
|
SELECT id, did, name, contactMethods, nextPubKeyHashB64, notes, profileImageUrl, publicKeyBase64, seesMe, registered, COALESCE(hideTheirContent, 0) FROM contacts
|
||||||
|
WHERE id IN (SELECT MAX(id) FROM contacts GROUP BY did);
|
||||||
|
|
||||||
DROP TABLE contacts;
|
DROP TABLE contacts;
|
||||||
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
|
ALTER TABLE _contacts_backup_008 RENAME TO contacts;
|
||||||
@@ -263,7 +266,9 @@ const MIGRATIONS = [
|
|||||||
);
|
);
|
||||||
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
|
CREATE INDEX idx_contact_labels_label ON contact_labels(label);
|
||||||
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
|
CREATE INDEX idx_contact_labels_did ON contact_labels(did);
|
||||||
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008;
|
-- The PRAGMA above is a no-op inside the migration's transaction, so foreign keys are enforced here:
|
||||||
|
-- skip any label whose contact no longer exists.
|
||||||
|
INSERT INTO contact_labels SELECT * FROM _contact_labels_backup_008 WHERE did IN (SELECT did FROM contacts);
|
||||||
DROP TABLE _contact_labels_backup_008;
|
DROP TABLE _contact_labels_backup_008;
|
||||||
|
|
||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
@@ -287,6 +292,13 @@ const MIGRATIONS = [
|
|||||||
ALTER TABLE settings ADD COLUMN smsNotificationsEnabled BOOLEAN DEFAULT FALSE;
|
ALTER TABLE settings ADD COLUMN smsNotificationsEnabled BOOLEAN DEFAULT FALSE;
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "011_add_imageServer_to_settings",
|
||||||
|
sql: `
|
||||||
|
-- Image API server URL; blank means DEFAULT_IMAGE_API_SERVER
|
||||||
|
ALTER TABLE settings ADD COLUMN imageServer TEXT;
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,6 +325,8 @@ function extractSingleValue<T>(result: T): string | number | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let migrationsRegistered = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param sqlExec - A function that executes a SQL statement and returns the result
|
* @param sqlExec - A function that executes a SQL statement and returns the result
|
||||||
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
|
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
|
||||||
@@ -324,9 +338,14 @@ export async function runMigrations<T>(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.debug("[Migration] Starting database migrations");
|
logger.debug("[Migration] Starting database migrations");
|
||||||
|
|
||||||
for (const migration of MIGRATIONS) {
|
// Register once: a retry after a failed initialization calls this again,
|
||||||
logger.debug("[Migration] Registering migration:", migration.name);
|
// and the registry rejects duplicate names.
|
||||||
registerMigration(migration);
|
if (!migrationsRegistered) {
|
||||||
|
for (const migration of MIGRATIONS) {
|
||||||
|
logger.debug("[Migration] Registering migration:", migration.name);
|
||||||
|
registerMigration(migration);
|
||||||
|
}
|
||||||
|
migrationsRegistered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug("[Migration] Running migration service");
|
logger.debug("[Migration] Running migration service");
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export type Settings = {
|
|||||||
hasBackedUpSeed?: boolean; // tracks whether the user has backed up their seed phrase
|
hasBackedUpSeed?: boolean; // tracks whether the user has backed up their seed phrase
|
||||||
hideRegisterPromptOnNewContact?: boolean;
|
hideRegisterPromptOnNewContact?: boolean;
|
||||||
isRegistered?: boolean;
|
isRegistered?: boolean;
|
||||||
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
|
imageServer?: string; // image API server URL
|
||||||
lastName?: string; // deprecated - put all names in firstName
|
lastName?: string; // deprecated - put all names in firstName
|
||||||
|
|
||||||
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
|
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface AccountSettings {
|
|||||||
notifyingReminderMessage?: string;
|
notifyingReminderMessage?: string;
|
||||||
notifyingReminderTime?: string;
|
notifyingReminderTime?: string;
|
||||||
starredPlanHandleIds?: string[];
|
starredPlanHandleIds?: string[];
|
||||||
|
imageServer?: string;
|
||||||
reminderFastRolloverForTesting?: boolean;
|
reminderFastRolloverForTesting?: boolean;
|
||||||
partnerApiServer?: string;
|
partnerApiServer?: string;
|
||||||
profileImageUrl?: string;
|
profileImageUrl?: string;
|
||||||
@@ -102,6 +103,8 @@ export interface SettingsState {
|
|||||||
activeDid: string;
|
activeDid: string;
|
||||||
apiServer: string;
|
apiServer: string;
|
||||||
apiServerInput: string;
|
apiServerInput: string;
|
||||||
|
imageServer: string;
|
||||||
|
imageServerInput: string;
|
||||||
partnerApiServer: string;
|
partnerApiServer: string;
|
||||||
partnerApiServerInput: string;
|
partnerApiServerInput: string;
|
||||||
webPushServer: string;
|
webPushServer: string;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import "./assets/styles/tailwind.css";
|
|||||||
import { FontAwesomeIcon } from "./libs/fontawesome";
|
import { FontAwesomeIcon } from "./libs/fontawesome";
|
||||||
import Camera from "simple-vue-camera";
|
import Camera from "simple-vue-camera";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
|
import {
|
||||||
|
isDatabaseStartupError,
|
||||||
|
recordStartupError,
|
||||||
|
} from "./utils/startupError";
|
||||||
|
|
||||||
// Global Error Handler
|
// Global Error Handler
|
||||||
function setupGlobalErrorHandler(app: VueApp) {
|
function setupGlobalErrorHandler(app: VueApp) {
|
||||||
@@ -22,6 +26,12 @@ function setupGlobalErrorHandler(app: VueApp) {
|
|||||||
info,
|
info,
|
||||||
component: instance?.$options.name || "unknown",
|
component: instance?.$options.name || "unknown",
|
||||||
});
|
});
|
||||||
|
if (isDatabaseStartupError(err)) {
|
||||||
|
// Nothing works without the database: show the recovery options instead.
|
||||||
|
recordStartupError(err);
|
||||||
|
router.replace("/start");
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert(
|
alert(
|
||||||
(err instanceof Error ? err.message : "Something bad happened") +
|
(err instanceof Error ? err.message : "Something bad happened") +
|
||||||
" - Try reloading or restarting the app.",
|
" - Try reloading or restarting the app.",
|
||||||
|
|||||||
+21
-25
@@ -6,11 +6,14 @@ import {
|
|||||||
RouteRecordRaw,
|
RouteRecordRaw,
|
||||||
} from "vue-router";
|
} from "vue-router";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import {
|
||||||
|
isDatabaseStartupError,
|
||||||
|
recordStartupError,
|
||||||
|
} from "../utils/startupError";
|
||||||
import {
|
import {
|
||||||
retrieveAccountDids,
|
retrieveAccountDids,
|
||||||
generateSaveAndActivateIdentity,
|
generateSaveAndActivateIdentity,
|
||||||
} from "../libs/util";
|
} from "../libs/util";
|
||||||
import { includeDevToolkitRoutes } from "../utils/includeDevToolkitRoutes";
|
|
||||||
|
|
||||||
const routes: Array<RouteRecordRaw> = [
|
const routes: Array<RouteRecordRaw> = [
|
||||||
{
|
{
|
||||||
@@ -92,6 +95,15 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
requiresAuth: false,
|
requiresAuth: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/dev/notifications",
|
||||||
|
name: "dev-notifications",
|
||||||
|
component: () => import("../views/dev/NotificationDebugView.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "Notification Debug",
|
||||||
|
requiresAuth: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/did/:did?",
|
path: "/did/:did?",
|
||||||
name: "did",
|
name: "did",
|
||||||
@@ -291,19 +303,6 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
name: "user-profile",
|
name: "user-profile",
|
||||||
component: () => import("../views/UserProfileView.vue"),
|
component: () => import("../views/UserProfileView.vue"),
|
||||||
},
|
},
|
||||||
...(includeDevToolkitRoutes
|
|
||||||
? ([
|
|
||||||
{
|
|
||||||
path: "/dev/notifications",
|
|
||||||
name: "dev-notifications",
|
|
||||||
component: () => import("../views/dev/NotificationDebugView.vue"),
|
|
||||||
meta: {
|
|
||||||
title: "Notification Debug",
|
|
||||||
requiresAuth: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] satisfies Array<RouteRecordRaw>)
|
|
||||||
: []),
|
|
||||||
// Catch-all route for 404 errors - must be last
|
// Catch-all route for 404 errors - must be last
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
@@ -342,6 +341,12 @@ const errorHandler = (
|
|||||||
) => {
|
) => {
|
||||||
// Handle the error here
|
// Handle the error here
|
||||||
logger.error("Caught in top level error handler:", error, to, from);
|
logger.error("Caught in top level error handler:", error, to, from);
|
||||||
|
if (isDatabaseStartupError(error)) {
|
||||||
|
// Nothing works without the database: show the recovery options instead.
|
||||||
|
recordStartupError(error);
|
||||||
|
router.replace("/start");
|
||||||
|
return;
|
||||||
|
}
|
||||||
alert("Something is very wrong. Try reloading or restarting the app.");
|
alert("Something is very wrong. Try reloading or restarting the app.");
|
||||||
|
|
||||||
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
// You can also perform additional actions, such as displaying an error message or redirecting the user to a specific page
|
||||||
@@ -426,17 +431,8 @@ router.beforeEach(async (to, _from, next) => {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store error details so StartView can display them
|
// Store error details so StartView can display them with recovery options
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
recordStartupError(error);
|
||||||
const errorStack = error instanceof Error ? error.stack || "" : "";
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(
|
|
||||||
"startupError",
|
|
||||||
JSON.stringify({ message: errorMessage, stack: errorStack }),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// sessionStorage may be unavailable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep diagnostic pages reachable when identity creation fails (e.g. a
|
// Keep diagnostic pages reachable when identity creation fails (e.g. a
|
||||||
// failed migration leaves every DB call throwing), so the user can open
|
// failed migration leaves every DB call throwing), so the user can open
|
||||||
|
|||||||
@@ -209,6 +209,23 @@ export interface PlatformService {
|
|||||||
*/
|
*/
|
||||||
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
|
retrieveSettingsForActiveAccount(): Promise<Record<string, unknown> | null>;
|
||||||
|
|
||||||
|
// --- Startup-failure recovery (optional, only implemented on native mobile) ---
|
||||||
|
/**
|
||||||
|
* Opens the share sheet with a copy of the raw database file, so a user
|
||||||
|
* whose database cannot be opened can still save it or send it to support.
|
||||||
|
* It contains their keys, so the UI must say so.
|
||||||
|
*/
|
||||||
|
shareDatabaseFile?(): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the database file aside (into the app's files directory) and then
|
||||||
|
* deletes it, so the next start builds a fresh database. The caller is
|
||||||
|
* expected to reload the app afterward.
|
||||||
|
* @returns the backup file's URI, or undefined when there was no file to back up
|
||||||
|
* @throws if the backup copy fails (the original is never deleted without one)
|
||||||
|
*/
|
||||||
|
resetDatabase?(): Promise<string | undefined>;
|
||||||
|
|
||||||
// --- PWA/Web-only methods (optional, only implemented on web) ---
|
// --- PWA/Web-only methods (optional, only implemented on web) ---
|
||||||
/**
|
/**
|
||||||
* Registers the service worker for PWA support (web only)
|
* Registers the service worker for PWA support (web only)
|
||||||
|
|||||||
@@ -153,6 +153,17 @@ class MigrationRegistry {
|
|||||||
// Create a singleton instance of the migration registry
|
// Create a singleton instance of the migration registry
|
||||||
const migrationRegistry = new MigrationRegistry();
|
const migrationRegistry = new MigrationRegistry();
|
||||||
|
|
||||||
|
/** The migration's SQL followed by the statement that records it as applied. */
|
||||||
|
function withMigrationRecord(migration: Migration): string {
|
||||||
|
// A trailing "--" comment ends at its newline, so check what's left without comments.
|
||||||
|
const terminated = migration.sql
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.trim()
|
||||||
|
.endsWith(";");
|
||||||
|
const name = migration.name.replace(/'/g, "''");
|
||||||
|
return `${migration.sql}\n${terminated ? "" : ";\n"}INSERT INTO migrations (name) VALUES ('${name}');\n`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a migration with the migration service
|
* Register a migration with the migration service
|
||||||
*
|
*
|
||||||
@@ -282,19 +293,15 @@ export async function runMigrations<T>(
|
|||||||
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
logger.debug(`[Migration] Executing SQL for: ${migration.name}`);
|
||||||
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
logger.debug(`[Migration] SQL content: ${migration.sql}`);
|
||||||
|
|
||||||
// Execute the migration SQL directly - it should be atomic
|
// The record goes in the same batch as the migration, which the native
|
||||||
// The SQL itself should handle any necessary transactions
|
// plugin runs as one transaction: if the app dies between the two, a
|
||||||
const execResult = await sqlExec(migration.sql);
|
// migration that can't run twice (like 001) would fail every launch.
|
||||||
|
const execResult = await sqlExec(withMigrationRecord(migration));
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
`[Migration] SQL execution result: ${JSON.stringify(execResult)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record that the migration was applied
|
|
||||||
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
|
|
||||||
migration.name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
logger.debug(`✅ [Migration] Successfully applied: ${migration.name}`);
|
||||||
appliedCount++;
|
appliedCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -21,9 +21,17 @@ import {
|
|||||||
PlatformService,
|
PlatformService,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger, safeStringify } from "../../utils/logger";
|
||||||
import { BaseDatabaseService } from "./BaseDatabaseService";
|
import { BaseDatabaseService } from "./BaseDatabaseService";
|
||||||
|
|
||||||
|
/** Readable one-line description of anything thrown, including plugin rejections. */
|
||||||
|
function describeError(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
return typeof error === "string" ? error : safeStringify(error);
|
||||||
|
}
|
||||||
|
|
||||||
interface QueuedOperation {
|
interface QueuedOperation {
|
||||||
type: "run" | "query" | "rawQuery";
|
type: "run" | "query" | "rawQuery";
|
||||||
sql: string;
|
sql: string;
|
||||||
@@ -54,6 +62,8 @@ export class CapacitorPlatformService
|
|||||||
private initializationPromise: Promise<void> | null = null;
|
private initializationPromise: Promise<void> | null = null;
|
||||||
private operationQueue: Array<QueuedOperation> = [];
|
private operationQueue: Array<QueuedOperation> = [];
|
||||||
private isProcessingQueue: boolean = false;
|
private isProcessingQueue: boolean = false;
|
||||||
|
/** Set by resetDatabase so a stray query cannot recreate the file mid-reset. */
|
||||||
|
private resetInProgress = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -71,6 +81,12 @@ export class CapacitorPlatformService
|
|||||||
return this.initializationPromise;
|
return this.initializationPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.resetInProgress) {
|
||||||
|
throw new Error(
|
||||||
|
"[CapacitorPlatformService] The database is being reset; restart the app.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Start initialization
|
// Start initialization
|
||||||
this.initializationPromise = this._initialize();
|
this.initializationPromise = this._initialize();
|
||||||
@@ -90,6 +106,8 @@ export class CapacitorPlatformService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which step failed is the first thing support needs to know.
|
||||||
|
let step = "create connection";
|
||||||
try {
|
try {
|
||||||
// Try to create/Open database connection
|
// Try to create/Open database connection
|
||||||
try {
|
try {
|
||||||
@@ -164,6 +182,7 @@ export class CapacitorPlatformService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open the connection if it's not already open
|
// Open the connection if it's not already open
|
||||||
|
step = "open database";
|
||||||
try {
|
try {
|
||||||
await this.db.open();
|
await this.db.open();
|
||||||
} catch (openError: unknown) {
|
} catch (openError: unknown) {
|
||||||
@@ -182,6 +201,7 @@ export class CapacitorPlatformService
|
|||||||
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
// await this.db.execute("PRAGMA journal_mode=WAL;");
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
|
step = "run migrations";
|
||||||
await this.runCapacitorMigrations();
|
await this.runCapacitorMigrations();
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
@@ -196,8 +216,9 @@ export class CapacitorPlatformService
|
|||||||
"[CapacitorPlatformService] Error initializing SQLite database:",
|
"[CapacitorPlatformService] Error initializing SQLite database:",
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
// Keep the underlying cause in the message: it is all a user can send us.
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"[CapacitorPlatformService] Failed to initialize database",
|
`[CapacitorPlatformService] Failed to initialize database (step: ${step}): ${describeError(error)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,6 +441,119 @@ export class CapacitorPlatformService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** File name the SQLite plugin stores this.dbName under (it appends "SQLite.db"). */
|
||||||
|
private get databaseFileName(): string {
|
||||||
|
return `${this.dbName}SQLite.db`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URI of the directory where the SQLite plugin keeps its database files. */
|
||||||
|
private async databaseDirectoryUri(): Promise<string> {
|
||||||
|
const platform = Capacitor.getPlatform();
|
||||||
|
if (platform === "android") {
|
||||||
|
// The plugin uses Context.getDatabasePath(), the "databases" sibling of
|
||||||
|
// getFilesDir() (which is Directory.Data).
|
||||||
|
const { uri } = await Filesystem.getUri({
|
||||||
|
path: "",
|
||||||
|
directory: Directory.Data,
|
||||||
|
});
|
||||||
|
if (!/\/files\/?$/.test(uri)) {
|
||||||
|
throw new Error(`Unexpected app data directory: ${uri}`);
|
||||||
|
}
|
||||||
|
return uri.replace(/\/files\/?$/, "/databases");
|
||||||
|
}
|
||||||
|
if (platform === "ios") {
|
||||||
|
// Matches iosDatabaseLocation "Documents" in capacitor.config
|
||||||
|
const { uri } = await Filesystem.getUri({
|
||||||
|
path: "",
|
||||||
|
directory: Directory.Documents,
|
||||||
|
});
|
||||||
|
return uri.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
throw new Error(`Database file access is not supported on ${platform}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async databaseFileExists(fileUri: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await Filesystem.stat({ path: fileUri });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shareDatabaseFile(): Promise<void> {
|
||||||
|
const source = `${await this.databaseDirectoryUri()}/${this.databaseFileName}`;
|
||||||
|
if (!(await this.databaseFileExists(source))) {
|
||||||
|
throw new Error("There is no database file on this device.");
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const { uri } = await Filesystem.copy({
|
||||||
|
from: source,
|
||||||
|
to: `timesafari-database-${stamp}.sqlite`,
|
||||||
|
toDirectory: Directory.Cache,
|
||||||
|
});
|
||||||
|
await Share.share({
|
||||||
|
title: "Time Safari database",
|
||||||
|
url: uri,
|
||||||
|
dialogTitle: "Save or send your database file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetDatabase(): Promise<string | undefined> {
|
||||||
|
this.resetInProgress = true;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await this.sqlite.closeConnection(this.dbName, false);
|
||||||
|
} catch (closeError) {
|
||||||
|
// Expected when the connection was never created
|
||||||
|
logger.debug(
|
||||||
|
"[CapacitorPlatformService] Close before reset failed (may be expected):",
|
||||||
|
closeError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.db = null;
|
||||||
|
this.initialized = false;
|
||||||
|
this.initializationPromise = null;
|
||||||
|
|
||||||
|
const source = `${await this.databaseDirectoryUri()}/${this.databaseFileName}`;
|
||||||
|
let backupUri: string | undefined;
|
||||||
|
if (await this.databaseFileExists(source)) {
|
||||||
|
// Never delete without a copy: this file may hold the only copy of the user's keys.
|
||||||
|
const backupDir = "database-backups";
|
||||||
|
try {
|
||||||
|
await Filesystem.mkdir({
|
||||||
|
path: backupDir,
|
||||||
|
directory: Directory.Data,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Already exists
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const { uri } = await Filesystem.copy({
|
||||||
|
from: source,
|
||||||
|
to: `${backupDir}/${this.databaseFileName}-${stamp}`,
|
||||||
|
toDirectory: Directory.Data,
|
||||||
|
});
|
||||||
|
backupUri = uri;
|
||||||
|
await Filesystem.deleteFile({ path: source });
|
||||||
|
}
|
||||||
|
for (const suffix of ["-journal", "-wal", "-shm"]) {
|
||||||
|
if (await this.databaseFileExists(source + suffix)) {
|
||||||
|
await Filesystem.deleteFile({ path: source + suffix });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.warn(
|
||||||
|
`[CapacitorPlatformService] Database reset; backup at ${backupUri ?? "(no file existed)"}`,
|
||||||
|
);
|
||||||
|
// resetInProgress stays set: the caller reloads the app.
|
||||||
|
return backupUri;
|
||||||
|
} catch (error) {
|
||||||
|
this.resetInProgress = false;
|
||||||
|
throw new Error(`Could not reset the database: ${describeError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if an object is a Proxy object that cannot be serialized
|
* Detect if an object is a Proxy object that cannot be serialized
|
||||||
* Proxy objects cause "An object could not be cloned" errors in Capacitor
|
* Proxy objects cause "An object could not be cloned" errors in Capacitor
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
PlatformService,
|
PlatformService,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
} from "../PlatformService";
|
} from "../PlatformService";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger, safeStringify } from "../../utils/logger";
|
||||||
import { QueryExecResult } from "@/interfaces/database";
|
import { QueryExecResult } from "@/interfaces/database";
|
||||||
import { BaseDatabaseService } from "./BaseDatabaseService";
|
import { BaseDatabaseService } from "./BaseDatabaseService";
|
||||||
// Dynamic import of initBackend to prevent worker context errors
|
// Dynamic import of initBackend to prevent worker context errors
|
||||||
@@ -123,7 +123,9 @@ export class WebPlatformService
|
|||||||
logger.error("[WebPlatformService] Failed to initialize worker:", error);
|
logger.error("[WebPlatformService] Failed to initialize worker:", error);
|
||||||
this.workerReady = false;
|
this.workerReady = false;
|
||||||
this.workerInitPromise = null;
|
this.workerInitPromise = null;
|
||||||
throw new Error("Failed to initialize database worker");
|
throw new Error(
|
||||||
|
`Failed to initialize database worker: ${error instanceof Error ? String(error) : safeStringify(error)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* The SQLite plugin reads its settings only under its registered name,
|
||||||
|
* "CapacitorSQLite". Under any other key the settings are silently ignored and
|
||||||
|
* the plugin defaults apply, which on Android means Keystore-backed encryption
|
||||||
|
* setup that can fail on every launch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
const repo = join(__dirname, "..", "..");
|
||||||
|
|
||||||
|
function read(relativePath: string): string {
|
||||||
|
return readFileSync(join(repo, relativePath), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SQLite plugin config", () => {
|
||||||
|
it.each(["capacitor.config.ts", "electron/capacitor.config.ts"])(
|
||||||
|
"%s uses the plugin's registered name",
|
||||||
|
(file) => {
|
||||||
|
const source = read(file);
|
||||||
|
expect(source).toMatch(/^\s*CapacitorSQLite:\s*\{/m);
|
||||||
|
expect(source).toMatch(/androidIsEncryption:\s*false/);
|
||||||
|
expect(source).toMatch(/iosDatabaseLocation:\s*'Documents'/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("the synced Android config uses the plugin's registered name", () => {
|
||||||
|
const config = JSON.parse(
|
||||||
|
read("android/app/src/main/assets/capacitor.config.json"),
|
||||||
|
);
|
||||||
|
expect(config.plugins.CapacitorSQLite.androidIsEncryption).toBe(false);
|
||||||
|
expect(config.plugins.CapacitorSQLite.iosDatabaseLocation).toBe(
|
||||||
|
"Documents",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Runs the real migrations against sql.js, executing multi-statement SQL the
|
||||||
|
* way CapacitorPlatformService does: "--" comments stripped, split on ";",
|
||||||
|
* and run in one transaction with foreign keys enforced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
jest.mock("@/utils/logger", () => ({
|
||||||
|
logger: {
|
||||||
|
debug: jest.fn(),
|
||||||
|
log: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
jest.mock("@/constants/app", () => ({
|
||||||
|
DEFAULT_ENDORSER_API_SERVER: "https://api.example",
|
||||||
|
}));
|
||||||
|
jest.mock("@/libs/crypto", () => ({
|
||||||
|
arrayBufferToBase64: (buffer: ArrayBuffer) =>
|
||||||
|
Buffer.from(buffer).toString("base64"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { runMigrations } from "@/db-sql/migration";
|
||||||
|
|
||||||
|
// sql.js has no type declarations installed here
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const initSqlJs = require("sql.js");
|
||||||
|
|
||||||
|
type QueryResult = Array<{ columns: string[]; values: unknown[][] }>;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type SqlJsDatabase = any;
|
||||||
|
|
||||||
|
const MIGRATION_008 = "008_remove_iViewContent_from_contacts";
|
||||||
|
|
||||||
|
async function newDatabase(): Promise<SqlJsDatabase> {
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
const db = new SQL.Database();
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param afterExec runs after each successful sqlExec, e.g. to simulate the app dying */
|
||||||
|
async function migrate(
|
||||||
|
db: SqlJsDatabase,
|
||||||
|
afterExec?: (sql: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const rawExec = async (sql: string, params?: unknown[]): Promise<void> => {
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
db.run(sql, params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const statements = sql
|
||||||
|
.replace(/--[^\n]*/g, "")
|
||||||
|
.split(";")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s);
|
||||||
|
if (statements.length > 1) {
|
||||||
|
// Like the plugin's executeSet: all or nothing
|
||||||
|
db.run("BEGIN");
|
||||||
|
try {
|
||||||
|
statements.forEach((statement) => db.run(statement));
|
||||||
|
db.run("COMMIT");
|
||||||
|
} catch (error) {
|
||||||
|
db.run("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db.run(sql);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sqlExec = async (sql: string, params?: unknown[]): Promise<void> => {
|
||||||
|
await rawExec(sql, params);
|
||||||
|
afterExec?.(sql);
|
||||||
|
};
|
||||||
|
const sqlQuery = async (
|
||||||
|
sql: string,
|
||||||
|
params?: unknown[],
|
||||||
|
): Promise<QueryResult> => db.exec(sql, params);
|
||||||
|
const extractMigrationNames = (result: QueryResult): Set<string> =>
|
||||||
|
new Set((result[0]?.values || []).map((row) => row[0] as string));
|
||||||
|
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows(db: SqlJsDatabase, sql: string): unknown[][] {
|
||||||
|
return (db.exec(sql) as QueryResult)[0]?.values || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a fully migrated database back into the pre-008 shape that older app
|
||||||
|
* versions left behind: no unique index on contacts.did, so duplicates and
|
||||||
|
* dangling labels can exist.
|
||||||
|
*/
|
||||||
|
function revertTo007(db: SqlJsDatabase): void {
|
||||||
|
db.run("PRAGMA foreign_keys = OFF");
|
||||||
|
db.run("DROP INDEX idx_contacts_did");
|
||||||
|
db.run("CREATE INDEX idx_contacts_did ON contacts(did)");
|
||||||
|
db.run(`DELETE FROM migrations WHERE name = '${MIGRATION_008}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("migrations", () => {
|
||||||
|
it("migrates a fresh database", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
expect(
|
||||||
|
rows(db, `SELECT name FROM migrations WHERE name = '${MIGRATION_008}'`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives the app dying right after a migration commits", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await expect(
|
||||||
|
migrate(db, (sql) => {
|
||||||
|
if (sql.includes("CREATE TABLE IF NOT EXISTS accounts")) {
|
||||||
|
throw new Error("app killed");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("app killed");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
const clean = await newDatabase();
|
||||||
|
await migrate(clean);
|
||||||
|
const names = "SELECT name FROM migrations ORDER BY name";
|
||||||
|
expect(rows(db, names)).toEqual(rows(clean, names));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-running 001 keeps the existing secret", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
const secret = rows(db, "SELECT secretBase64 FROM secret");
|
||||||
|
db.run("DELETE FROM migrations WHERE name = '001_initial'");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
expect(rows(db, "SELECT secretBase64 FROM secret")).toEqual(secret);
|
||||||
|
expect(
|
||||||
|
rows(db, "SELECT name FROM migrations WHERE name = '001_initial'"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("008 keeps the newest contact when a DID was saved twice", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
revertTo007(db);
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO contacts (did, name) VALUES ('did:a', 'A old'), ('did:b', 'B'), ('did:a', 'A new')",
|
||||||
|
);
|
||||||
|
db.run("INSERT INTO contact_labels VALUES ('did:a', 'friends')");
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
expect(rows(db, "SELECT did, name FROM contacts ORDER BY did")).toEqual([
|
||||||
|
["did:a", "A new"],
|
||||||
|
["did:b", "B"],
|
||||||
|
]);
|
||||||
|
expect(rows(db, "SELECT did, label FROM contact_labels")).toEqual([
|
||||||
|
["did:a", "friends"],
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
rows(db, `SELECT name FROM migrations WHERE name = '${MIGRATION_008}'`),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("008 drops labels whose contact no longer exists", async () => {
|
||||||
|
const db = await newDatabase();
|
||||||
|
await migrate(db);
|
||||||
|
revertTo007(db);
|
||||||
|
db.run("INSERT INTO contacts (did, name) VALUES ('did:a', 'A')");
|
||||||
|
db.run(
|
||||||
|
"INSERT INTO contact_labels VALUES ('did:a', 'friends'), ('did:gone', 'orphan')",
|
||||||
|
);
|
||||||
|
db.run("PRAGMA foreign_keys = ON");
|
||||||
|
|
||||||
|
await migrate(db);
|
||||||
|
|
||||||
|
expect(rows(db, "SELECT did, label FROM contact_labels")).toEqual([
|
||||||
|
["did:a", "friends"],
|
||||||
|
]);
|
||||||
|
expect(rows(db, "PRAGMA foreign_key_check")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/**
|
|
||||||
* True for `vite dev` and for `vite build` when mode is not `production`
|
|
||||||
* (e.g. `--mode capacitor`, `--mode test`). Use for dev-only routes and UI.
|
|
||||||
*/
|
|
||||||
export const includeDevToolkitRoutes =
|
|
||||||
import.meta.env.DEV || import.meta.env.MODE !== "production";
|
|
||||||
+4
-2
@@ -219,9 +219,10 @@ export const logger = {
|
|||||||
console.warn(message, ...args);
|
console.warn(message, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database logging
|
// Database logging, plus memory so problems can be shown even when the database is unusable
|
||||||
const argsString =
|
const argsString =
|
||||||
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
||||||
|
appendToMemoryLogs(`[WARN] ${message}${argsString}`);
|
||||||
logToDatabase(message + argsString, "warn");
|
logToDatabase(message + argsString, "warn");
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -231,9 +232,10 @@ export const logger = {
|
|||||||
console.error(message, ...args);
|
console.error(message, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database logging
|
// Database logging, plus memory so problems can be shown even when the database is unusable
|
||||||
const argsString =
|
const argsString =
|
||||||
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
args.length > 0 ? " - " + args.map(safeStringify).join(", ") : "";
|
||||||
|
appendToMemoryLogs(`[ERROR] ${message}${argsString}`);
|
||||||
logToDatabase(message + argsString, "error");
|
logToDatabase(message + argsString, "error");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Hand-off of a fatal startup error (usually a database that will not open)
|
||||||
|
* from wherever it is caught to StartView, which shows recovery options.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "startupError";
|
||||||
|
|
||||||
|
export interface StartupError {
|
||||||
|
message: string;
|
||||||
|
stack: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the errors thrown when the platform database cannot be opened. */
|
||||||
|
export function isDatabaseStartupError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes("Failed to initialize database") ||
|
||||||
|
message.includes("The database could not be initialized") ||
|
||||||
|
message.includes("The database is being reset")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordStartupError(error: unknown): void {
|
||||||
|
const value: StartupError = {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack || "" : "",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// sessionStorage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the recorded error, if any, and clears it. */
|
||||||
|
export function takeStartupError(): StartupError | null {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return { message: parsed.message || raw, stack: parsed.stack || "" };
|
||||||
|
} catch {
|
||||||
|
return { message: raw, stack: "" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,9 +168,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Channel 1: In-App notification -->
|
<!-- Channel 1: In-App notification (only with general advanced functions on) -->
|
||||||
<div
|
<div
|
||||||
v-if="isDev"
|
v-if="showGeneralAdvanced"
|
||||||
class="flex items-center justify-between mt-3 mb-2 pl-1"
|
class="flex items-center justify-between mt-3 mb-2 pl-1"
|
||||||
>
|
>
|
||||||
<!-- label -->
|
<!-- label -->
|
||||||
@@ -205,12 +205,15 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isDev" class="mb-2 pl-1">
|
<div v-if="showGeneralAdvanced" class="mb-2 pl-1">
|
||||||
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
||||||
Troubleshoot your in-app notifications…
|
Troubleshoot your in-app notifications…
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="notifyingNewActivity && isDev" class="w-full pl-1">
|
<div
|
||||||
|
v-if="notifyingNewActivity && showGeneralAdvanced"
|
||||||
|
class="w-full pl-1"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
class="text-sm text-slate-500 mb-2 bg-white rounded px-3 py-2 border border-slate-200"
|
||||||
>
|
>
|
||||||
@@ -635,6 +638,7 @@
|
|||||||
color="white"
|
color="white"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></font-awesome>
|
></font-awesome>
|
||||||
|
<span class="text-white">Unsavedaroonosity</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="mt-2" role="group" aria-label="Quick server selection">
|
<div class="mt-2" role="group" aria-label="Quick server selection">
|
||||||
<button
|
<button
|
||||||
@@ -732,6 +736,7 @@
|
|||||||
class="fa-fw"
|
class="fa-fw"
|
||||||
color="white"
|
color="white"
|
||||||
></font-awesome>
|
></font-awesome>
|
||||||
|
<span class="text-white">Unsaved</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
@@ -778,6 +783,7 @@
|
|||||||
class="fa-fw"
|
class="fa-fw"
|
||||||
color="white"
|
color="white"
|
||||||
></font-awesome>
|
></font-awesome>
|
||||||
|
<span class="text-white">Unsaved</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
@@ -810,11 +816,49 @@
|
|||||||
{{ DEFAULT_PARTNER_API_SERVER }}
|
{{ DEFAULT_PARTNER_API_SERVER }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="mt-2">
|
<h2 class="text-slate-500 text-sm font-bold mb-2">Image Server URL</h2>
|
||||||
<span class="text-slate-500 text-sm font-bold">Image Server URL</span>
|
<div class="px-3 py-4">
|
||||||
|
<input
|
||||||
<span class="text-sm">{{ DEFAULT_IMAGE_API_SERVER }}</span>
|
v-model="imageServerInput"
|
||||||
|
type="text"
|
||||||
|
class="block w-full rounded border border-slate-400 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="imageServerInput != imageServer"
|
||||||
|
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
||||||
|
@click="onClickSaveImageServer()"
|
||||||
|
>
|
||||||
|
<font-awesome
|
||||||
|
icon="floppy-disk"
|
||||||
|
class="fa-fw"
|
||||||
|
color="white"
|
||||||
|
></font-awesome>
|
||||||
|
<span class="text-white">Unsaved</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
|
@click="imageServerInput = AppConstants.PROD_IMAGE_API_SERVER"
|
||||||
|
>
|
||||||
|
Use Prod
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
|
@click="imageServerInput = AppConstants.TEST_IMAGE_API_SERVER"
|
||||||
|
>
|
||||||
|
Use Test
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||||
|
@click="imageServerInput = AppConstants.LOCAL_IMAGE_API_SERVER"
|
||||||
|
>
|
||||||
|
Use Local
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-if="!imageServerInput" class="px-4 text-sm">
|
||||||
|
When that setting is blank, this app will use the default image server
|
||||||
|
URL:
|
||||||
|
{{ DEFAULT_IMAGE_API_SERVER }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-slate-500 text-sm font-bold"
|
<span class="text-slate-500 text-sm font-bold"
|
||||||
@@ -939,10 +983,7 @@
|
|||||||
Test Page
|
Test Page
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<!-- Non-production bundles only; route `dev-notifications` must exist
|
|
||||||
(see `includeDevToolkitRoutes`). -->
|
|
||||||
<router-link
|
<router-link
|
||||||
v-if="isDev"
|
|
||||||
:to="{ name: 'dev-notifications' }"
|
:to="{ name: 'dev-notifications' }"
|
||||||
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
||||||
>
|
>
|
||||||
@@ -1025,8 +1066,8 @@ import { logger } from "../utils/logger";
|
|||||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
|
||||||
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
|
||||||
|
import { NOTIFY_IMAGE_DELETE_NOT_FOUND } from "@/constants/notifications";
|
||||||
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
|
||||||
import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
|
|
||||||
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
import { AccountSettings, isApiError } from "@/interfaces/accountView";
|
||||||
import {
|
import {
|
||||||
NotificationService,
|
NotificationService,
|
||||||
@@ -1105,7 +1146,6 @@ export default class AccountViewView extends Vue {
|
|||||||
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
|
readonly DEFAULT_IMAGE_API_SERVER: string = DEFAULT_IMAGE_API_SERVER;
|
||||||
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
|
readonly DEFAULT_PARTNER_API_SERVER: string = DEFAULT_PARTNER_API_SERVER;
|
||||||
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
|
readonly PASSKEYS_ENABLED: boolean = PASSKEYS_ENABLED;
|
||||||
readonly isDev: boolean = includeDevToolkitRoutes;
|
|
||||||
|
|
||||||
// Server URLs section starts collapsed
|
// Server URLs section starts collapsed
|
||||||
showServerSettings: boolean = false;
|
showServerSettings: boolean = false;
|
||||||
@@ -1124,6 +1164,8 @@ export default class AccountViewView extends Vue {
|
|||||||
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
searchBox: { name: string; bbox: BoundingBox } | null = null;
|
||||||
partnerApiServer: string = DEFAULT_PARTNER_API_SERVER;
|
partnerApiServer: string = DEFAULT_PARTNER_API_SERVER;
|
||||||
partnerApiServerInput: string = DEFAULT_PARTNER_API_SERVER;
|
partnerApiServerInput: string = DEFAULT_PARTNER_API_SERVER;
|
||||||
|
imageServer: string = DEFAULT_IMAGE_API_SERVER;
|
||||||
|
imageServerInput: string = DEFAULT_IMAGE_API_SERVER;
|
||||||
passkeyExpirationDescription: string = "";
|
passkeyExpirationDescription: string = "";
|
||||||
passkeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
passkeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
||||||
previousPasskeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
previousPasskeyExpirationMinutes: number = DEFAULT_PASSKEY_EXPIRATION_MINUTES;
|
||||||
@@ -1356,6 +1398,8 @@ export default class AccountViewView extends Vue {
|
|||||||
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
this.partnerApiServer = settings.partnerApiServer || this.partnerApiServer;
|
||||||
this.partnerApiServerInput =
|
this.partnerApiServerInput =
|
||||||
settings.partnerApiServer || this.partnerApiServerInput;
|
settings.partnerApiServer || this.partnerApiServerInput;
|
||||||
|
this.imageServer = settings.imageServer || this.imageServer;
|
||||||
|
this.imageServerInput = settings.imageServer || this.imageServerInput;
|
||||||
this.profileImageUrl = settings.profileImageUrl;
|
this.profileImageUrl = settings.profileImageUrl;
|
||||||
this.showContactGives = !!settings.showContactGivesInline;
|
this.showContactGives = !!settings.showContactGivesInline;
|
||||||
this.passkeyExpirationMinutes =
|
this.passkeyExpirationMinutes =
|
||||||
@@ -2437,7 +2481,7 @@ export default class AccountViewView extends Vue {
|
|||||||
const imageResp = await fetchImageRateLimits(
|
const imageResp = await fetchImageRateLimits(
|
||||||
this.axios,
|
this.axios,
|
||||||
did,
|
did,
|
||||||
this.DEFAULT_IMAGE_API_SERVER,
|
this.imageServer,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (imageResp && imageResp.status === 200) {
|
if (imageResp && imageResp.status === 200) {
|
||||||
@@ -2472,7 +2516,7 @@ export default class AccountViewView extends Vue {
|
|||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
did: did,
|
did: did,
|
||||||
apiServer: this.apiServer,
|
apiServer: this.apiServer,
|
||||||
imageServer: this.DEFAULT_IMAGE_API_SERVER,
|
imageServer: this.imageServer,
|
||||||
partnerApiServer: this.partnerApiServer,
|
partnerApiServer: this.partnerApiServer,
|
||||||
errorCode: axiosError?.response?.data?.error?.code,
|
errorCode: axiosError?.response?.data?.error?.code,
|
||||||
errorMessage: axiosError?.response?.data?.error?.message,
|
errorMessage: axiosError?.response?.data?.error?.message,
|
||||||
@@ -2555,6 +2599,17 @@ export default class AccountViewView extends Vue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onClickSaveImageServer(): Promise<void> {
|
||||||
|
await this.$saveSettings({
|
||||||
|
imageServer: this.imageServerInput,
|
||||||
|
});
|
||||||
|
this.imageServer = this.imageServerInput;
|
||||||
|
|
||||||
|
await this.$saveUserSettings(this.activeDid, {
|
||||||
|
imageServer: this.imageServer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async onClickSavePushServer(): Promise<void> {
|
async onClickSavePushServer(): Promise<void> {
|
||||||
await this.$saveSettings({
|
await this.$saveSettings({
|
||||||
webPushServer: this.webPushServerInput,
|
webPushServer: this.webPushServerInput,
|
||||||
@@ -2585,15 +2640,14 @@ export default class AccountViewView extends Vue {
|
|||||||
|
|
||||||
async deleteImage(): Promise<void> {
|
async deleteImage(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Extract the image ID from the full URL
|
if (!this.profileImageUrl) {
|
||||||
const imageId = this.profileImageUrl?.split("/").pop();
|
|
||||||
if (!imageId) {
|
|
||||||
this.notify.error("Invalid image URL");
|
this.notify.error("Invalid image URL");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// the image server looks the image up by its full URL
|
||||||
const response = await this.axios.delete(
|
const response = await this.axios.delete(
|
||||||
this.apiServer + "/api/image/" + imageId,
|
this.imageServer + "/image/" + encodeURIComponent(this.profileImageUrl),
|
||||||
{ headers: await getHeaders(this.activeDid) },
|
{ headers: await getHeaders(this.activeDid) },
|
||||||
);
|
);
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
@@ -2609,12 +2663,12 @@ export default class AccountViewView extends Vue {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isApiError(error) && error.response?.status === 404) {
|
if (isApiError(error) && error.response?.status === 404) {
|
||||||
// it already doesn't exist so we won't say anything to the user
|
// still drop it from the profile, which is what the user asked for
|
||||||
// Clear the local reference since the image is gone
|
|
||||||
this.profileImageUrl = "";
|
this.profileImageUrl = "";
|
||||||
await this.$saveSettings({
|
await this.$saveSettings({
|
||||||
profileImageUrl: "",
|
profileImageUrl: "",
|
||||||
});
|
});
|
||||||
|
this.notify.error(NOTIFY_IMAGE_DELETE_NOT_FOUND.message, TIMEOUTS.LONG);
|
||||||
} else {
|
} else {
|
||||||
this.notify.error(
|
this.notify.error(
|
||||||
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_ERROR,
|
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMAGE_DELETE_ERROR,
|
||||||
|
|||||||
@@ -71,26 +71,25 @@
|
|||||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
<code class="bg-gray-100 px-2 py-1 rounded">{{ deepLinkUrl }}</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
|
<div v-else-if="!pageError" class="text-center text-gray-600">
|
||||||
|
<p>Processing redirect...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Outside the chain above: a redirect failure is set after the
|
||||||
|
buttons render, and it points the user back up to them. -->
|
||||||
|
<div v-if="pageError" class="text-center text-red-500 mt-4 mb-4">
|
||||||
{{ pageError }}
|
{{ pageError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="text-center text-gray-600">
|
<details class="text-center text-xs text-gray-400 mt-4">
|
||||||
<p>Processing redirect...</p>
|
<summary class="cursor-pointer">Debug Info</summary>
|
||||||
</div>
|
<p>
|
||||||
|
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
|
||||||
|
</p>
|
||||||
|
<p class="break-all">User Agent: {{ userAgent }}</p>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -112,7 +111,6 @@ export default class DeepLinkRedirectView extends Vue {
|
|||||||
destinationUrl: string | null = null; // full path after "/deep-link/"
|
destinationUrl: string | null = null; // full path after "/deep-link/"
|
||||||
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
|
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
|
||||||
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
|
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
|
||||||
isDevelopment: boolean = false;
|
|
||||||
userAgent: string = "";
|
userAgent: string = "";
|
||||||
platformService = PlatformServiceFactory.getInstance();
|
platformService = PlatformServiceFactory.getInstance();
|
||||||
|
|
||||||
@@ -148,7 +146,6 @@ 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 !== "production";
|
|
||||||
this.userAgent = navigator.userAgent;
|
this.userAgent = navigator.userAgent;
|
||||||
|
|
||||||
this.openDeepLink();
|
this.openDeepLink();
|
||||||
@@ -190,13 +187,13 @@ export default class DeepLinkRedirectView extends Vue {
|
|||||||
"Fallback deep link failed: " + errorStringForLog(error),
|
"Fallback deep link failed: " + errorStringForLog(error),
|
||||||
);
|
);
|
||||||
this.pageError =
|
this.pageError =
|
||||||
"Redirecting to the app failed. Please use a manual option below.";
|
"Redirecting to the app failed. Please use a manual option above.";
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
logger.error("Deep link redirect failed: " + errorStringForLog(error));
|
||||||
this.pageError =
|
this.pageError =
|
||||||
"Unable to open the app. Please use a manual option below.";
|
"Unable to open the app. Please use a manual option above.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ import {
|
|||||||
NOTIFY_GIFTED_DETAILS_CREATE_GIVE_ERROR,
|
NOTIFY_GIFTED_DETAILS_CREATE_GIVE_ERROR,
|
||||||
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED,
|
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED,
|
||||||
NOTIFY_GIFT_ERROR_NO_DESCRIPTION,
|
NOTIFY_GIFT_ERROR_NO_DESCRIPTION,
|
||||||
|
NOTIFY_IMAGE_DELETE_ERROR,
|
||||||
|
NOTIFY_IMAGE_DELETE_NOT_FOUND,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -563,30 +565,33 @@ export default class GiftedDetails extends Vue {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const headers = await getHeaders(this.activeDid);
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const imageServer =
|
||||||
|
(await this.$accountSettings()).imageServer || DEFAULT_IMAGE_API_SERVER;
|
||||||
if (
|
if (
|
||||||
window.location.hostname === "localhost" &&
|
window.location.hostname === "localhost" &&
|
||||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
!imageServer.includes("localhost")
|
||||||
) {
|
) {
|
||||||
logger.log(
|
logger.log(
|
||||||
"Using shared image API server, so only users on that server can play with images.",
|
"Using shared image API server, so only users on that server can play with images.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await this.axios.delete(
|
const response = await this.axios.delete(
|
||||||
DEFAULT_IMAGE_API_SERVER + "/image/" + encodeURIComponent(imageUrl),
|
imageServer + "/image/" + encodeURIComponent(imageUrl),
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
// don't bother with a notification
|
// don't bother with a notification
|
||||||
} else {
|
} else {
|
||||||
logger.error("Problem deleting image:", response);
|
logger.error("Problem deleting image:", response);
|
||||||
|
this.notify.error(NOTIFY_IMAGE_DELETE_ERROR.message, TIMEOUTS.LONG);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error deleting image:", error);
|
logger.error("Error deleting image:", error);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if ((error as any)?.response?.status === 404) {
|
if ((error as any)?.response?.status === 404) {
|
||||||
logger.log("Image was already deleted:", error);
|
this.notify.error(NOTIFY_IMAGE_DELETE_NOT_FOUND.message, TIMEOUTS.LONG);
|
||||||
} else {
|
} else {
|
||||||
logger.error("Failed to delete image from server:", error);
|
this.notify.error(NOTIFY_IMAGE_DELETE_ERROR.message, TIMEOUTS.LONG);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-38
@@ -16,7 +16,6 @@
|
|||||||
>
|
>
|
||||||
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Help button -->
|
<!-- Help button -->
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'help' }"
|
:to="{ name: 'help' }"
|
||||||
@@ -28,63 +27,66 @@
|
|||||||
|
|
||||||
<!-- eslint-disable prettier/prettier -->
|
<!-- eslint-disable prettier/prettier -->
|
||||||
<div>
|
<div>
|
||||||
<p style="display:inline; align-items: center">
|
|
||||||
This work is public domain. (If you like rules, reference
|
|
||||||
<a
|
|
||||||
href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1"
|
|
||||||
target="_blank"
|
|
||||||
rel="license noopener noreferrer"
|
|
||||||
>
|
|
||||||
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
|
||||||
<img
|
|
||||||
src="../assets/help/creative-commons-circle.svg"
|
|
||||||
alt="CC circle"
|
|
||||||
width="20"
|
|
||||||
class="display: inline"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src="../assets/help/creative-commons-zero.svg"
|
|
||||||
alt="CC zero"
|
|
||||||
width="20"
|
|
||||||
style="display: inline"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
</p>
|
|
||||||
<p class="mt-4">
|
<p class="mt-4">
|
||||||
This is offered freely, with the hope that it helps but without any
|
This Giftopia service here at {{ APP_SERVER }}
|
||||||
|
is offered freely by
|
||||||
|
<a
|
||||||
|
:href="ORG_SERVER_URL"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
{{ ORG_SERVER }},
|
||||||
|
</a>
|
||||||
|
with the hope that it helps but without any
|
||||||
warranty or guarantee. When you share data or even look at information here,
|
warranty or guarantee. When you share data or even look at information here,
|
||||||
you accept the risk that goes with those activities. In other words,
|
you accept the risk that goes with those activities. In other words,
|
||||||
if you expect some functionality or you expect some protection, and you
|
if you expect some functionality or protection and you
|
||||||
feel it is appropriate to force those expectations on the system or its
|
feel it is appropriate to force those expectations on the system or its
|
||||||
operators or creators, then you are not allowed to use it.
|
operators or its creators, then you are not allowed to use it.
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-4">
|
<p class="mt-4">
|
||||||
Here is how your data is used:
|
Here is how your data is used:
|
||||||
</p>
|
</p>
|
||||||
<ul class="list-disc list-outside ml-4">
|
<ul class="list-disc list-outside ml-4">
|
||||||
<li>
|
<li>
|
||||||
If sending images, a server stores them. They can be removed by editing
|
Your name and your contacts' names, numbers, etc. are stored only on your device.
|
||||||
each claim and deleting the image.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If sending other partner system data (eg. to Trustroots) a public key
|
Your ID is a random ID, and your name is not associated with it.
|
||||||
and message data are stored on a server. Those can be removed via
|
You set or erase your visibility to others on the platform.
|
||||||
direct personal request (email
|
|
||||||
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500">
|
|
||||||
{{ SUPPORT_EMAIL }}
|
|
||||||
</a>).
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
For all other claim data,
|
Posts of gratitude, offers, projects, and profiles are stored on an
|
||||||
|
<a
|
||||||
|
href="https://endorser.ch"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-500"
|
||||||
|
>
|
||||||
|
Endorser
|
||||||
|
</a>
|
||||||
|
server.
|
||||||
|
|
||||||
|
These can be edited
|
||||||
|
at any time, including erasing the content and imagery.
|
||||||
|
|
||||||
|
For more details,
|
||||||
<a
|
<a
|
||||||
href="https://endorser.ch/privacy-policy"
|
href="https://endorser.ch/privacy-policy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
>
|
>
|
||||||
the Endorser Service has this Privacy Policy.
|
our Endorser Service has this Privacy Policy.
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
You may elect to share data to other system data (eg. to Trustroots).
|
||||||
|
In that case, we may store a public key
|
||||||
|
and message data on our server. Those can be removed via
|
||||||
|
direct personal request to
|
||||||
|
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500">
|
||||||
|
{{ SUPPORT_EMAIL }}
|
||||||
|
</a>.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
@@ -126,6 +128,41 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<p style="display:inline; align-items: center">
|
||||||
|
Giftopia is public domain software. (For mind-numbing specifics, see
|
||||||
|
<a
|
||||||
|
href="http://creativecommons.org/publicdomain/zero/1.0?ref=chooser-v1"
|
||||||
|
target="_blank"
|
||||||
|
rel="license noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span class="text-blue-500 mr-1">CC0 1.0</span>
|
||||||
|
<img
|
||||||
|
src="../assets/help/creative-commons-circle.svg"
|
||||||
|
alt="CC circle"
|
||||||
|
width="20"
|
||||||
|
class="display: inline"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="../assets/help/creative-commons-zero.svg"
|
||||||
|
alt="CC zero"
|
||||||
|
width="20"
|
||||||
|
style="display: inline"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
Send any comments, questions, or concerns about
|
||||||
|
personal information or other service features
|
||||||
|
to:
|
||||||
|
<a :href="`mailto:${SUPPORT_EMAIL}`" class="text-blue-500">
|
||||||
|
{{ SUPPORT_EMAIL }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- eslint-enable -->
|
<!-- eslint-enable -->
|
||||||
</section>
|
</section>
|
||||||
@@ -136,11 +173,14 @@ import { Component, Vue } from "vue-facing-decorator";
|
|||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import { SUPPORT_EMAIL } from "../constants/app";
|
import { APP_SERVER, ORG_SERVER, SUPPORT_EMAIL } from "../constants/app";
|
||||||
|
|
||||||
@Component({ components: { QuickNav } })
|
@Component({ components: { QuickNav } })
|
||||||
export default class HelpTermsView extends Vue {
|
export default class HelpTermsView extends Vue {
|
||||||
$router!: Router;
|
$router!: Router;
|
||||||
|
APP_SERVER = APP_SERVER;
|
||||||
|
ORG_SERVER = ORG_SERVER;
|
||||||
|
ORG_SERVER_URL = "https://" + ORG_SERVER;
|
||||||
SUPPORT_EMAIL = SUPPORT_EMAIL;
|
SUPPORT_EMAIL = SUPPORT_EMAIL;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -335,13 +335,14 @@ import {
|
|||||||
NotificationIface,
|
NotificationIface,
|
||||||
} from "../constants/app";
|
} from "../constants/app";
|
||||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
import { createNotifyHelpers } from "../utils/notify";
|
import { createNotifyHelpers, TIMEOUTS } from "../utils/notify";
|
||||||
import {
|
import {
|
||||||
NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR,
|
NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR,
|
||||||
NOTIFY_PROJECT_RETRIEVAL_ERROR,
|
NOTIFY_PROJECT_RETRIEVAL_ERROR,
|
||||||
NOTIFY_PROJECT_DELETE_IMAGE_CONFIRM,
|
NOTIFY_PROJECT_DELETE_IMAGE_CONFIRM,
|
||||||
NOTIFY_PROJECT_DELETE_IMAGE_ERROR,
|
NOTIFY_PROJECT_DELETE_IMAGE_ERROR,
|
||||||
NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR,
|
NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR,
|
||||||
|
NOTIFY_IMAGE_DELETE_NOT_FOUND,
|
||||||
NOTIFY_PROJECT_INVALID_LOCATION,
|
NOTIFY_PROJECT_INVALID_LOCATION,
|
||||||
NOTIFY_PROJECT_INVALID_START_DATE,
|
NOTIFY_PROJECT_INVALID_START_DATE,
|
||||||
NOTIFY_PROJECT_INVALID_END_DATE,
|
NOTIFY_PROJECT_INVALID_END_DATE,
|
||||||
@@ -617,9 +618,11 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
|
const headers = (await getHeaders(this.activeDid)) as AxiosRequestHeaders;
|
||||||
|
const imageServer =
|
||||||
|
(await this.$accountSettings()).imageServer || DEFAULT_IMAGE_API_SERVER;
|
||||||
if (
|
if (
|
||||||
window.location.hostname === "localhost" &&
|
window.location.hostname === "localhost" &&
|
||||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
!imageServer.includes("localhost")
|
||||||
) {
|
) {
|
||||||
logger.log(
|
logger.log(
|
||||||
"Using shared image API server, so only users on that server can play with images.",
|
"Using shared image API server, so only users on that server can play with images.",
|
||||||
@@ -627,9 +630,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.axios.delete(
|
const response = await this.axios.delete(
|
||||||
DEFAULT_IMAGE_API_SERVER +
|
imageServer + "/image/" + encodeURIComponent(this.imageUrl),
|
||||||
"/image/" +
|
|
||||||
encodeURIComponent(this.imageUrl),
|
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
@@ -645,12 +646,10 @@ export default class NewEditProjectView extends Vue {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error deleting image:", error);
|
logger.error("Error deleting image:", error);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if ((error as any).response.status === 404) {
|
if ((error as any)?.response?.status === 404) {
|
||||||
logger.log("The image was already deleted:", error);
|
// still drop it from the project, which is what the user asked for
|
||||||
|
|
||||||
this.imageUrl = "";
|
this.imageUrl = "";
|
||||||
|
this.notify.error(NOTIFY_IMAGE_DELETE_NOT_FOUND.message, TIMEOUTS.LONG);
|
||||||
// it already doesn't exist so we won't say anything to the user
|
|
||||||
} else {
|
} else {
|
||||||
this.notify.error(NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR.message);
|
this.notify.error(NOTIFY_PROJECT_DELETE_IMAGE_GENERAL_ERROR.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,19 +338,19 @@ export default class SharedPhotoView extends Vue {
|
|||||||
);
|
);
|
||||||
formData.append("claimType", imageType);
|
formData.append("claimType", imageType);
|
||||||
|
|
||||||
|
const imageServer =
|
||||||
|
(await this.$accountSettings()).imageServer || DEFAULT_IMAGE_API_SERVER;
|
||||||
if (
|
if (
|
||||||
window.location.hostname === "localhost" &&
|
window.location.hostname === "localhost" &&
|
||||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
!imageServer.includes("localhost")
|
||||||
) {
|
) {
|
||||||
logger.log(
|
logger.log(
|
||||||
"Using shared image API server, so only users on that server can play with images.",
|
"Using shared image API server, so only users on that server can play with images.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await axios.post(
|
const response = await axios.post(imageServer + "/image", formData, {
|
||||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
headers,
|
||||||
formData,
|
});
|
||||||
{ headers },
|
|
||||||
);
|
|
||||||
if (response?.data?.url) {
|
if (response?.data?.url) {
|
||||||
this.imageBlob = undefined;
|
this.imageBlob = undefined;
|
||||||
this.imageFileName = undefined;
|
this.imageFileName = undefined;
|
||||||
|
|||||||
+196
-51
@@ -6,53 +6,121 @@
|
|||||||
<!-- Sub View Heading -->
|
<!-- Sub View Heading -->
|
||||||
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
<div id="SubViewHeading" class="flex gap-4 items-start mb-8">
|
||||||
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
<h1 class="grow text-xl text-center font-semibold leading-tight">
|
||||||
Generate an Identity
|
{{ startupError ? "Time Safari Cannot Start" : "Generate an Identity" }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Back -->
|
<!-- Back and Help lead nowhere while the database is down -->
|
||||||
<a
|
<template v-if="!startupError">
|
||||||
class="order-first text-lg text-center leading-none p-1"
|
<!-- Back -->
|
||||||
@click="goBack()"
|
<a
|
||||||
>
|
class="order-first text-lg text-center leading-none p-1"
|
||||||
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
@click="goBack()"
|
||||||
</a>
|
>
|
||||||
|
<font-awesome icon="chevron-left" class="block text-center w-[1em]" />
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Help button -->
|
<!-- Help button -->
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'help' }"
|
:to="{ name: 'help' }"
|
||||||
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
|
||||||
>
|
>
|
||||||
<font-awesome icon="question" class="block text-center w-[1em]" />
|
<font-awesome icon="question" class="block text-center w-[1em]" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Startup error banner -->
|
<!-- Startup error: recovery options instead of identity creation -->
|
||||||
<div
|
<div v-if="startupError" class="max-w-3xl mx-auto">
|
||||||
v-if="startupError"
|
<div class="p-4 bg-red-50 border border-red-300 rounded-lg">
|
||||||
class="max-w-3xl mx-auto mb-6 p-4 bg-red-50 border border-red-300 rounded-lg"
|
<p class="text-red-800">
|
||||||
>
|
The app could not open its data on this device, so nothing else will
|
||||||
<h2 class="text-red-800 font-semibold text-lg mb-2">Startup Error</h2>
|
work until this is fixed. Reinstalling usually does not help, because
|
||||||
<p class="text-red-700 mb-3">
|
the phone restores the same data from its backup.
|
||||||
The app encountered a critical error during startup. This is often
|
</p>
|
||||||
caused by a database problem. Please send the details below to
|
<details class="mt-3 bg-white border border-red-200 rounded p-3">
|
||||||
<a :href="`mailto:${SUPPORT_EMAIL}`" class="underline font-semibold">{{
|
<summary class="cursor-pointer text-red-700 font-medium">
|
||||||
|
Error details
|
||||||
|
</summary>
|
||||||
|
<pre
|
||||||
|
class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words"
|
||||||
|
>{{ errorReport }}</pre
|
||||||
|
>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-lg mt-6">1. Try again</h2>
|
||||||
|
<p class="mt-1">Occasionally the problem is temporary.</p>
|
||||||
|
<a :class="secondaryButtonClass + ' mt-2'" @click="tryAgain()">
|
||||||
|
Try Again
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-lg mt-6">2. Tell us</h2>
|
||||||
|
<p class="mt-1">
|
||||||
|
Send the error details to
|
||||||
|
<a :href="supportMailto" class="underline font-semibold">{{
|
||||||
SUPPORT_EMAIL
|
SUPPORT_EMAIL
|
||||||
}}</a>
|
}}</a>
|
||||||
so we can help resolve it.
|
so we can fix it for everyone. If the email is cut short, copy the
|
||||||
|
details and paste them in.
|
||||||
</p>
|
</p>
|
||||||
<details class="bg-white border border-red-200 rounded p-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||||
<summary class="cursor-pointer text-red-700 font-medium">
|
<a :href="supportMailto" :class="secondaryButtonClass">Email Details</a>
|
||||||
Error details
|
<a :class="secondaryButtonClass" @click="copyErrorReport()">
|
||||||
</summary>
|
{{ copied ? "Copied" : "Copy Details" }}
|
||||||
<pre
|
</a>
|
||||||
class="mt-2 text-xs text-red-900 whitespace-pre-wrap break-words"
|
</div>
|
||||||
>{{ startupError }}</pre
|
|
||||||
|
<template v-if="canRecoverFiles">
|
||||||
|
<h2 class="font-semibold text-lg mt-6">3. Keep a copy of your data</h2>
|
||||||
|
<p class="mt-1">
|
||||||
|
Saves the database file somewhere outside the app, such as your files
|
||||||
|
or a drive. It contains your identity keys, so only send it to someone
|
||||||
|
you trust.
|
||||||
|
</p>
|
||||||
|
<a :class="secondaryButtonClass + ' mt-2'" @click="saveDatabaseCopy()">
|
||||||
|
Save a Copy of My Data
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<h2 class="font-semibold text-lg mt-6">4. Start over</h2>
|
||||||
|
<p class="mt-1">
|
||||||
|
Sets the old data aside (a copy stays inside the app) and starts fresh
|
||||||
|
with a new identity. Your previous identity, contacts, and settings
|
||||||
|
will not be in the app afterward; you can import that identity again
|
||||||
|
later only if you have its seed phrase.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
v-if="!confirmingReset"
|
||||||
|
:class="dangerButtonClass + ' mt-2'"
|
||||||
|
@click="confirmingReset = true"
|
||||||
>
|
>
|
||||||
</details>
|
Start Over
|
||||||
|
</a>
|
||||||
|
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-2">
|
||||||
|
<a :class="dangerButtonClass" @click="startOver()">
|
||||||
|
Yes, Set My Data Aside
|
||||||
|
</a>
|
||||||
|
<a :class="secondaryButtonClass" @click="confirmingReset = false">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="font-semibold text-lg mt-6">3. Start over</h2>
|
||||||
|
<p class="mt-1">
|
||||||
|
In your browser settings, clear the stored data for this site, then
|
||||||
|
reload. This removes your identity from this browser, so do it only if
|
||||||
|
you have your seed phrase or do not need that identity.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="recoveryMessage" class="mt-4 text-green-800">
|
||||||
|
{{ recoveryMessage }}
|
||||||
|
</p>
|
||||||
|
<p v-if="recoveryError" class="mt-4 text-red-700">{{ recoveryError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- id used by puppeteer test script -->
|
<!-- id used by puppeteer test script -->
|
||||||
<div id="start-question">
|
<div v-else id="start-question">
|
||||||
<div class="max-w-3xl mx-auto">
|
<div class="max-w-3xl mx-auto">
|
||||||
<p class="text-center text-xl font-light">
|
<p class="text-center text-xl font-light">
|
||||||
How do you want to create this identifier?
|
How do you want to create this identifier?
|
||||||
@@ -161,9 +229,13 @@
|
|||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
|
|
||||||
|
import * as Package from "../../package.json";
|
||||||
import { AppString, PASSKEYS_ENABLED, SUPPORT_EMAIL } from "../constants/app";
|
import { AppString, PASSKEYS_ENABLED, SUPPORT_EMAIL } from "../constants/app";
|
||||||
|
import { copyToClipboard } from "../services/ClipboardService";
|
||||||
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||||
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
|
||||||
import { logger } from "../utils/logger";
|
import { getMemoryLogs, logger } from "../utils/logger";
|
||||||
|
import { takeStartupError } from "../utils/startupError";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
registerSaveAndActivatePasskey,
|
registerSaveAndActivatePasskey,
|
||||||
@@ -185,6 +257,11 @@ export default class StartView extends Vue {
|
|||||||
givenName = "";
|
givenName = "";
|
||||||
numAccounts = 0;
|
numAccounts = 0;
|
||||||
startupError = "";
|
startupError = "";
|
||||||
|
copied = false;
|
||||||
|
confirmingReset = false;
|
||||||
|
recoveryMessage = "";
|
||||||
|
recoveryError = "";
|
||||||
|
canRecoverFiles = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for primary action button styling
|
* Computed property for primary action button styling
|
||||||
@@ -202,6 +279,34 @@ export default class StartView extends Vue {
|
|||||||
return "block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer";
|
return "block w-full text-center text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get dangerButtonClass() {
|
||||||
|
return "block w-full text-center text-md uppercase bg-gradient-to-b from-red-500 to-red-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md cursor-pointer";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything support needs: the error, recent warnings and errors (the
|
||||||
|
* database log table is unreachable in this state), version, and device.
|
||||||
|
*/
|
||||||
|
get errorReport(): string {
|
||||||
|
const recentLogs = getMemoryLogs().slice(-30);
|
||||||
|
return [
|
||||||
|
this.startupError,
|
||||||
|
`App version: ${Package.version} (${import.meta.env.VITE_GIT_HASH || "unknown commit"})`,
|
||||||
|
`Device: ${navigator.userAgent}`,
|
||||||
|
"Recent warnings and errors:\n" + (recentLogs.join("\n") || "(none)"),
|
||||||
|
].join("\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
get supportMailto(): string {
|
||||||
|
// Mail apps truncate long mailto links; Copy Details carries the full text.
|
||||||
|
const body = this.errorReport.slice(0, 1800);
|
||||||
|
return (
|
||||||
|
`mailto:${SUPPORT_EMAIL}` +
|
||||||
|
`?subject=${encodeURIComponent("Time Safari startup error")}` +
|
||||||
|
`&body=${encodeURIComponent(body)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for migration button styling
|
* Computed property for migration button styling
|
||||||
* Provides consistent classes for database migration button
|
* Provides consistent classes for database migration button
|
||||||
@@ -224,21 +329,22 @@ export default class StartView extends Vue {
|
|||||||
* identity creation options based on user preferences and existing accounts
|
* identity creation options based on user preferences and existing accounts
|
||||||
*/
|
*/
|
||||||
async mounted() {
|
async mounted() {
|
||||||
try {
|
const startupError = takeStartupError();
|
||||||
const raw = sessionStorage.getItem("startupError");
|
if (startupError) {
|
||||||
if (raw) {
|
this.startupError = [startupError.message, startupError.stack]
|
||||||
sessionStorage.removeItem("startupError");
|
.filter(Boolean)
|
||||||
try {
|
.join("\n\n");
|
||||||
const parsed = JSON.parse(raw);
|
const platformService = PlatformServiceFactory.getInstance();
|
||||||
const parts = [parsed.message, parsed.stack].filter(Boolean);
|
this.canRecoverFiles =
|
||||||
this.startupError = parts.length > 0 ? parts.join("\n\n") : raw;
|
typeof platformService.resetDatabase === "function" &&
|
||||||
logger.error("[StartView] Displaying startup error to user", parsed);
|
typeof platformService.shareDatabaseFile === "function" &&
|
||||||
} catch {
|
platformService.isCapacitor();
|
||||||
this.startupError = raw;
|
logger.error(
|
||||||
}
|
"[StartView] Displaying startup error to user",
|
||||||
}
|
startupError,
|
||||||
} catch {
|
);
|
||||||
// sessionStorage or JSON parse may fail; non-critical
|
// Loading settings would only fail again against the same database.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -259,6 +365,45 @@ export default class StartView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public tryAgain() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async copyErrorReport() {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(this.errorReport);
|
||||||
|
this.copied = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.recoveryError = "Could not copy: " + String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveDatabaseCopy() {
|
||||||
|
this.recoveryError = "";
|
||||||
|
try {
|
||||||
|
await PlatformServiceFactory.getInstance().shareDatabaseFile?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[StartView] Could not share database file", error);
|
||||||
|
this.recoveryError = "Could not save a copy: " + String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async startOver() {
|
||||||
|
this.recoveryError = "";
|
||||||
|
this.confirmingReset = false;
|
||||||
|
try {
|
||||||
|
const backupUri =
|
||||||
|
await PlatformServiceFactory.getInstance().resetDatabase?.();
|
||||||
|
logger.warn("[StartView] User reset the database", { backupUri });
|
||||||
|
this.recoveryMessage = "Starting over...";
|
||||||
|
// Reload from the root so startup runs against the fresh database.
|
||||||
|
window.location.replace("/");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[StartView] Database reset failed", error);
|
||||||
|
this.recoveryError = String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle new seed identity generation selection
|
* Handle new seed identity generation selection
|
||||||
* Routes user to new identifier creation flow with seed-based approach
|
* Routes user to new identifier creation flow with seed-based approach
|
||||||
|
|||||||
@@ -15,17 +15,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<NotificationDebugPanel />
|
||||||
v-if="!isDev"
|
|
||||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border rounded-md overflow-hidden px-4 py-3"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
This screen is hidden in production Vite builds (for example when built
|
|
||||||
with
|
|
||||||
<span class="font-mono text-sm">--mode production</span>).
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NotificationDebugPanel v-else />
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -33,13 +23,11 @@
|
|||||||
import { Component, Vue } from "vue-facing-decorator";
|
import { Component, Vue } from "vue-facing-decorator";
|
||||||
import { Router } from "vue-router";
|
import { Router } from "vue-router";
|
||||||
import NotificationDebugPanel from "@/components/dev/NotificationDebugPanel.vue";
|
import NotificationDebugPanel from "@/components/dev/NotificationDebugPanel.vue";
|
||||||
import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { NotificationDebugPanel },
|
components: { NotificationDebugPanel },
|
||||||
})
|
})
|
||||||
export default class NotificationDebugView extends Vue {
|
export default class NotificationDebugView extends Vue {
|
||||||
$router!: Router;
|
$router!: Router;
|
||||||
readonly isDev: boolean = includeDevToolkitRoutes;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user