Compare commits

..

28 Commits

Author SHA1 Message Date
3118f71320 fix linting (whitespace only) 2025-06-18 21:44:11 -06:00
d12f23aa81 Merge pull request 'Make all external URLs go to the /deep-link/ endpoint to redirect to mobile vs web' (#139) from deep-link-redirect into master
Reviewed-on: #139
2025-06-18 23:33:12 -04:00
e9a8a3c1e7 add support for deep-link query parameters 2025-06-18 19:31:16 -06:00
1e0efe6011 lengthen the error timeout when the message may be complicated, eg. with details from the server 2025-06-18 18:32:55 -06:00
16557f1e4b update build instruction & package-lock.json 2025-06-18 17:32:41 -06:00
c4a54967bc fix linting 2025-06-18 16:33:55 -06:00
20ade415dc bump to version 0.5.8 build 34 2025-06-18 16:31:31 -06:00
6689520270 fix all copies for externally-shared links to redirected deep links 2025-06-18 15:53:16 -06:00
3fd6c2b80d add first cut at deep-link redirecting, with one example contact-import that works on mobile 2025-06-18 13:16:17 -06:00
a5c5c2b9dd bump to build 33 and version 0.5.7 2025-06-18 02:34:18 -06:00
cf33a39fbc fix the invite-one setup, fix adeep link, and tweak other verbiage & error info 2025-06-18 02:32:47 -06:00
8629cefa13 bump to build 32 & version 0.5.6 2025-06-17 05:25:45 -06:00
5e851e442f shrink the contents of the QR code so people can scan it 2025-06-16 15:38:11 -06:00
4a43bc9c6c bump build to 31 and version to 0.5.5 2025-06-16 07:38:16 -06:00
60de8cee62 reword some of the help-page introduction (no code changes) 2025-06-16 07:24:37 -06:00
Jose Olarte III
bb2a4ab76e URL scheme config for iOS
- Registers the timesafari:// URL scheme
- Sets the bundle URL name to app.timesafari
2025-06-16 16:09:08 +08:00
Matthew Raymer
048dded278 fix: resolve deep link route mismatch for project links
- Fix schema validation mismatch between "project-details" and "project"
- Update VALID_DEEP_LINK_ROUTES to include "project" instead of "project-details"
- Update deepLinkSchemas to use "project" route name
- Update documentation to reflect correct route name
- Resolves "Invalid route path: project" errors in deep link handling

The deep link timesafari://project/01JWH0YAB3MAGBD751VAJAXQ17 now works correctly
and routes to the ProjectViewView component as expected.

Fixes: Deep link validation errors for project routes
2025-06-16 05:48:13 +00:00
e240c2940a remove unused deep links and add another 2025-06-15 13:54:12 -06:00
54dca9e745 fix project deep-link (and reorder alphabetically) 2025-06-15 11:02:16 -06:00
9f0fed0a60 update ios check to work, and add links to app stores 2025-06-14 22:10:49 -06:00
0d152adbf2 remove the deep-link autoVerify because it caused a build failure 2025-06-14 22:06:12 -06:00
cead308800 incorporate one of the BUILDING steps directly into the file 2025-06-13 22:37:03 -06:00
676a301331 bump to build 30 version 0.5.4 2025-06-13 22:36:28 -06:00
d6db81cc36 fix some result types and refactor types themselves 2025-06-13 21:58:57 -06:00
Matthew Raymer
f2ddcd2541 feat: add conditional rendering for claim certificate link and update gitignore
- Add v-if directive to show claim certificate link only when veriClaim.id exists
- Update .gitignore to exclude android app resource directory
- Prevents broken links when claim data is not fully loaded
- Improves build process by ignoring generated Android resources

This change ensures the certificate link is only displayed when there's
valid claim data available, preventing navigation errors and improving
user experience. The gitignore update helps keep the repository clean
by excluding Android-specific generated files.
2025-06-14 03:31:12 +00:00
fb81f7b96e fix problems with :href links causing the app to reload for DB errors on mobile 2025-06-13 20:39:12 -06:00
a23416ead1 fix optional message at top to not overflow 2025-06-12 20:10:31 -06:00
530c7c1a13 fix problem with user-profile page, and bump to build 29 & version 0.5.3 2025-06-12 19:16:02 -06:00
55 changed files with 1181 additions and 6060 deletions

2
.gitignore vendored
View File

@@ -21,7 +21,6 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
android/app/src/main/res/
# Editor directories and files
.idea
@@ -56,3 +55,4 @@ build_logs/
icons
android/app/src/main/res/

View File

@@ -41,6 +41,7 @@ Install dependencies:
1. Run the production build:
```bash
rm -rf dist
npm run build:web
```
@@ -64,6 +65,8 @@ Install dependencies:
* Commit everything (since the commit hash is used the app).
* Run a build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build`
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
@@ -71,7 +74,7 @@ Install dependencies:
* For test, build the app (because test server is not yet set up to build):
```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web
```
... and transfer to the test server:
@@ -321,11 +324,11 @@ Prerequisites: macOS with Xcode installed
#### Each Release
0. First time (or if XCode dependencies change):
0. First time (or if dependencies change):
- `pkgx +rubygems.org sh`
- ... and you may have to fix these, especially with pkgx
- ... and you may have to fix these, especially with pkgx:
```bash
gem_path=$(which gem)
@@ -334,23 +337,12 @@ Prerequisites: macOS with Xcode installed
export GEM_PATH=$shortened_path
```
```bash
cd ios/App
pod install
```
1. Build the web assets:
1. Build the web assets & update ios:
```bash
rm -rf dist
npm run build:web
npm run build:capacitor
```
2. Update iOS project with latest build:
```bash
npx cap sync ios
```
@@ -367,15 +359,14 @@ Prerequisites: macOS with Xcode installed
npx capacitor-assets generate --ios
```
4. Bump the version to match Android:
4. Bump the version to match Android & package.json:
```
cd ios/App
xcrun agvtool new-version 25
xcrun agvtool new-version 34
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.1;/g" > temp
mv temp App.xcodeproj/project.pbxproj
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.8;/g" > temp && mv temp App.xcodeproj/project.pbxproj
cd -
```
@@ -427,7 +418,7 @@ Prerequisites: Android Studio with Java SDK installed
npx capacitor-assets generate --android
```
4. Bump version to match iOS: android/app/build.gradle
4. Bump version to match iOS & package.json: android/app/build.gradle
5. Open the project in Android Studio:
@@ -444,7 +435,6 @@ Prerequisites: Android Studio with Java SDK installed
./gradlew clean
./gradlew build -Dlint.baselines.continue=true
cd -
npx cap run android
```
... or, to create the `aab` file, `bundle` instead of `build`:
@@ -478,7 +468,7 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send those changes or your (closed) testers won't see it.
## First-time Android Configuration for deep links
## Android Configuration for deep links
You must add the following intent filter to the `android/app/src/main/AndroidManifest.xml` file:
@@ -489,4 +479,6 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" />
</intent-filter>
```
```
... though when we tried that most recently it failed to 'build' the APK with: http(s) scheme and host attribute are missing, but are required for Android App Links [AppLinkUrlError]

View File

@@ -6,6 +6,13 @@ 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).
## [0.5.8]
### Added
- /deep-link/ path for URLs that are shared with people
### Changed
- External links now go to /deep-link/...
- Feed visuals now have arrow imagery from giver to receiver
## [0.4.7]
### Fixed

View File

@@ -1,533 +0,0 @@
# TimeSafari Contact Backup System
## Overview
The TimeSafari application implements a comprehensive contact backup and listing system that works across multiple platforms (Web, iOS, Android, Desktop). This document breaks down how contacts are saved, exported, and listed as backups.
## Architecture Components
### 1. Database Layer
#### Contact Data Structure
```typescript
interface Contact {
did: string; // Decentralized Identifier (primary key)
contactMethods?: ContactMethod[]; // Array of contact methods (EMAIL, SMS, etc.)
name?: string; // Display name
nextPubKeyHashB64?: string; // Base64 hash of next public key
notes?: string; // User notes
profileImageUrl?: string; // Profile image URL
publicKeyBase64?: string; // Base64 encoded public key
seesMe?: boolean; // Visibility setting
registered?: boolean; // Registration status
}
interface ContactMethod {
label: string; // Display label
type: string; // Type (EMAIL, SMS, WHATSAPP, etc.)
value: string; // Contact value
}
```
#### Database Schema
```sql
CREATE TABLE contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL, -- Decentralized Identifier
name TEXT, -- Display name
contactMethods TEXT, -- JSON string of contact methods
nextPubKeyHashB64 TEXT, -- Next public key hash
notes TEXT, -- User notes
profileImageUrl TEXT, -- Profile image URL
publicKeyBase64 TEXT, -- Public key
seesMe BOOLEAN, -- Visibility flag
registered BOOLEAN -- Registration status
);
CREATE INDEX idx_contacts_did ON contacts(did);
CREATE INDEX idx_contacts_name ON contacts(name);
```
### 2. Contact Saving Operations
#### A. Adding New Contacts
**1. QR Code Scanning (`ContactQRScanFullView.vue`)**
```typescript
async addNewContact(contact: Contact) {
// Check for existing contact
const existingContacts = await platformService.dbQuery(
"SELECT * FROM contacts WHERE did = ?", [contact.did]
);
if (existingContact) {
// Handle duplicate
return;
}
// Convert contactMethods to JSON string for storage
contact.contactMethods = JSON.stringify(
parseJsonField(contact.contactMethods, [])
);
// Insert into database
const { sql, params } = databaseUtil.generateInsertStatement(
contact as unknown as Record<string, unknown>, "contacts"
);
await platformService.dbExec(sql, params);
}
```
**2. Manual Contact Addition (`ContactsView.vue`)**
```typescript
private async addContact(newContact: Contact) {
// Validate DID format
if (!isDid(newContact.did)) {
throw new Error("Invalid DID format");
}
// Generate and execute INSERT statement
const { sql, params } = databaseUtil.generateInsertStatement(
newContact as unknown as Record<string, unknown>, "contacts"
);
await platformService.dbExec(sql, params);
}
```
**3. Contact Import (`ContactImportView.vue`)**
```typescript
async importContacts() {
for (const contact of selectedContacts) {
const contactToStore = contactToDbRecord(contact);
if (existingContact) {
// Update existing contact
const { sql, params } = databaseUtil.generateUpdateStatement(
contactToStore, "contacts", "did = ?", [contact.did]
);
await platformService.dbExec(sql, params);
} else {
// Add new contact
const { sql, params } = databaseUtil.generateInsertStatement(
contactToStore, "contacts"
);
await platformService.dbExec(sql, params);
}
}
}
```
#### B. Updating Existing Contacts
**Contact Editing (`ContactEditView.vue`)**
```typescript
async saveEdit() {
// Normalize contact methods
const contactMethods = this.contactMethods.map(method => ({
...method,
type: method.type.toUpperCase()
}));
// Update database
const contactMethodsString = JSON.stringify(contactMethods);
await platformService.dbExec(
"UPDATE contacts SET name = ?, notes = ?, contactMethods = ? WHERE did = ?",
[this.contactName, this.contactNotes, contactMethodsString, this.contact?.did]
);
}
```
### 3. Contact Export/Backup System
#### A. Export Process (`DataExportSection.vue`)
#### 1. Data Retrieval
```typescript
async exportDatabase() {
// Query all contacts from database
const result = await platformService.dbQuery("SELECT * FROM contacts");
const allContacts = databaseUtil.mapQueryResultToValues(result) as Contact[];
// Convert to export format
const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
}
```
#### 2. Export Format Conversion (`libs/util.ts`)
```typescript
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
const rows = contacts.map((contact) => ({
did: contact.did,
name: contact.name || null,
contactMethods: contact.contactMethods
? JSON.stringify(parseJsonField(contact.contactMethods, []))
: null,
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null,
notes: contact.notes || null,
profileImageUrl: contact.profileImageUrl || null,
publicKeyBase64: contact.publicKeyBase64 || null,
seesMe: contact.seesMe || false,
registered: contact.registered || false,
}));
return {
data: {
data: [{ tableName: "contacts", rows }]
}
};
};
```
#### 3. File Generation
```typescript
// Create timestamped filename
const timestamp = getTimestampForFilename();
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`;
// Create blob and save
const blob = new Blob([jsonStr], { type: "application/json" });
```
#### B. Platform-Specific File Saving
##### 1. Web Platform (`WebPlatformService.ts`)**
```typescript
// Uses browser download API
const downloadUrl = URL.createObjectURL(blob);
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
downloadAnchor.href = downloadUrl;
downloadAnchor.download = fileName;
downloadAnchor.click();
```
##### 2. Mobile Platforms (`CapacitorPlatformService.ts`)
```typescript
async writeAndShareFile(fileName: string, content: string, options = {}) {
let fileUri: string;
if (options.allowLocationSelection) {
// User chooses location
fileUri = await this.saveWithUserChoice(fileName, content, options.mimeType);
} else if (options.saveToPrivateStorage) {
// Save to app-private storage
const result = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
recursive: true,
});
fileUri = result.uri;
} else {
// Save to user-accessible location (Downloads/Documents)
fileUri = await this.saveToDownloads(fileName, content);
}
// Share the file
return await this.shareFile(fileUri, fileName);
}
```
##### 3. Desktop Platforms (`ElectronPlatformService.ts`, `PyWebViewPlatformService.ts`)
```typescript
// Not implemented - returns empty results
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
return [];
}
```
### 4. Backup File Listing System
#### A. File Discovery (`CapacitorPlatformService.ts`)
##### 1. Enhanced File Discovery
```typescript
async listUserAccessibleFilesEnhanced(): Promise<Array<{name: string, uri: string, size?: number, path?: string}>> {
const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = [];
if (this.getCapabilities().isIOS) {
// iOS: Documents directory
const result = await Filesystem.readdir({
path: ".",
directory: Directory.Documents,
});
const files = result.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: "Documents"
}));
allFiles.push(...files);
} else {
// Android: Multiple locations
const commonPaths = ["Download", "Documents", "Backups", "TimeSafari", "Data"];
for (const path of commonPaths) {
try {
const result = await Filesystem.readdir({
path: path,
directory: Directory.ExternalStorage,
});
// Filter for TimeSafari-related files
const relevantFiles = result.files
.filter(file => {
const fileName = typeof file === "string" ? file : file.name;
const name = fileName.toLowerCase();
return name.includes('timesafari') ||
name.includes('backup') ||
name.includes('contacts') ||
name.endsWith('.json');
})
.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: path
}));
allFiles.push(...relevantFiles);
} catch (error) {
// Silently skip inaccessible directories
}
}
}
return allFiles;
}
```
**2. Backup File Filtering**
```typescript
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
const allFiles = await this.listUserAccessibleFilesEnhanced();
const backupFiles = allFiles
.filter(file => {
const name = file.name.toLowerCase();
// Exclude directory-access notification files
if (name.startsWith('timesafari-directory-access-') && name.endsWith('.txt')) {
return false;
}
// Check backup criteria
const isJson = name.endsWith('.json');
const hasTimeSafari = name.includes('timesafari');
const hasBackup = name.includes('backup');
const hasContacts = name.includes('contacts');
const hasSeed = name.includes('seed');
const hasExport = name.includes('export');
const hasData = name.includes('data');
return isJson || hasTimeSafari || hasBackup || hasContacts || hasSeed || hasExport || hasData;
})
.map(file => {
const name = file.name.toLowerCase();
let type: 'contacts' | 'seed' | 'other' = 'other';
// Categorize files
if (name.includes('contacts') || (name.includes('timesafari') && name.includes('backup'))) {
type = 'contacts';
} else if (name.includes('seed') || name.includes('mnemonic') || name.includes('private')) {
type = 'seed';
} else if (name.endsWith('.json')) {
type = 'other';
}
return { ...file, type };
});
return backupFiles;
}
```
#### B. UI Components (`BackupFilesList.vue`)
**1. File Display**
```typescript
@Component
export default class BackupFilesList extends Vue {
backupFiles: Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}> = [];
selectedType: 'all' | 'contacts' | 'seed' | 'other' = 'all';
isLoading = false;
async refreshFiles() {
this.isLoading = true;
try {
this.backupFiles = await this.platformService.listBackupFiles();
// Log file type distribution
const typeCounts = {
contacts: this.backupFiles.filter(f => f.type === 'contacts').length,
seed: this.backupFiles.filter(f => f.type === 'seed').length,
other: this.backupFiles.filter(f => f.type === 'other').length,
total: this.backupFiles.length
};
} catch (error) {
// Handle error
} finally {
this.isLoading = false;
}
}
}
```
**2. File Operations**
```typescript
async openFile(fileUri: string, fileName: string) {
const result = await this.platformService.openFile(fileUri, fileName);
if (!result.success) {
throw new Error(result.error || "Failed to open file");
}
}
async openBackupDirectory() {
const result = await this.platformService.openBackupDirectory();
if (!result.success) {
throw new Error(result.error || "Failed to open backup directory");
}
}
```
### 5. Platform-Specific Storage Locations
#### A. iOS Platform
- **Primary Location**: Documents folder (accessible via Files app)
- **Persistence**: Survives app installations
- **Access**: Through iOS Files app
- **File Format**: JSON with timestamped filenames
#### B. Android Platform
- **Primary Locations**:
- `Download/TimeSafari/` (external storage)
- `TimeSafari/` (external storage)
- User-chosen locations via file picker
- **Persistence**: Survives app installations
- **Access**: Through file managers
- **File Format**: JSON with timestamped filenames
#### C. Web Platform
- **Primary Location**: Browser downloads folder
- **Persistence**: Depends on browser settings
- **Access**: Through browser download manager
- **File Format**: JSON with timestamped filenames
#### D. Desktop Platforms (Electron/PyWebView)
- **Status**: Not implemented
- **Fallback**: Returns empty arrays for file operations
### 6. File Naming Convention
#### A. Contact Backup Files
```
TimeSafari-backup-contacts-YYYY-MM-DD-HH-MM-SS.json
```
#### B. File Content Structure
```json
{
"data": {
"data": [
{
"tableName": "contacts",
"rows": [
{
"did": "did:ethr:0x...",
"name": "Contact Name",
"contactMethods": "[{\"type\":\"EMAIL\",\"value\":\"email@example.com\"}]",
"notes": "User notes",
"profileImageUrl": "https://...",
"publicKeyBase64": "base64...",
"seesMe": true,
"registered": false
}
]
}
]
}
}
```
### 7. Error Handling and Logging
#### A. Comprehensive Logging
```typescript
logger.log("[CapacitorPlatformService] File write successful:", {
uri: fileUri,
saved,
timestamp: new Date().toISOString(),
});
logger.log("[BackupFilesList] Refreshed backup files:", {
count: this.backupFiles.length,
files: this.backupFiles.map(f => ({
name: f.name,
type: f.type,
path: f.path,
size: f.size
})),
platform: this.platformCapabilities.isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
```
#### B. Error Recovery
```typescript
try {
// File operations
} catch (error) {
logger.error("[CapacitorPlatformService] Failed to list backup files:", error);
return [];
}
```
### 8. Security Considerations
#### A. Data Privacy
- Contact data is stored locally on device
- No cloud synchronization of contact data
- User controls visibility settings per contact
- Backup files contain only user-authorized data
#### B. File Access
- Platform-specific permission handling
- User choice for file locations
- Secure storage options for sensitive data
- Proper error handling for access failures
### 9. Performance Optimizations
#### A. Database Operations
- Indexed queries on `did` and `name` fields
- Batch operations for multiple contacts
- Efficient JSON serialization/deserialization
- Connection pooling and reuse
#### B. File Operations
- Asynchronous file I/O
- Efficient file discovery algorithms
- Caching of file lists
- Background refresh operations
## Summary
The TimeSafari contact backup system provides:
1. **Robust Data Storage**: SQLite-based contact storage with proper indexing
2. **Cross-Platform Compatibility**: Works on web, iOS, Android, and desktop
3. **Flexible Export Options**: Multiple file formats and storage locations
4. **Intelligent File Discovery**: Finds backup files regardless of user-chosen locations
5. **User-Friendly Interface**: Clear categorization and easy file management
6. **Comprehensive Logging**: Detailed tracking for debugging and monitoring
7. **Security-First Design**: Privacy-preserving with user-controlled data access
The system ensures that users can reliably backup and restore their contact data across different platforms while maintaining data integrity and user privacy.

View File

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

View File

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

View File

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

View File

@@ -49,5 +49,16 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
</dict>
</plist>

16
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "timesafari",
"version": "0.5.1",
"version": "0.5.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "timesafari",
"version": "0.5.1",
"version": "0.5.8",
"dependencies": {
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
@@ -11707,9 +11707,9 @@
}
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -14602,9 +14602,9 @@
}
},
"node_modules/decode-named-character-reference": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz",
"integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
"integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "0.5.1",
"version": "0.5.8",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"

View File

@@ -1,894 +0,0 @@
/** * Backup Files List Component * * Displays a list of backup files saved by
the app and provides options to: * - View backup files by type (contacts, seed,
other) * - Open individual files in the device's file viewer * - Access the
backup directory in the device's file explorer * * @component * @displayName
BackupFilesList * @example * ```vue *
<BackupFilesList />
* ``` */
<template>
<div class="backup-files-list">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Backup Files</h3>
<div class="flex gap-2">
<button
v-if="platformCapabilities.hasFileSystem"
class="text-sm bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
@click="refreshFiles()"
>
<font-awesome
icon="refresh"
class="fa-fw"
:class="{ 'animate-spin': isLoading }"
/>
Refresh
</button>
<button
v-if="platformCapabilities.hasFileSystem"
class="text-sm bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
@click="openBackupDirectory()"
>
<font-awesome icon="folder-open" class="fa-fw" />
Open Directory
</button>
<button
v-if="platformCapabilities.hasFileSystem && isDevelopment"
class="text-sm bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
title="Debug file discovery (development only)"
@click="debugFileDiscovery()"
>
<font-awesome icon="bug" class="fa-fw" />
Debug
</button>
<button
:disabled="isLoading"
class="px-3 py-1 bg-green-500 text-white rounded text-sm hover:bg-green-600 disabled:opacity-50"
@click="createTestBackup"
>
Create Test Backup
</button>
<button
:disabled="isLoading"
class="px-3 py-1 bg-purple-500 text-white rounded text-sm hover:bg-purple-600 disabled:opacity-50"
@click="testDirectoryContexts"
>
Test Contexts
</button>
</div>
</div>
<div v-if="isLoading" class="text-center py-4">
<font-awesome icon="spinner" class="animate-spin fa-2x" />
<p class="mt-2">Loading backup files...</p>
</div>
<div
v-else-if="backupFiles.length === 0"
class="text-center py-4 text-gray-500"
>
<font-awesome icon="folder-open" class="fa-2x mb-2" />
<p>No backup files found</p>
<p class="text-sm mt-1">
Create backups using the export functions above
</p>
<div
class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg text-left"
>
<p class="text-sm font-medium text-blue-800 mb-2">
💡 How to create backup files:
</p>
<ul class="text-xs text-blue-700 space-y-1">
<li>
Use the "Export Contacts" button above to create contact backups
</li>
<li> Use the "Export Seed" button to backup your recovery phrase</li>
<li>
Backup files are saved to persistent storage that survives app
installations
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="text-orange-700"
>
On Android: Files are saved to Downloads/TimeSafari or app data
directory
</li>
<li v-if="platformCapabilities.isIOS" class="text-orange-700">
On iOS: Files are saved to Documents folder (accessible via Files
app)
</li>
</ul>
</div>
</div>
<div v-else class="space-y-2">
<!-- File Type Filter -->
<div class="flex gap-2 mb-3">
<button
v-for="type in ['all', 'contacts', 'seed', 'other'] as const"
:key="type"
:class="[
'text-sm px-3 py-1 rounded border',
selectedType === type
? 'bg-blue-500 text-white border-blue-500'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50',
]"
@click="selectedType = type"
>
{{
type === "all"
? "All"
: type.charAt(0).toUpperCase() + type.slice(1)
}}
<span class="ml-1 text-xs"> ({{ getFileCountByType(type) }}) </span>
</button>
</div>
<!-- Files List -->
<div class="flex items-center gap-2 mb-2">
<span v-for="(crumb, idx) in breadcrumbs" :key="idx">
<span
v-if="idx < breadcrumbs.length - 1"
class="text-blue-600 cursor-pointer underline"
@click="goToBreadcrumb(idx)"
>
{{ crumb }}
</span>
<span v-else class="font-bold">{{ crumb }}</span>
<span v-if="idx < breadcrumbs.length - 1"> / </span>
</span>
</div>
<div v-if="currentPath.length > 1" class="mb-2">
<button class="text-xs text-blue-500 underline" @click="goUp">
Up
</button>
</div>
<div class="mb-2">
<label class="inline-flex items-center">
<input
v-model="debugShowAll"
type="checkbox"
class="mr-2"
@change="loadDirectory"
/>
<span class="text-xs">Debug: Show all entries as files</span>
</label>
<span v-if="debugShowAll" class="text-xs text-red-600 ml-2"
>[Debug mode: forcibly treating all entries as files]</span
>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div
v-for="entry in folders"
:key="'folder-' + entry.path"
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer"
@click="openFolder(entry)"
>
<div class="flex items-center gap-2">
<font-awesome icon="folder" class="fa-fw text-yellow-500" />
<span class="font-medium">{{ entry.name }}</span>
<span
class="text-xs bg-gray-200 text-gray-700 px-2 py-0.5 rounded-full ml-2"
>Folder</span
>
</div>
</div>
<div
v-for="entry in files"
:key="'file-' + entry.path"
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<font-awesome icon="file-alt" class="fa-fw text-gray-500" />
<span class="font-medium truncate">{{ entry.name }}</span>
</div>
<div class="text-sm text-gray-500 mt-1">
<span v-if="entry.size">{{ formatFileSize(entry.size) }}</span>
<span v-else>Size unknown</span>
<span
v-if="entry.path && !platformCapabilities.isIOS"
class="ml-2 text-xs text-blue-600"
>📁 {{ entry.path }}</span
>
</div>
</div>
<div class="flex gap-2 ml-3">
<button
class="text-blue-500 hover:text-blue-700 p-1"
title="Open file"
@click="openFile(entry.uri, entry.name)"
>
<font-awesome icon="external-link-alt" class="fa-fw" />
</button>
</div>
</div>
</div>
<!-- Summary -->
<div class="text-sm text-gray-500 mt-3 pt-3 border-t">
Showing {{ filteredFiles.length }} of {{ backupFiles.length }} backup
files
</div>
<div class="text-sm text-gray-600 mb-2">
<p>
📁 Backup files are saved to persistent storage that survives app
installations:
</p>
<ul class="list-disc list-inside ml-2 mt-1 text-xs">
<li v-if="platformCapabilities.isIOS">
iOS: Documents folder (accessible via Files app)
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
>
Android: Downloads/TimeSafari or external storage (accessible via
file managers)
</li>
<li v-if="!platformCapabilities.isMobile">
Desktop: User's download directory
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
/**
* @vue-component
* Backup Files List Component
* Displays and manages backup files with platform-specific functionality
*/
@Component
export default class BackupFilesList extends Vue {
/**
* Notification function injected by Vue
* Used to show success/error messages to the user
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Platform service instance for platform-specific operations
*/
private platformService: PlatformService =
PlatformServiceFactory.getInstance();
/**
* Platform capabilities for the current platform
*/
private get platformCapabilities(): PlatformCapabilities {
return this.platformService.getCapabilities();
}
/**
* List of backup files found on the device
*/
backupFiles: Array<{
name: string;
uri: string;
size?: number;
type: "contacts" | "seed" | "other";
path?: string;
}> = [];
/**
* Currently selected file type filter
*/
selectedType: "all" | "contacts" | "seed" | "other" = "all";
/**
* Loading state for file operations
*/
isLoading = false;
/**
* Interval for periodic refresh (5 minutes)
*/
private refreshInterval: number | null = null;
/**
* Current path for folder navigation (array for breadcrumbs)
*/
currentPath: string[] = [];
/**
* List of files/folders in the current directory
*/
directoryEntries: Array<{
name: string;
uri: string;
size?: number;
path: string;
type: "file" | "folder";
}> = [];
/**
* Temporary debug mode to show all entries as files
*/
debugShowAll = false;
/**
* Checks and requests storage permissions if needed.
* Returns true if permission is granted, false otherwise.
*/
private async ensureStoragePermission(): Promise<boolean> {
logger.log(
"[BackupFilesList] ensureStoragePermission called. platformCapabilities:",
this.platformCapabilities,
);
if (!this.platformCapabilities.hasFileSystem) return true;
// Only relevant for native platforms (Android/iOS)
const platformService = this.platformService as any;
if (typeof platformService.checkStoragePermissions === "function") {
try {
await platformService.checkStoragePermissions();
logger.log("[BackupFilesList] Storage permission granted.");
return true;
} catch (error) {
logger.error("[BackupFilesList] Storage permission denied:", error);
// Get specific guidance for the platform
let guidance =
"This app needs permission to access your files to list and restore backups.";
if (
typeof platformService.getStoragePermissionGuidance === "function"
) {
try {
guidance = await platformService.getStoragePermissionGuidance();
} catch (guidanceError) {
logger.warn(
"[BackupFilesList] Could not get permission guidance:",
guidanceError,
);
}
}
this.$notify(
{
group: "alert",
type: "warning",
title: "Storage Permission Required",
text: guidance,
},
10000, // Show for 10 seconds to give user time to read
);
return false;
}
}
return true;
}
/**
* Lifecycle hook to load backup files when component is mounted
*/
async mounted() {
logger.log(
"[BackupFilesList] mounted hook called. platformCapabilities:",
this.platformCapabilities,
);
if (this.platformCapabilities.hasFileSystem) {
// Check/request permission before loading
const hasPermission = await this.ensureStoragePermission();
if (hasPermission) {
// Set default root path
if (this.platformCapabilities.isIOS) {
this.currentPath = ["."];
} else {
this.currentPath = ["Download", "TimeSafari"];
}
await this.loadDirectory();
this.refreshInterval = window.setInterval(
() => {
this.loadDirectory();
},
5 * 60 * 1000,
);
}
}
}
/**
* Lifecycle hook to clean up resources when component is unmounted
*/
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
/**
* Computed property for filtered files based on selected type
* Note: The 'All' tab count is sometimes too small. Logging for debugging.
*/
get filteredFiles() {
if (this.selectedType === "all") {
logger.log("[BackupFilesList] filteredFiles (All):", this.backupFiles);
return this.backupFiles;
}
const filtered = this.backupFiles.filter(
(file) => file.type === this.selectedType,
);
logger.log(
`[BackupFilesList] filteredFiles (${this.selectedType}):`,
filtered,
);
return filtered;
}
/**
* Computed property to check if we're in development mode
*/
get isDevelopment(): boolean {
return import.meta.env.DEV;
}
/**
* Load the current directory entries
*/
async loadDirectory() {
if (!this.platformCapabilities.hasFileSystem) return;
this.isLoading = true;
try {
const path =
this.currentPath.join("/") ||
(this.platformCapabilities.isIOS ? "." : "Download/TimeSafari");
this.directoryEntries = await (
this.platformService as PlatformService
).listFilesInDirectory(path, this.debugShowAll);
logger.log("[BackupFilesList] Loaded directory:", {
path,
entries: this.directoryEntries,
});
} catch (error) {
logger.error("[BackupFilesList] Failed to load directory:", error);
this.directoryEntries = [];
} finally {
this.isLoading = false;
}
}
/**
* Navigate into a folder
*/
async openFolder(entry: { name: string; path: string }) {
this.currentPath.push(entry.name);
await this.loadDirectory();
}
/**
* Navigate to a breadcrumb
*/
async goToBreadcrumb(index: number) {
this.currentPath = this.currentPath.slice(0, index + 1);
await this.loadDirectory();
}
/**
* Go up one directory
*/
async goUp() {
if (this.currentPath.length > 1) {
this.currentPath.pop();
await this.loadDirectory();
}
}
/**
* Computed property for breadcrumbs
*/
get breadcrumbs() {
return this.currentPath;
}
/**
* Computed property for showing files and folders
*/
get folders() {
return this.directoryEntries.filter((e) => e.type === "folder");
}
get files() {
return this.directoryEntries.filter((e) => e.type === "file");
}
/**
* Refreshes the list of backup files from the device
*/
async refreshFiles() {
logger.log("[BackupFilesList] refreshFiles called.");
if (!this.platformCapabilities.hasFileSystem) {
return;
}
// Check/request permission before refreshing
const hasPermission = await this.ensureStoragePermission();
if (!hasPermission) {
this.backupFiles = [];
this.isLoading = false;
return;
}
this.isLoading = true;
try {
this.backupFiles = await this.platformService.listBackupFiles();
logger.log("[BackupFilesList] Refreshed backup files:", {
count: this.backupFiles.length,
files: this.backupFiles.map((f) => ({
name: f.name,
type: f.type,
path: f.path,
size: f.size,
})),
platform: this.platformCapabilities.isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Debug: Log file type distribution
const typeCounts = {
contacts: this.backupFiles.filter((f) => f.type === "contacts").length,
seed: this.backupFiles.filter((f) => f.type === "seed").length,
other: this.backupFiles.filter((f) => f.type === "other").length,
total: this.backupFiles.length,
};
logger.log("[BackupFilesList] File type distribution:", typeCounts);
// Log the full backupFiles array for debugging the 'All' tab count
logger.log(
"[BackupFilesList] backupFiles array for All tab:",
this.backupFiles,
);
} catch (error) {
logger.error("[BackupFilesList] Failed to refresh backup files:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Files",
text: "Failed to load backup files from your device.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
/**
* Creates a test backup file for debugging purposes
*/
async createTestBackup() {
try {
this.isLoading = true;
logger.log("[BackupFilesList] Creating test backup file");
const result = await this.platformService.createTestBackupFile();
if (result.success) {
logger.log("[BackupFilesList] Test backup file created successfully:", {
fileName: result.fileName,
uri: result.uri,
timestamp: new Date().toISOString(),
});
this.$notify(
{
group: "alert",
type: "success",
title: "Test Backup Created",
text: `Test backup file "${result.fileName}" created successfully. Refresh the list to see it.`,
},
5000,
);
// Refresh the file list to show the new test file
await this.refreshFiles();
} else {
throw new Error(result.error || "Failed to create test backup file");
}
} catch (error) {
logger.error(
"[BackupFilesList] Failed to create test backup file:",
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Test Backup Failed",
text: "Failed to create test backup file. Check the console for details.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
/**
* Tests different directory contexts to debug file visibility issues
*/
async testDirectoryContexts() {
try {
this.isLoading = true;
logger.log("[BackupFilesList] Testing directory contexts");
const debugOutput = await this.platformService.testDirectoryContexts();
logger.log(
"[BackupFilesList] Directory context test results:",
debugOutput,
);
// Show the debug output in a notification or alert
this.$notify(
{
group: "alert",
type: "info",
title: "Directory Context Test",
text: "Directory context test completed. Check the console for detailed results.",
},
5000,
);
// Also log the full output to console for easy access
logger.log("=== Directory Context Test Results ===");
logger.log(debugOutput);
logger.log("=== End Test Results ===");
} catch (error) {
logger.error(
"[BackupFilesList] Failed to test directory contexts:",
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Context Test Failed",
text: "Failed to test directory contexts. Check the console for details.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
/**
* Refreshes the file list after a backup is created
* This method can be called from parent components
*/
async refreshAfterSave() {
logger.log("[BackupFilesList] refreshAfterSave called");
await this.refreshFiles();
}
/**
* Opens a specific file in the device's file viewer
* @param fileUri - URI of the file to open
* @param fileName - Name of the file for display
*/
async openFile(fileUri: string, fileName: string) {
try {
const result = await this.platformService.openFile(fileUri, fileName);
if (result.success) {
logger.log("[BackupFilesList] File opened successfully:", {
fileName,
timestamp: new Date().toISOString(),
});
} else {
throw new Error(result.error || "Failed to open file");
}
} catch (error) {
logger.error("[BackupFilesList] Failed to open file:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Opening File",
text: `Failed to open ${fileName}. ${error instanceof Error ? error.message : String(error)}`,
},
5000,
);
}
}
/**
* Opens the backup directory in the device's file explorer
*/
async openBackupDirectory() {
try {
const result = await this.platformService.openBackupDirectory();
if (result.success) {
logger.log("[BackupFilesList] Backup directory opened successfully:", {
timestamp: new Date().toISOString(),
});
} else {
throw new Error(result.error || "Failed to open backup directory");
}
} catch (error) {
logger.error("[BackupFilesList] Failed to open backup directory:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Opening Directory",
text: `Failed to open backup directory. ${error instanceof Error ? error.message : String(error)}`,
},
5000,
);
}
}
/**
* Gets the count of files for a specific type
* Note: The 'All' tab count is sometimes too small. Logging for debugging.
*/
getFileCountByType(type: "all" | "contacts" | "seed" | "other"): number {
let count;
if (type === "all") {
count = this.backupFiles.length;
logger.log(
"[BackupFilesList] getFileCountByType (All):",
count,
this.backupFiles,
);
return count;
}
count = this.backupFiles.filter((file) => file.type === type).length;
logger.log(`[BackupFilesList] getFileCountByType (${type}):`, count);
return count;
}
/**
* Gets the appropriate icon for a file type
* @param type - File type
* @returns FontAwesome icon name
*/
getFileIcon(type: "contacts" | "seed" | "other"): string {
switch (type) {
case "contacts":
return "address-book";
case "seed":
return "key";
default:
return "file-alt";
}
}
/**
* Gets the appropriate icon color for a file type
* @param type - File type
* @returns CSS color class
*/
getFileIconColor(type: "contacts" | "seed" | "other"): string {
switch (type) {
case "contacts":
return "text-blue-500";
case "seed":
return "text-orange-500";
default:
return "text-gray-500";
}
}
/**
* Gets the appropriate badge color for a file type
* @param type - File type
* @returns CSS color class
*/
getTypeBadgeColor(type: "contacts" | "seed" | "other"): string {
switch (type) {
case "contacts":
return "bg-blue-100 text-blue-800";
case "seed":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
}
/**
* Formats file size in human-readable format
* @param bytes - File size in bytes
* @returns Formatted file size string
*/
formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
* Debug method to test file discovery
* Can be called from browser console for troubleshooting
*/
public async debugFileDiscovery() {
try {
logger.log("[BackupFilesList] Starting debug file discovery...");
// Test the platform service's test methods
const platformService = PlatformServiceFactory.getInstance();
// Test listing all user files
const allFilesResult = await platformService.testListUserFiles();
logger.log(
"[BackupFilesList] All user files test result:",
allFilesResult,
);
// Test listing backup files specifically
const backupFilesResult = await platformService.testBackupFiles();
logger.log(
"[BackupFilesList] Backup files test result:",
backupFilesResult,
);
// Note: testListAllBackupFiles method is not part of the PlatformService interface
// It exists only in CapacitorPlatformService implementation
// If needed, this could be added to the interface or called via type assertion
// Test debug listing all files without filtering (if available)
if ("debugListAllFiles" in platformService) {
const debugAllFiles = await (
platformService as any
).debugListAllFiles();
logger.log("[BackupFilesList] Debug all files (no filtering):", {
count: debugAllFiles.length,
files: debugAllFiles.map((f: any) => ({
name: f.name,
path: f.path,
size: f.size,
})),
});
}
// Test comprehensive step-by-step debug (if available)
if ("debugFileDiscoveryStepByStep" in platformService) {
const stepByStepDebug = await (
platformService as any
).debugFileDiscoveryStepByStep();
logger.log(
"[BackupFilesList] Step-by-step debug output:",
stepByStepDebug,
);
}
return {
allFiles: allFilesResult,
backupFiles: backupFilesResult,
currentBackupFiles: this.backupFiles,
debugAllFiles:
"debugListAllFiles" in platformService
? await (platformService as any).debugListAllFiles()
: null,
};
} catch (error) {
logger.error("[BackupFilesList] Debug file discovery failed:", error);
throw error;
}
}
@Watch("platformCapabilities.hasFileSystem", { immediate: true })
async onFileSystemCapabilityChanged(newVal: boolean) {
if (newVal) {
await this.refreshFiles();
}
}
}
</script>

View File

@@ -1,8 +1,7 @@
/** * Data Export Section Component * * Provides UI and functionality for
exporting user data and backing up identifier seeds. * Includes buttons for seed
backup and database export, with platform-specific download instructions. * Also
displays a list of backup files with options to open them in the device's file
explorer. * * @component * @displayName DataExportSection * @example * ```vue *
backup and database export, with platform-specific download instructions. * *
@component * @displayName DataExportSection * @example * ```vue *
<DataExportSection :active-did="currentDid" />
* ``` */
@@ -44,27 +43,18 @@ explorer. * * @component * @displayName DataExportSection * @example * ```vue *
v-if="platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On iOS: Files are saved to Documents folder (accessible via Files app)
and persist between app installations.
On iOS: You will be prompted to choose a location to save your backup
file.
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On Android: Files are saved to Downloads/TimeSafari or external
storage (accessible via file managers) and persist between app
installations.
On Android: You will be prompted to choose a location to save your
backup file.
</li>
</ul>
</div>
<!-- Backup Files List -->
<div
v-if="platformCapabilities.hasFileSystem"
class="mt-6 pt-6 border-t border-gray-300"
>
<BackupFilesList ref="backupFilesList" />
</div>
</div>
</template>
@@ -75,21 +65,20 @@ import { AppString, NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { logger, getTimestampForFilename } from "../utils/logger";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
import { contactsToExportJson } from "../libs/util";
import BackupFilesList from "./BackupFilesList.vue";
/**
* @vue-component
* Data Export Section Component
* Handles database export and seed backup functionality with platform-specific behavior
*/
@Component({ components: { BackupFilesList } })
@Component
export default class DataExportSection extends Vue {
/**
* Notification function injected by Vue
@@ -162,9 +151,7 @@ export default class DataExportSection extends Vue {
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
// Create timestamped filename
const timestamp = getTimestampForFilename();
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`;
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts.json`;
if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link
@@ -175,21 +162,8 @@ export default class DataExportSection extends Vue {
downloadAnchor.click();
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
} else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to user-accessible location and share
const result = await this.platformService.writeAndShareFile(
fileName,
jsonStr,
{
allowLocationSelection: true,
showLocationSelectionDialog: true,
mimeType: "application/json",
},
);
// Handle the result
if (!result.saved) {
throw new Error(result.error || "Failed to save file");
}
// Native platform: Write to app directory
await this.platformService.writeAndShareFile(fileName, jsonStr);
} else {
throw new Error("This platform does not support file downloads.");
}
@@ -201,19 +175,10 @@ export default class DataExportSection extends Vue {
title: "Export Successful",
text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup."
: "Backup saved to persistent storage that survives app installations. Use the share dialog to access your file and choose where to save it permanently.",
: "The backup file has been saved.",
},
5000,
3000,
);
// Refresh the backup files list
const backupFilesList = this.$refs.backupFilesList as any;
if (
backupFilesList &&
typeof backupFilesList.refreshAfterSave === "function"
) {
await backupFilesList.refreshAfterSave();
}
} catch (error) {
logger.error("Export Error:", error);
this.$notify(
@@ -247,18 +212,5 @@ export default class DataExportSection extends Vue {
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
};
}
async mounted() {
// Ensure permissions are requested and refresh backup files list on mount
if (this.platformCapabilities.hasFileSystem) {
const backupFilesList = this.$refs.backupFilesList as any;
if (
backupFilesList &&
typeof backupFilesList.refreshFiles === "function"
) {
await backupFilesList.refreshFiles();
}
}
}
}
</script>

View File

@@ -321,7 +321,7 @@ export default class GiftedDialog extends Vue {
);
if (!result.success) {
const errorMessage = this.getGiveCreationErrorMessage(result);
const errorMessage = result.error;
logger.error("Error with give creation result:", result);
this.$notify(
{
@@ -367,19 +367,6 @@ export default class GiftedDialog extends Vue {
// Helper functions for readability
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{

View File

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

View File

@@ -268,7 +268,7 @@ import {
} from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger, getTimestampForFilename } from "../utils/logger";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import * as databaseUtil from "../db/databaseUtil";
@@ -576,7 +576,7 @@ export default class ImageMethodDialog extends Vue {
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo-${getTimestampForFilename()}.jpg`;
this.fileName = `photo_${Date.now()}.jpg`;
this.showRetry = true;
this.stopCameraPreview();
}

View File

@@ -83,10 +83,7 @@
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import {
createAndSubmitOffer,
serverMessageForUser,
} from "../libs/endorserServer";
import { createAndSubmitOffer } from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
@@ -250,7 +247,7 @@ export default class OfferDialog extends Vue {
);
if (!result.success) {
const errorMessage = this.getOfferCreationErrorMessage(result);
const errorMessage = result.error;
logger.error("Error with offer creation result:", result);
this.$notify(
{
@@ -290,21 +287,6 @@ export default class OfferDialog extends Vue {
);
}
}
// Helper functions for readability
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getOfferCreationErrorMessage(result: any) {
return (
serverMessageForUser(result) ||
result.error?.userMessage ||
result.error?.error
);
}
}
</script>

View File

@@ -127,7 +127,7 @@ import {
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger, getTimestampForFilename } from "../utils/logger";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
@Component({ components: { VuePictureCropper } })
@@ -393,7 +393,7 @@ export default class PhotoDialog extends Vue {
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo-${getTimestampForFilename()}.jpg`;
this.fileName = `photo_${Date.now()}.jpg`;
this.stopCameraPreview();
}
},

View File

@@ -38,14 +38,13 @@ export default class TopMessage extends Vue {
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You're linked to a non-prod server, user " + didPrefix;
this.message = "You're not using prod, user " + didPrefix;
} else if (
settings.warnIfProdServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15);
this.message =
"You're linked to the production server, user " + didPrefix;
this.message = "You are using prod, user " + didPrefix;
}
} catch (err: unknown) {
this.$notify(

View File

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

View File

@@ -1,6 +1,4 @@
import { AxiosResponse } from "axios";
import { GiverReceiverInputInfo } from "../libs/util";
import { ErrorResult, ResultWithType } from "./common";
export interface GiverOutputInfo {
action: string;
@@ -47,12 +45,3 @@ export interface ProviderInfo {
*/
linkConfirmed: boolean;
}
// Type for createAndSubmitClaim result
export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;
// Update SuccessResult to use ClaimResult
export interface SuccessResult extends ResultWithType {
type: "success";
response: AxiosResponse<ClaimResult>;
}

View File

@@ -15,10 +15,6 @@ export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
publicUrls?: Record<string, string>;
}
export interface ResultWithType {
type: string;
}
export interface ErrorResponse {
error?: {
message?: string;
@@ -30,11 +26,6 @@ export interface InternalError {
userMessage?: string;
}
export interface ErrorResult extends ResultWithType {
type: "error";
error: InternalError;
}
export interface KeyMeta {
did: string;
publicKeyHex: string;

View File

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

View File

@@ -1,5 +1,6 @@
export type {
// From common.ts
CreateAndSubmitClaimResult,
GenericCredWrapper,
GenericVerifiableCredential,
KeyMeta,
@@ -18,11 +19,6 @@ export type {
RegisterActionClaim,
} from "./claims";
export type {
// From claims-result.ts
CreateAndSubmitClaimResult,
} from "./claims-result";
export type {
// From records.ts
PlanSummaryRecord,

View File

@@ -12,14 +12,6 @@ import type { PluginListenerHandle } from "@capacitor/core";
* Supports 'backButton' and 'appUrlOpen' events from Capacitor
*/
interface AppInterface {
/**
* Force exit the app. This should only be used in conjunction with the `backButton` handler for Android to
* exit the app when navigation is complete.
*
* @returns Promise that resolves when the app has been exited
*/
exitApp(): Promise<void>;
/**
* Add listener for back button events
* @param eventName - Must be 'backButton'
@@ -46,19 +38,8 @@ interface AppInterface {
/**
* App wrapper for Capacitor functionality
* Provides type-safe event listeners for back button and URL open events
* and app exit functionality
*/
export const App: AppInterface = {
/**
* Force exit the app. This should only be used in conjunction with the `backButton` handler for Android to
* exit the app when navigation is complete.
*
* @returns Promise that resolves when the app has been exited
*/
exitApp(): Promise<void> {
return CapacitorApp.exitApp();
},
addListener(
eventName: "backButton" | "appUrlOpen",
listenerFunc: BackButtonListener | ((data: AppLaunchUrl) => void),

View File

@@ -979,7 +979,7 @@ export const createAndSubmitConfirmation = async (
handleId: string | undefined,
apiServer: string,
axios: Axios,
) => {
): Promise<CreateAndSubmitClaimResult> => {
const goodClaim = removeSchemaContext(
removeVisibleToDids(
addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId),
@@ -1074,7 +1074,8 @@ export async function generateEndorserJwtUrlForAccount(
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
const viewPrefix =
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
return viewPrefix + vcJwt;
}

View File

@@ -882,6 +882,71 @@ export const contactToCsvLine = (contact: Contact): string => {
return fields.join(",");
};
/**
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
* @param lineRaw - The CSV line to parse
* @returns A Contact object
*/
export const csvLineToContact = (lineRaw: string): Contact => {
// Note that Endorser Mobile puts name first, then did, etc.
let line = lineRaw.trim();
let did, publicKeyInput, seesMe, registered;
let name;
let commaPos1 = -1;
if (line.startsWith('"')) {
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
if (doubleDoubleQuotePos === -1) {
doubleDoubleQuotePos = 1;
}
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
if (quote2Pos > -1) {
commaPos1 = line.indexOf(",", quote2Pos);
name = line.substring(1, quote2Pos).trim();
name = name.replace(/""/g, '"');
} else {
// something is weird with one " to start, so ignore it and start after "
line = line.substring(1);
commaPos1 = line.indexOf(",");
name = line.substring(0, commaPos1).trim();
}
} else {
commaPos1 = line.indexOf(",");
name = line.substring(0, commaPos1).trim();
}
if (commaPos1 > -1) {
did = line.substring(commaPos1 + 1).trim();
const commaPos2 = line.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
did = line.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = line.substring(commaPos2 + 1).trim();
const commaPos3 = line.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
seesMe = line.substring(commaPos3 + 1).trim() == "true";
const commaPos4 = line.indexOf(",", commaPos3 + 1);
if (commaPos4 > -1) {
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
registered = line.substring(commaPos4 + 1).trim() == "true";
}
}
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact: Contact = {
did: did || "",
name,
publicKeyBase64,
seesMe,
registered,
};
return newContact;
};
/**
* Interface for the JSON export format of database tables
*/

View File

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

View File

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

View File

@@ -65,21 +65,9 @@ export interface PlatformService {
* Writes content to a file at the specified path and shares it.
* @param fileName - The filename of the file to write
* @param content - The content to write to the file
* @param options - Optional parameters for file saving behavior
* @returns Promise that resolves to save/share result
* @returns Promise that resolves when the write is complete
*/
writeAndShareFile(
fileName: string,
content: string,
options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
},
): Promise<{ saved: boolean; uri?: string; shared: boolean; error?: string }>;
writeAndShareFile(fileName: string, content: string): Promise<void>;
/**
* Deletes a file at the specified path.
@@ -95,48 +83,6 @@ export interface PlatformService {
*/
listFiles(directory: string): Promise<string[]>;
/**
* Tests the file sharing functionality by creating and sharing a test file.
* @returns Promise resolving to a test result message
*/
testFileSharing(): Promise<string>;
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
testFileSaveOnly(): Promise<string>;
/**
* Tests the location selection functionality using the file picker.
* @returns Promise resolving to a test result message
*/
testLocationSelection(): Promise<string>;
/**
* Tests location selection without showing the dialog (restores original behavior).
* @returns Promise resolving to a test result message
*/
testLocationSelectionSilent(): Promise<string>;
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
testListUserFiles(): Promise<string>;
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
testBackupFiles(): Promise<string>;
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
testOpenBackupDirectory(): Promise<string>;
// Camera operations
/**
* Activates the device camera to take a picture.
@@ -184,92 +130,4 @@ export interface PlatformService {
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }>;
/**
* Lists user-accessible files saved by the app.
* Returns files from Downloads (Android) or Documents (iOS) directories.
* @returns Promise resolving to array of file information
*/
listUserAccessibleFiles(): Promise<
Array<{ name: string; uri: string; size?: number }>
>;
/**
* Lists backup files specifically saved by the app.
* Filters for files that appear to be TimeSafari backups.
* @returns Promise resolving to array of backup file information
*/
listBackupFiles(): Promise<
Array<{
name: string;
uri: string;
size?: number;
type: "contacts" | "seed" | "other";
path?: string;
}>
>;
/**
* Opens a file in the device's default file viewer/app.
* Uses the native share dialog to provide options for opening the file.
* @param fileUri - URI of the file to open
* @param fileName - Name of the file (for display purposes)
* @returns Promise resolving to success status
*/
openFile(
fileUri: string,
fileName: string,
): Promise<{ success: boolean; error?: string }>;
/**
* Opens the directory containing backup files in the device's file explorer.
* Uses the native share dialog to provide options for accessing the directory.
* @returns Promise resolving to success status
*/
openBackupDirectory(): Promise<{ success: boolean; error?: string }>;
/**
* Creates a test backup file to verify file writing and reading functionality.
* This is useful for debugging file visibility issues.
* @returns Promise resolving to success status and file information
*/
createTestBackupFile(): Promise<{
success: boolean;
fileName?: string;
uri?: string;
error?: string;
}>;
/**
* Tests different directory contexts to see what files are available.
* This helps debug file visibility issues across different storage contexts.
* @returns Promise resolving to debug information about file discovery across contexts
*/
testDirectoryContexts(): Promise<string>;
/**
* Lists files and folders in a specific directory for directory browsing
* @param path - The directory path to list
* @param debugShowAll - Debug flag to treat all entries as files
* @returns Promise resolving to array of directory entries
*/
listFilesInDirectory(
path: string,
debugShowAll?: boolean,
): Promise<
Array<{
name: string;
uri: string;
size?: number;
path: string;
type: "file" | "folder";
}>
>;
/**
* Debug method to check what's actually in the TimeSafari directory
* This helps identify if the directory exists but is empty or has permission issues
* @returns Promise resolving to debug information about the TimeSafari directory
*/
debugTimeSafariDirectory(): Promise<string>;
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -205,7 +205,6 @@ export class ElectronPlatformService implements PlatformService {
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
isNativeApp: true,
};
}
@@ -235,32 +234,11 @@ export class ElectronPlatformService implements PlatformService {
* Writes content to a file and opens the system share dialog.
* @param _fileName - Name of the file to create
* @param _content - Content to write to the file
* @param _options - Options for file saving behavior
* @throws Error with "Not implemented" message
* @todo Implement using Electron's dialog and file system APIs
*/
async writeAndShareFile(
_fileName: string,
_content: string,
_options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
},
): Promise<{
saved: boolean;
uri?: string;
shared: boolean;
error?: string;
}> {
return {
saved: false,
shared: false,
error: "Not implemented in Electron platform",
};
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("Not implemented");
}
/**
@@ -306,17 +284,6 @@ export class ElectronPlatformService implements PlatformService {
throw new Error("Not implemented");
}
/**
* Should rotate the camera between front and back cameras.
* @returns Promise that resolves when the camera is rotated
* @throws Error with "Not implemented" message
* @todo Implement camera rotation using Electron's media APIs
*/
async rotateCamera(): Promise<void> {
logger.error("rotateCamera not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should handle deep link URLs for the desktop application.
* @param _url - The deep link URL to handle
@@ -378,173 +345,4 @@ export class ElectronPlatformService implements PlatformService {
);
}
}
/**
* Tests the file sharing functionality.
* @returns Promise resolving to a test result message
*/
async testFileSharing(): Promise<string> {
return "File sharing not available in Electron platform - not implemented";
}
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
async testFileSaveOnly(): Promise<string> {
return "File save only not available in Electron platform - not implemented";
}
/**
* Tests the location selection functionality using the file picker.
* @returns Promise resolving to a test result message
*/
async testLocationSelection(): Promise<string> {
return "Location selection not available in Electron platform - not implemented";
}
/**
* Tests location selection without showing the dialog (restores original behavior).
* @returns Promise resolving to a test result message
*/
async testLocationSelectionSilent(): Promise<string> {
return "Location selection not available in Electron platform - not implemented";
}
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
async testListUserFiles(): Promise<string> {
return "File listing not available in Electron platform - not implemented";
}
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
async testBackupFiles(): Promise<string> {
return "Backup file listing not available in Electron platform - not implemented";
}
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
async testOpenBackupDirectory(): Promise<string> {
return "Directory access not available in Electron platform - not implemented";
}
/**
* Lists user-accessible files saved by the app.
* Not implemented in Electron platform.
* @returns Promise resolving to empty array
*/
async listUserAccessibleFiles(): Promise<
Array<{ name: string; uri: string; size?: number }>
> {
return [];
}
/**
* Lists backup files specifically saved by the app.
* Not implemented for Electron platform.
* @returns Promise resolving to empty array
*/
async listBackupFiles(): Promise<
Array<{
name: string;
uri: string;
size?: number;
type: "contacts" | "seed" | "other";
path?: string;
}>
> {
return [];
}
/**
* Opens a file in the device's default file viewer/app.
* Not implemented in Electron platform.
* @param _fileUri - URI of the file to open
* @param _fileName - Name of the file (for display purposes)
* @returns Promise resolving to error status
*/
async openFile(
_fileUri: string,
_fileName: string,
): Promise<{ success: boolean; error?: string }> {
return {
success: false,
error: "File opening not implemented in Electron platform",
};
}
/**
* Opens the directory containing backup files in the device's file explorer.
* Not implemented in Electron platform.
* @returns Promise resolving to error status
*/
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
return {
success: false,
error: "Directory access not implemented in Electron platform",
};
}
/**
* Lists files and folders in a specific directory for directory browsing.
* Not implemented for Electron platform.
* @returns Promise resolving to empty array
*/
async listFilesInDirectory(
_path: string,
_debugShowAll?: boolean,
): Promise<
Array<{
name: string;
uri: string;
size?: number;
path: string;
type: "file" | "folder";
}>
> {
return [];
}
/**
* Debug method to check what's actually in the TimeSafari directory.
* Not implemented for Electron platform.
* @returns Promise resolving to debug information
*/
async debugTimeSafariDirectory(): Promise<string> {
return "Electron platform does not support file system access for debugging TimeSafari directory.";
}
/**
* Creates a test backup file to verify file writing and reading functionality.
* Not implemented for Electron platform.
* @returns Promise resolving to error status
*/
async createTestBackupFile(): Promise<{
success: boolean;
fileName?: string;
uri?: string;
error?: string;
}> {
return {
success: false,
error:
"Electron platform does not support file system access for creating test backup files.",
};
}
/**
* Test method to try different directory contexts and see what files are available.
* Not implemented for Electron platform.
* @returns Promise resolving to debug information
*/
async testDirectoryContexts(): Promise<string> {
return "Electron platform does not support file system access for testing directory contexts.";
}
}

View File

@@ -32,7 +32,6 @@ export class PyWebViewPlatformService implements PlatformService {
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
isNativeApp: true,
};
}
@@ -123,211 +122,14 @@ export class PyWebViewPlatformService implements PlatformService {
}
/**
* Writes content to a file at the specified path and shares it.
* Not implemented in PyWebView platform.
* @param _fileName - The filename of the file to write
* @param _content - The content to write to the file
* @param _options - Optional parameters for file saving behavior
* @returns Promise that resolves to save/share result
* Should write and share a file using the Python backend.
* @param _fileName - Name of the file to write and share
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing and sharing through pywebview's Python-JavaScript bridge
*/
async writeAndShareFile(
_fileName: string,
_content: string,
_options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
},
): Promise<{
saved: boolean;
uri?: string;
shared: boolean;
error?: string;
}> {
return {
saved: false,
shared: false,
error: "File sharing not implemented in PyWebView platform",
};
}
/**
* Lists user-accessible files saved by the app.
* Not implemented in PyWebView platform.
* @returns Promise resolving to empty array
*/
async listUserAccessibleFiles(): Promise<
Array<{ name: string; uri: string; size?: number }>
> {
return [];
}
/**
* Lists backup files specifically saved by the app.
* Not implemented for PyWebView platform.
* @returns Promise resolving to empty array
*/
async listBackupFiles(): Promise<
Array<{
name: string;
uri: string;
size?: number;
type: "contacts" | "seed" | "other";
path?: string;
}>
> {
return [];
}
/**
* Opens a file in the device's default file viewer/app.
* Not implemented in PyWebView platform.
* @param _fileUri - URI of the file to open
* @param _fileName - Name of the file (for display purposes)
* @returns Promise resolving to error status
*/
async openFile(
_fileUri: string,
_fileName: string,
): Promise<{ success: boolean; error?: string }> {
return {
success: false,
error: "File opening not implemented in PyWebView platform",
};
}
/**
* Opens the directory containing backup files in the device's file explorer.
* Not implemented in PyWebView platform.
* @returns Promise resolving to error status
*/
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
return {
success: false,
error: "Directory access not implemented in PyWebView platform",
};
}
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
async testListUserFiles(): Promise<string> {
return "File listing not available in PyWebView platform - not implemented";
}
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
async testBackupFiles(): Promise<string> {
return "Backup file listing not available in PyWebView platform - not implemented";
}
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
async testOpenBackupDirectory(): Promise<string> {
return "Directory access not available in PyWebView platform - not implemented";
}
/**
* Tests the file sharing functionality.
* @returns Promise resolving to a test result message
*/
async testFileSharing(): Promise<string> {
return "File sharing not available in PyWebView platform - not implemented";
}
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
async testFileSaveOnly(): Promise<string> {
return "File saving not available in PyWebView platform - not implemented";
}
/**
* Tests the location selection functionality.
* @returns Promise resolving to a test result message
*/
async testLocationSelection(): Promise<string> {
return "Location selection not available in PyWebView platform - not implemented";
}
/**
* Tests location selection without showing the dialog.
* @returns Promise resolving to a test result message
*/
async testLocationSelectionSilent(): Promise<string> {
return "Silent location selection not available in PyWebView platform - not implemented";
}
/**
* Rotates the camera between front and back.
* Not implemented in PyWebView platform.
*/
async rotateCamera(): Promise<void> {
// Not implemented
}
/**
* Lists files and folders in a specific directory for directory browsing.
* Not implemented for PyWebView platform.
* @returns Promise resolving to empty array
*/
async listFilesInDirectory(
_path: string,
_debugShowAll?: boolean,
): Promise<
Array<{
name: string;
uri: string;
size?: number;
path: string;
type: "file" | "folder";
}>
> {
return [];
}
/**
* Debug method to check what's actually in the TimeSafari directory.
* Not implemented for PyWebView platform.
* @returns Promise resolving to debug information
*/
async debugTimeSafariDirectory(): Promise<string> {
return "PyWebView platform does not support file system access for debugging TimeSafari directory.";
}
/**
* Creates a test backup file to verify file writing and reading functionality.
* Not implemented for PyWebView platform.
* @returns Promise resolving to error status
*/
async createTestBackupFile(): Promise<{
success: boolean;
fileName?: string;
uri?: string;
error?: string;
}> {
return {
success: false,
error:
"PyWebView platform does not support file system access for creating test backup files.",
};
}
/**
* Test method to try different directory contexts and see what files are available.
* Not implemented for PyWebView platform.
* @returns Promise resolving to debug information
*/
async testDirectoryContexts(): Promise<string> {
return "PyWebView platform does not support file system access for testing directory contexts.";
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
logger.error("writeAndShareFile not implemented in PyWebView platform");
throw new Error("Not implemented");
}
}

View File

@@ -3,7 +3,7 @@ import {
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger, getTimestampForFilename } from "../../utils/logger";
import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
import databaseService from "../AbsurdSqlDatabaseService";
@@ -29,14 +29,10 @@ export class WebPlatformService implements PlatformService {
return {
hasFileSystem: false,
hasCamera: true, // Through file input with capture
isMobile:
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
),
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: true,
needsFileHandlingInstructions: false,
isNativeApp: false,
};
}
@@ -194,7 +190,7 @@ export class WebPlatformService implements PlatformService {
if (blob) {
resolve({
blob,
fileName: `photo-${getTimestampForFilename()}.jpg`,
fileName: `photo_${Date.now()}.jpg`,
});
} else {
reject(new Error("Failed to capture image from webcam"));
@@ -360,31 +356,10 @@ export class WebPlatformService implements PlatformService {
* Not supported in web platform.
* @param _fileName - Unused fileName parameter
* @param _content - Unused content parameter
* @param _options - Unused options parameter
* @returns Promise that resolves to a failure result
* @throws Error indicating file system access is not available
*/
async writeAndShareFile(
_fileName: string,
_content: string,
_options?: {
allowLocationSelection?: boolean;
saveToDownloads?: boolean;
saveToPrivateStorage?: boolean;
mimeType?: string;
showShareDialog?: boolean;
showLocationSelectionDialog?: boolean;
},
): Promise<{
saved: boolean;
uri?: string;
shared: boolean;
error?: string;
}> {
return {
saved: false,
shared: false,
error: "File system access not available in web platform",
};
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform");
}
/**
@@ -415,183 +390,4 @@ export class WebPlatformService implements PlatformService {
.query(sql, params)
.then((result: QueryExecResult[]) => result[0]?.values[0]);
}
/**
* Tests the file sharing functionality.
* @returns Promise resolving to a test result message
*/
async testFileSharing(): Promise<string> {
return "File sharing not available in web platform - use download instead";
}
/**
* Tests saving a file without showing the share dialog.
* @returns Promise resolving to a test result message
*/
async testFileSaveOnly(): Promise<string> {
return "File saving not available in web platform - use download instead";
}
/**
* Tests the location selection functionality using the file picker.
* @returns Promise resolving to a test result message
*/
async testLocationSelection(): Promise<string> {
return "Location selection not available in web platform - use download instead";
}
/**
* Tests location selection without showing the dialog (restores original behavior).
* @returns Promise resolving to a test result message
*/
async testLocationSelectionSilent(): Promise<string> {
return "Location selection not available in web platform - use download instead";
}
/**
* Tests listing user-accessible files saved by the app.
* @returns Promise resolving to a test result message
*/
async testListUserFiles(): Promise<string> {
return "File listing not available in web platform - files are downloaded directly";
}
/**
* Tests listing backup files specifically saved by the app.
* @returns Promise resolving to a test result message
*/
async testBackupFiles(): Promise<string> {
return "Backup file listing not available in web platform - files are downloaded directly";
}
/**
* Tests opening the backup directory in the device's file explorer.
* @returns Promise resolving to a test result message
*/
async testOpenBackupDirectory(): Promise<string> {
return "Directory access not available in web platform - files are downloaded directly";
}
/**
* Lists user-accessible files saved by the app.
* Not supported in web platform.
* @returns Promise resolving to empty array
*/
async listUserAccessibleFiles(): Promise<
Array<{ name: string; uri: string; size?: number }>
> {
return [];
}
/**
* Lists backup files specifically saved by the app.
* Not supported in web platform.
* @returns Promise resolving to empty array
*/
async listBackupFiles(): Promise<
Array<{
name: string;
uri: string;
size?: number;
type: "contacts" | "seed" | "other";
path?: string;
}>
> {
return [];
}
/**
* Opens a file in the device's default file viewer/app.
* Not supported in web platform.
* @param _fileUri - URI of the file to open
* @param _fileName - Name of the file (for display purposes)
* @returns Promise resolving to error status
*/
async openFile(
_fileUri: string,
_fileName: string,
): Promise<{ success: boolean; error?: string }> {
return {
success: false,
error: "File opening not available in web platform",
};
}
/**
* Opens the directory containing backup files in the device's file explorer.
* Not supported in web platform.
* @returns Promise resolving to error status
*/
async openBackupDirectory(): Promise<{ success: boolean; error?: string }> {
return {
success: false,
error: "Directory access not available in web platform",
};
}
/**
* Rotates the camera between front and back cameras.
* Not supported in web platform.
* @returns Promise that resolves immediately
*/
async rotateCamera(): Promise<void> {
// Not supported in web platform
return Promise.resolve();
}
/**
* Lists files and folders in a specific directory for directory browsing.
* Not supported in web platform.
* @returns Promise resolving to empty array
*/
async listFilesInDirectory(
_path: string,
_debugShowAll?: boolean,
): Promise<
Array<{
name: string;
uri: string;
size?: number;
path: string;
type: "file" | "folder";
}>
> {
return [];
}
/**
* Debug method to check what's actually in the TimeSafari directory.
* Not supported in web platform.
* @returns Promise resolving to debug information
*/
async debugTimeSafariDirectory(): Promise<string> {
return "Web platform does not support file system access for debugging TimeSafari directory.";
}
/**
* Creates a test backup file to verify file writing and reading functionality.
* Not supported in web platform.
* @returns Promise resolving to error status
*/
async createTestBackupFile(): Promise<{
success: boolean;
fileName?: string;
uri?: string;
error?: string;
}> {
return {
success: false,
error:
"Web platform does not support file system access for creating test backup files.",
};
}
/**
* Test method to try different directory contexts and see what files are available.
* Not supported in web platform.
* @returns Promise resolving to debug information
*/
async testDirectoryContexts(): Promise<string> {
return "Web platform does not support file system access for testing directory contexts.";
}
}

View File

@@ -1,6 +1,6 @@
import { logToDb } from "../db/databaseUtil";
function safeStringify(obj: unknown) {
export function safeStringify(obj: unknown) {
const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => {
@@ -67,8 +67,9 @@ export const logger = {
// Errors will always be logged
// eslint-disable-next-line no-console
console.error(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
const messageString = safeStringify(message);
const argsString = args.length > 0 ? safeStringify(args) : "";
logToDb(messageString + argsString);
},
};
@@ -79,22 +80,3 @@ if (typeof module !== "undefined" && module.exports) {
// Add default export for ESM
export default { logger };
/**
* Formats current timestamp for use in filenames.
* Returns ISO string with colons and periods replaced with hyphens, truncated to seconds.
* Format: 2024-01-15T14-30-45
* @returns Formatted timestamp string safe for filenames
*/
export function getTimestampForFilename(): string {
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
}
/**
* Formats current timestamp for use in filenames with date only.
* Format: 2024-01-15
* @returns Date-only timestamp string safe for filenames
*/
export function getDateForFilename(): string {
return new Date().toISOString().slice(0, 10);
}

View File

@@ -198,7 +198,7 @@ export default class ClaimAddRawView extends Vue {
this.apiServer,
this.axios,
);
if (result.type === "success") {
if (result.success) {
this.$notify(
{
group: "alert",

View File

@@ -46,23 +46,35 @@
</h2>
<div class="flex justify-center w-full">
<router-link
v-if="veriClaim.id"
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
class="text-blue-500 mt-2"
title="Printable Certificate"
title="View Printable Certificate"
>
<font-awesome
icon="square"
class="text-white bg-yellow-500 p-1"
/>
</router-link>
<button
v-if="veriClaim.id"
class="text-blue-500 ml-2 mt-2"
title="Copy Printable Certificate Link"
@click="
copyToClipboard(
'A link to the certificate page',
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
)
"
>
<font-awesome icon="link" class="text-yellow-500 p-1" />
</button>
</div>
<!-- show link icon to copy this URL to the clipboard -->
<div class="flex justify-end w-full">
<button
title="Copy Link"
@click="
copyToClipboard('A link to this page', window.location.href)
"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>
<font-awesome icon="link" class="text-slate-500" />
</button>
@@ -292,12 +304,17 @@
<div class="text-sm">
{{ didInfo(confirmerId) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<a :href="`/did/${confirmerId}`" class="text-blue-500">
<router-link
:to="{
path: '/did/' + encodeURIComponent(confirmerId),
}"
class="text-blue-500"
>
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</router-link>
</span>
</div>
</div>
@@ -329,12 +346,17 @@
<div class="text-sm">
{{ didInfo(confsVisibleTo) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(confsVisibleTo)">
<a :href="`/did/${confsVisibleTo}`" class="text-blue-500">
<router-link
:to="{
path: '/did/' + encodeURIComponent(confsVisibleTo),
}"
class="text-blue-500"
>
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</router-link>
</span>
</div>
</div>
@@ -394,7 +416,7 @@
contacts can see more details:
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -417,7 +439,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowLocation)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -443,12 +465,17 @@
<span>
{{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<a :href="`/did/${visDid}`" class="text-blue-500">
<router-link
:to="{
path: '/did/' + encodeURIComponent(visDid),
}"
class="text-blue-500"
>
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</router-link>
</span>
<span v-if="veriClaim.publicUrls?.[visDid]"
>, found at&nbsp;<a
@@ -530,7 +557,7 @@ import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { db } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
@@ -577,8 +604,9 @@ export default class ClaimView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href;
windowDeepLink = window.location.href; // changed in the setup for deep linking
APP_SERVER = APP_SERVER;
R = R;
yaml = yaml;
libsUtil = libsUtil;
@@ -655,6 +683,7 @@ export default class ClaimView extends Vue {
5000,
);
}
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
this.canShare = !!navigator.share;
}
@@ -925,7 +954,7 @@ export default class ClaimView extends Vue {
this.apiServer,
this.axios,
);
if (result.type === "success") {
if (result.success) {
this.$notify(
{
group: "alert",
@@ -990,11 +1019,11 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowLocation);
this.copyToClipboard("A link to this page", this.windowDeepLink);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowLocation,
url: this.windowDeepLink,
});
}

View File

@@ -407,14 +407,14 @@
</a>
</div>
<div class="mt-2 ml-2">
<a
<router-link
v-if="isRegistered"
class="text-blue-500 cursor-pointer"
:href="urlForNewGive"
:to="urlForNewGive"
>
<font-awesome icon="file-lines" />
Record a Give Similar to the Original
</a>
</router-link>
</div>
</div>
</div>
@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href;
windowLocation = window.location.href; // this is changed to a deep link in the setup
R = R;
yaml = yaml;
@@ -566,6 +566,9 @@ export default class ConfirmGiftView extends Vue {
}
const claimId = decodeURIComponent(pathParam);
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
await this.loadClaim(claimId, this.activeDid);
}
@@ -676,12 +679,12 @@ export default class ConfirmGiftView extends Vue {
/**
* Add participant (giver/recipient) name & URL info
*/
this.giverName = this.didInfo(this.giveDetails?.agentDid);
if (this.giveDetails?.agentDid) {
this.giverName = this.didInfo(this.giveDetails.agentDid);
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
}
this.recipientName = this.didInfo(this.giveDetails?.recipientDid);
if (this.giveDetails?.recipientDid) {
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
}
@@ -831,7 +834,7 @@ export default class ConfirmGiftView extends Vue {
this.apiServer,
this.axios,
);
if (result.type === "success") {
if (result.success) {
this.$notify(
{
group: "alert",

View File

@@ -104,6 +104,7 @@
</template>
<script lang="ts">
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
@@ -117,14 +118,20 @@ import { db } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import * as libsUtil from "../libs/util";
import { retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { setVisibilityUtil } from "../libs/endorserServer";
import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
setVisibilityUtil,
} from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { retrieveAccountMetadata } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { parseJsonField } from "../db/databaseUtil";
import { Account } from "@/db/tables/accounts";
interface QRScanResult {
rawValue?: string;
@@ -142,7 +149,7 @@ interface IUserNameDialog {
UserNameDialog,
},
})
export default class ContactQRScan extends Vue {
export default class ContactQRScanFull extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
@@ -151,6 +158,8 @@ export default class ContactQRScan extends Vue {
activeDid = "";
apiServer = "";
givenName = "";
isRegistered = false;
profileImageUrl = "";
qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@@ -172,19 +181,22 @@ export default class ContactQRScan extends Vue {
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(this.activeDid);
if (account) {
const name =
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : "");
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
settings.profileImageUrl || "",
false,
);
const publicKeyBase64 = Buffer.from(
account.publicKeyHex,
"hex",
).toString("base64");
this.qrValue =
CONTACT_CSV_HEADER +
"\n" +
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
}
} catch (error) {
logger.error("Error initializing component:", {
@@ -336,57 +348,69 @@ export default class ContactQRScan extends Vue {
logger.info("Processing QR code scan result:", rawValue);
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
});
return;
}
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
contact = {
did: did,
name: contactInfo.name || "",
publicKeyBase64: contactInfo.publicKeyBase64 || "",
seesMe: contactInfo.seesMe || false,
registered: contactInfo.registered || false,
};
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
const lines = rawValue.split(/\n/);
contact = libsUtil.csvLineToContact(lines[1]);
} else {
this.$notify({
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
title: "Error",
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
});
return;
}
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
const contact = {
did: did,
name: contactInfo.name || "",
email: contactInfo.email || "",
phone: contactInfo.phone || "",
company: contactInfo.company || "",
title: contactInfo.title || "",
notes: contactInfo.notes || "",
};
// Add contact but keep scanning
logger.info("Adding new contact to database:", {
did: contact.did,
@@ -468,7 +492,7 @@ export default class ContactQRScan extends Vue {
title: "Contact Exists",
text: "This contact has already been added to your list.",
},
3000,
5000,
);
return;
}
@@ -568,9 +592,19 @@ export default class ContactQRScan extends Vue {
);
}
onCopyUrlToClipboard() {
async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard()
.copy(this.qrValue)
.copy(jwtUrl)
.then(() => {
this.$notify(
{

View File

@@ -159,6 +159,7 @@
<script lang="ts">
import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
@@ -174,17 +175,20 @@ import * as databaseUtil from "../db/databaseUtil";
import { parseJsonField } from "../db/databaseUtil";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
register,
setVisibilityUtil,
} from "../libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { retrieveAccountMetadata } from "../libs/util";
import * as libsUtil from "../libs/util";
import { Router } from "vue-router";
import { logger } from "../utils/logger";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Account } from "@/db/tables/accounts";
interface QRScanResult {
rawValue?: string;
@@ -214,6 +218,7 @@ export default class ContactQRScanShow extends Vue {
isRegistered = false;
qrValue = "";
isScanning = false;
profileImageUrl = "";
error: string | null = null;
// QR Scanner properties
@@ -251,19 +256,21 @@ export default class ContactQRScanShow extends Vue {
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(this.activeDid);
const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
if (account) {
const name =
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : "");
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
settings.profileImageUrl || "",
false,
);
const publicKeyBase64 = Buffer.from(
account.publicKeyHex,
"hex",
).toString("base64");
this.qrValue =
CONTACT_CSV_HEADER +
"\n" +
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
}
} catch (error) {
logger.error("Error initializing component:", {
@@ -274,7 +281,7 @@ export default class ContactQRScanShow extends Vue {
group: "alert",
type: "danger",
title: "Initialization Error",
text: "Failed to initialize QR scanner. Please try again.",
text: "Failed to initialize QR renderer or scanner. Please try again.",
});
}
}
@@ -461,53 +468,68 @@ export default class ContactQRScanShow extends Vue {
logger.info("Processing QR code scan result:", rawValue);
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
});
return;
}
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
// Process JWT and contact info
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
contact = {
did: did,
name: contactInfo.name || "",
publicKeyBase64: contactInfo.publicKeyBase64 || "",
seesMe: contactInfo.seesMe || false,
registered: contactInfo.registered || false,
};
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
const lines = rawValue.split(/\n/);
contact = libsUtil.csvLineToContact(lines[1]);
} else {
this.$notify({
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
title: "Error",
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
});
return;
}
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
const contact = {
did: did,
name: contactInfo.name || "",
notes: contactInfo.notes || "",
};
// Add contact but keep scanning
logger.info("Adding new contact to database:", {
did: contact.did,
@@ -649,12 +671,20 @@ export default class ContactQRScanShow extends Vue {
});
}
onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard()
.copy(this.qrValue)
.copy(jwtUrl)
.then(() => {
// console.log("Contact URL:", this.qrValue);
this.$notify(
{
group: "alert",
@@ -772,7 +802,7 @@ export default class ContactQRScanShow extends Vue {
title: "Contact Exists",
text: "This contact has already been added to your list.",
},
3000,
5000,
);
return;
}

View File

@@ -126,7 +126,6 @@
<div class="flex items-center gap-2">
<button
v-if="showGiveNumbers"
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
:class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()"
@@ -142,7 +141,6 @@
</button>
<button
href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()"
>
@@ -493,7 +491,7 @@ export default class ContactsView extends Vue {
private async processContactJwt() {
// handle a contact sent via URL
//
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
// For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) {
@@ -619,7 +617,7 @@ export default class ContactsView extends Vue {
title: "Error with Invite",
text: message,
},
5000,
-1,
);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
@@ -935,45 +933,9 @@ export default class ContactsView extends Vue {
}
private async addContactFromEndorserMobileLine(
line: string,
lineRaw: string,
): Promise<IndexableType> {
// Note that Endorser Mobile puts name first, then did, etc.
let name = line;
let did = "";
let publicKeyInput, seesMe, registered;
const commaPos1 = line.indexOf(",");
if (commaPos1 > -1) {
name = line.substring(0, commaPos1).trim();
did = line.substring(commaPos1 + 1).trim();
const commaPos2 = line.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
did = line.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = line.substring(commaPos2 + 1).trim();
const commaPos3 = line.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
seesMe = line.substring(commaPos3 + 1).trim() == "true";
const commaPos4 = line.indexOf(",", commaPos3 + 1);
if (commaPos4 > -1) {
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
registered = line.substring(commaPos4 + 1).trim() == "true";
}
}
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact = {
did,
name,
publicKeyBase64,
seesMe,
registered,
};
const newContact = libsUtil.csvLineToContact(lineRaw);
const platformService = PlatformServiceFactory.getInstance();
const { sql, params } = databaseUtil.generateInsertStatement(
newContact as unknown as Record<string, unknown>,
@@ -1160,7 +1122,7 @@ export default class ContactsView extends Vue {
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
-1,
);
}
} catch (error) {
@@ -1194,7 +1156,7 @@ export default class ContactsView extends Vue {
title: "Registration Error",
text: userMessage,
},
5000,
-1,
);
}
}
@@ -1215,7 +1177,6 @@ export default class ContactsView extends Vue {
);
if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
if (showSuccessAlert) {
this.$notify(
{
@@ -1431,14 +1392,11 @@ export default class ContactsView extends Vue {
}
return contact;
});
// console.log(
// "Array of selected contacts:",
// JSON.stringify(selectedContacts),
// );
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts,
});
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt;
const contactsJwtUrl =
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
useClipboard()
.copy(contactsJwtUrl)
.then(() => {

View File

@@ -66,9 +66,14 @@ const formattedPath = computed(() => {
const path = originalPath.value.replace(/^\/+/, "");
// Log for debugging
logger.log("Original Path:", originalPath.value);
logger.log("Route Params:", route.params);
logger.log("Route Query:", route.query);
logger.log(
"[DeepLinkError] Original Path:",
originalPath.value,
"Route Params:",
route.params,
"Route Query:",
route.query,
);
return path;
});

View File

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

View File

@@ -523,9 +523,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
logger.error("Error with search all:", e);
// this sometimes gives different information
logger.error("Error with search all (error added): " + e);
logger.error("Error with search all: " + errorStringForLog(e));
this.$notify(
{
group: "alert",
@@ -617,7 +615,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
logger.error("Error with search local:", e);
logger.error("Error with search local: " + errorStringForLog(e));
this.$notify(
{
group: "alert",
@@ -788,7 +786,7 @@ export default class DiscoverView extends Vue {
const route = {
path: this.isProjectsActive
? "/project/" + encodeURIComponent(id)
: "/userProfile/" + encodeURIComponent(id),
: "/user-profile/" + encodeURIComponent(id),
};
this.$router.push(route);
}

View File

@@ -826,7 +826,7 @@ export default class GiftedDetails extends Vue {
}
if (!result.success) {
const errorMessage = this.getGiveCreationErrorMessage(result);
const errorMessage = result.error;
logger.error("Error with give creation result:", result);
this.$notify(
{
@@ -899,19 +899,6 @@ export default class GiftedDetails extends Vue {
// Helper functions for readability
/**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getGiveCreationErrorMessage(result: any) {
return (
result.error?.userMessage ||
result.error?.error ||
result.response?.data?.error?.message
);
}
explainData() {
this.$notify(
{

View File

@@ -24,11 +24,11 @@
<!-- eslint-disable prettier/prettier max-len -->
<div>
<p>
This app focuses on gifts & gratitude, using them to build cool things together with your network.
This app focuses on raw gratitude, using it to build cool things together with your network.
</p>
<p class="ml-4">
If you'd like to see the page-by-page help,
If you'd like to see the page-by-page help again,
<span
class="text-blue-500 cursor-pointer"
@click="unsetFinishedOnboarding()"
@@ -37,14 +37,16 @@
<h2 class="text-xl font-semibold">What is the idea here?</h2>
<p>
We are building networks of people who want to grow good society from the ground up, using modern
technology that connects people peer-to-peer.
First of all, let's showcase gratitude: see what people have given, and recognize
gifts you've seen. This is done in a way that leaves a permanent record -- one that
came from you, and one that the recipient can prove it was for them. This can be
personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and they can selectively show off their contributions
and network.
We are building networks of people who want to grow good society from the ground up, using
modern technology that connects people peer-to-peer.
First of all, let's showcase gratitude: see what people have given, and recognize gifts
you've seen. This is done in a way that leaves a permanent record -- one that provably
came from you, and one that the recipient can prove they were mentioned.
This can be personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and they can selectively show off their contributions and
network.
This is a way to build trust and reputation. It's a way to build a network of people who
are willing to help each other.
</p>
<p class="mt-2">
With this, you highlight giving and you also offer help --
@@ -555,9 +557,6 @@
initiative.
</p>
<h2 class="text-xl font-semibold">What app version is this?</h2>
<p>{{ package.version }} ({{ commitHash }})</p>
<h2 class="text-xl font-semibold">
I have other questions or feedback, like getting a new profile or removing my data or requesting an improvement.
</h2>
@@ -567,6 +566,28 @@
>info@TimeSafari.app</a
>
</p>
<h2 class="text-xl font-semibold">What app version is this?</h2>
<p>{{ package.version }} ({{ commitHash }})</p>
<div v-if="Capacitor.isNativePlatform()">
<h2 class="text-xl font-semibold">
Do I have the latest version?
</h2>
<p v-if="Capacitor.getPlatform() === 'ios'">
<a href="https://apps.apple.com/us/app/time-safari/id6742664907" target="_blank" class="text-blue-500">
Check the App Store.
</a>
</p>
<p v-else-if="Capacitor.getPlatform() === 'android'">
<a href="https://timesafari.app/app.apk" target="_blank" class="text-blue-500">
Download the latest APK to see.
</a>
</p>
<p v-else>
Sorry, your platform of '{{ Capacitor.getPlatform() }}' is not recognized.
</p>
</div>
</div>
<!-- eslint enable -->
</section>
@@ -603,6 +624,7 @@ export default class HelpView extends Vue {
showVerifiable = false;
APP_SERVER = APP_SERVER;
Capacitor = Capacitor;
// Ideally, we put no functionality in here, especially in the setup,
// because we never want this page to have a chance of throwing an error.

View File

@@ -519,7 +519,6 @@ export default class HomeView extends Vue {
// Retrieve DIDs with better error handling
try {
this.allMyDids = await retrieveAccountDids();
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
} catch (error) {
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
throw new Error(
@@ -552,9 +551,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logConsoleAndDb(
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
);
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve settings: ${error}`,
@@ -581,9 +577,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
logConsoleAndDb(
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
);
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve contacts: ${error}`,
@@ -641,9 +634,6 @@ export default class HomeView extends Vue {
});
}
this.isRegistered = true;
logConsoleAndDb(
`[HomeView] User ${this.activeDid} is now registered`,
);
}
} catch (error) {
logConsoleAndDb(
@@ -685,11 +675,6 @@ export default class HomeView extends Vue {
this.newOffersToUserHitLimit = offersToUser.hitLimit;
this.numNewOffersToUserProjects = offersToProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
logConsoleAndDb(
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
`${this.numNewOffersToUserProjects} project offers`,
);
}
} catch (error) {
logConsoleAndDb(
@@ -1843,7 +1828,7 @@ export default class HomeView extends Vue {
this.axios,
);
if (result.type === "success") {
if (result.success) {
this.$notify(
{
group: "alert",

View File

@@ -83,7 +83,7 @@
<span
v-else
class="text-center text-slate-500 cursor-pointer"
:title="inviteLink(invite.jwt)"
:title="invite.inviteIdentifier"
@click="
showInvite(
invite.inviteIdentifier,
@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
}
inviteLink(jwt: string): string {
return APP_SERVER + "/invite-one-accept/" + jwt;
return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
}
copyInviteAndNotify(inviteId: string, jwt: string) {
@@ -324,7 +324,7 @@ export default class InviteOneView extends Vue {
);
await axios.post(
this.apiServer + "/api/userUtil/invite",
{ inviteIdentifier, inviteJwt, notes, expiresAt },
{ inviteJwt, notes, expiresAt },
{ headers },
);
const newInvite = {

View File

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

View File

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

View File

@@ -155,7 +155,7 @@ import { Contact } from "../db/tables/contacts";
import {
GenericCredWrapper,
GenericVerifiableCredential,
ErrorResult,
CreateAndSubmitClaimResult,
} from "../interfaces";
import {
BVC_MEETUPS_PROJECT_CLAIM_ID,
@@ -298,28 +298,29 @@ export default class QuickActionBvcBeginView extends Vue {
}
// in parallel, make a confirmation for each selected claim and send them all to the server
const confirmResults = await Promise.allSettled(
this.claimsToConfirmSelected.map(async (jwtId) => {
const record = this.claimsToConfirm.find(
(claim) => claim.id === jwtId,
);
if (!record) {
return { type: "error", error: "Record not found." };
}
return createAndSubmitConfirmation(
this.activeDid,
record.claim as GenericVerifiableCredential,
record.id,
record.handleId,
this.apiServer,
axios,
);
}),
);
const confirmResults: PromiseSettledResult<CreateAndSubmitClaimResult>[] =
await Promise.allSettled(
this.claimsToConfirmSelected.map(async (jwtId) => {
const record = this.claimsToConfirm.find(
(claim) => claim.id === jwtId,
);
if (!record) {
return { success: false, error: "Record not found." };
}
return createAndSubmitConfirmation(
this.activeDid,
record.claim as GenericVerifiableCredential,
record.id,
record.handleId,
this.apiServer,
axios,
);
}),
);
// check for any rejected confirmations
const confirmsSucceeded = confirmResults.filter(
(result) =>
result.status === "fulfilled" && result.value.type === "success",
// 'fulfilled' is the status in a successful PromiseFulfilledResult
(result) => result.status === "fulfilled" && result.value.success,
);
if (confirmsSucceeded.length < this.claimsToConfirmSelected.length) {
logger.error("Error sending confirmations:", confirmResults);
@@ -353,7 +354,7 @@ export default class QuickActionBvcBeginView extends Vue {
undefined,
BVC_MEETUPS_PROJECT_CLAIM_ID,
);
giveSucceeded = giveResult.type === "success";
giveSucceeded = giveResult.success;
if (!giveSucceeded) {
logger.error("Error sending give:", giveResult);
this.$notify(
@@ -362,7 +363,7 @@ export default class QuickActionBvcBeginView extends Vue {
type: "danger",
title: "Error",
text:
(giveResult as ErrorResult)?.error?.userMessage ||
(giveResult as CreateAndSubmitClaimResult)?.error ||
"There was an error sending that give.",
},
5000,

View File

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

View File

@@ -215,65 +215,6 @@
</div>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">File Sharing Test</h2>
Test the new file sharing functionality that saves to user-accessible
locations.
<div>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testFileSharing()"
>
Test File Sharing
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testFileSaveOnly()"
>
Test Save Only (No Share Dialog)
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testLocationSelection()"
>
Test Location Selection
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testLocationSelectionSilent()"
>
Test Silent Location Selection
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testListUserFiles()"
>
List User Files
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testBackupFiles()"
>
Test Backup Files
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testOpenBackupDirectory()"
>
Test Open Directory
</button>
<button
class="font-bold capitalize bg-slate-500 text-white px-3 py-2 rounded-md mr-2"
@click="testFileDiscoveryDebug()"
>
Debug File Discovery
</button>
<div v-if="fileSharingResult" class="mt-2 p-2 bg-gray-100 rounded">
<strong>Result:</strong> {{ fileSharingResult }}
</div>
</div>
</div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target".
@@ -446,9 +387,6 @@ export default class Help extends Vue {
sqlQuery = "";
sqlResult: unknown = null;
// for file sharing test
fileSharingResult = "";
cryptoLib = cryptoLib;
async mounted() {
@@ -682,174 +620,5 @@ export default class Help extends Vue {
);
}
}
public async testFileSharing() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testFileSharing();
this.fileSharingResult = result;
logger.log("File Sharing Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("File Sharing Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "File Sharing Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testFileSaveOnly() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testFileSaveOnly();
this.fileSharingResult = result;
logger.log("File Save Only Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("File Save Only Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "File Save Only Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testLocationSelection() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testLocationSelection();
this.fileSharingResult = result;
logger.log("Location Selection Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("Location Selection Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Location Selection Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testLocationSelectionSilent() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testLocationSelectionSilent();
this.fileSharingResult = result;
logger.log(
"Silent Location Selection Test Result:",
this.fileSharingResult,
);
} catch (error) {
logger.error("Silent Location Selection Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Silent Location Selection Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testListUserFiles() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testListUserFiles();
this.fileSharingResult = result;
logger.log("List User Files Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("List User Files Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "List User Files Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testBackupFiles() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testBackupFiles();
this.fileSharingResult = result;
logger.log("Backup Files Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("Backup Files Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Backup Files Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testOpenBackupDirectory() {
const platformService = PlatformServiceFactory.getInstance();
try {
const result = await platformService.testOpenBackupDirectory();
this.fileSharingResult = result;
logger.log("Open Backup Directory Test Result:", this.fileSharingResult);
} catch (error) {
logger.error("Open Backup Directory Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Open Backup Directory Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
public async testFileDiscoveryDebug() {
const platformService = PlatformServiceFactory.getInstance();
try {
if ("debugFileDiscoveryStepByStep" in platformService) {
const result = await (
platformService as any
).debugFileDiscoveryStepByStep();
this.fileSharingResult = result;
logger.log("File Discovery Debug Test Result:", this.fileSharingResult);
} else {
this.fileSharingResult = "Debug method not available on this platform";
}
} catch (error) {
logger.error("File Discovery Debug Test Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "File Discovery Debug Error",
text: error instanceof Error ? error.message : String(error),
},
5000,
);
}
}
}
</script>

View File

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