Compare commits

...

31 Commits

Author SHA1 Message Date
002f240720 bump to version 1.0.4 and build 37 2025-07-20 20:37:26 -06:00
ffe8d90161 fix: linting 2025-07-20 19:55:37 -06:00
6d6816d1a8 Merge pull request 'Deep-link fixes' (#145) from deep-link into master
Reviewed-on: #145
2025-07-15 02:49:12 -04:00
c1477d0266 Merge branch 'master' into deep-link 2025-07-14 23:42:21 -04:00
33ce6bdb72 fix: invite-one-accept deep link would not route properly 2025-07-14 20:49:40 -06:00
dc21e8dac3 bump version number and add '-beta' 2025-07-12 22:10:53 -06:00
a9a8ba217c bump to version 1.0.3 build 36 2025-07-12 22:10:07 -06:00
b0d99e7c1e fix: quick-and-dirty fix to get the correct environment variables 2025-07-12 20:17:38 -06:00
a96cc8155c fix incorrect checks for success 2025-07-04 16:58:18 -06:00
861408c7bc Consolidate deep-link paths to be derived from the same source so they don't get out of sync any more. 2025-07-03 17:01:08 -06:00
1b283a0045 Merge pull request 'Lock to Portrait Mode (iOS and Android)' (#141) from app-portrait-mode-lock into master
Reviewed-on: #141
2025-06-27 21:47:11 -04:00
afd407e178 add portrait-mode camera to CHANGELOG 2025-06-27 19:46:30 -06:00
Jose Olarte III
59b13823c8 Feature: lock orientation mode 2025-06-23 17:39:21 +08:00
3baa6633a6 on mobile: bump version to 1.0.2 and build to 35 2025-06-20 20:27:16 -06:00
bda98eb632 reword the account-download button 2025-06-20 19:36:16 -06:00
eea1cb995a bump to version 1.0.3-beta 2025-06-20 19:27:07 -06:00
276e0a741b put version on front page so that people can tell whether to refresh 2025-06-20 19:03:50 -06:00
e46d6133fb bump to version 1.0.1 2025-06-20 15:56:47 -06:00
94994a7251 allow blocking another person's content from this user (with iViewContent contact field) 2025-06-20 15:53:31 -06:00
838723c26b remove debugging info messages (change to debug if we want these -- and tell us how to turn off debug locally) 2025-06-20 14:01:08 -06:00
bb6eb92ba1 fix ? instead of 0 in rate limits, update location verbiage 2025-06-20 13:34:14 -06:00
a997d4cb92 Merge branch 'migrate-dexie-to-sqlite' 2025-06-20 11:49:51 -06:00
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
46 changed files with 909 additions and 411 deletions

View File

@@ -41,6 +41,7 @@ Install dependencies:
1. Run the production build:
```bash
rm -rf dist
npm run build:web
```
@@ -60,13 +61,17 @@ Install dependencies:
* `npx prettier --write ./sw_scripts/`
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json.
* Run install & build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build:web`
* If also deploying to mobile, update the new version in the ios & android filed.
* Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version in the step later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 1.0.0 && git push origin 1.0.0`.
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 1.0.2 && git push origin 1.0.2`.
* For test, build the app (because test server is not yet set up to build):
@@ -90,11 +95,11 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.0 && npm install && npm run build:web && cd -`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web && cd -`
(The plain `npm run build:web` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-1 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-2 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, commit, and push. Also record what version is on production.
@@ -359,12 +364,9 @@ Prerequisites: macOS with Xcode installed
4. Bump the version to match Android & package.json:
```
cd ios/App
xcrun agvtool new-version 33
cd ios/App && xcrun agvtool new-version 37 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.4;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.7;/g" > temp && mv temp App.xcodeproj/project.pbxproj
cd -
```
5. Open the project in Xcode:
@@ -385,11 +387,12 @@ Prerequisites: macOS with Xcode installed
* This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly.
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`).
* Click Distribute -> App Store Connect
* In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
* In AppStoreConnect, add the build to the distribution. You may have to remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
* May have to go to App Review, click Submission, then hover over the build and click "-".
* It can take 15 minutes for the build to show up in the list of builds.
* You'll probably have to "Manage" something about encryption, disallowed in France.
* Then "Save" and "Add to Review" and "Resubmit to App Review".
* Eventually it'll be "Ready for Distribution" which means
### Android Build

View File

@@ -6,12 +6,42 @@ 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).
## [1.0.4] - 2025.07.20
### Fixed
- Deep link for invite-one-accept
## [1.0.3] - 2025.07.12
### Changed
- Photo is pinned to profile mode
### Fixed
- Deep link URLs (and other prod settings)
- Error in BVC begin view
## [1.0.2] - 2025.06.20 - 276e0a741bc327de3380c4e508cccb7fee58c06d
### Added
- Version on feed title
## [1.0.1] - 2025.06.20
### Added
- Allow a user to block someone else's content from view
## [1.0.0] - 2025.06.20 - 5aa693de6337e5dbb278bfddc6bd39094bc14f73
### Added
- Web-oriented migration from IndexedDB to SQLite
## [0.5.8]
### Added
- /deep-link/ path for URLs that are shared with people
### Changed
- External links now go to /deep-link/...
- Feed visuals now have arrow imagery from giver to receiver
## [0.4.7]
### Fixed
- Cameras everywhere

View File

@@ -55,7 +55,7 @@ See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
Application icons are in the `assets` directory, processed by the `capacitor-assets` command.
To add a Font Awesome icon, add to main.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
To add a Font Awesome icon, add to fontawesome.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
## Other

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.0.1-beta",
"version": "1.0.4",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"

View File

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

View File

@@ -34,7 +34,6 @@ const secretBase64 = arrayBufferToBase64(randomBytes);
const MIGRATIONS = [
{
name: "001_initial",
// see ../db/tables files for explanations of the fields
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -119,6 +118,12 @@ const MIGRATIONS = [
);
`,
},
{
name: "002_add_iViewContent_to_contacts",
sql: `
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
},
];
/**

View File

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

View File

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

View File

@@ -27,21 +27,49 @@
*/
import { z } from "zod";
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [
"user-profile",
"project",
"onboard-meeting-setup",
"invite-one-accept",
"contact-import",
"confirm-gift",
"claim",
"claim-cert",
"claim-add-raw",
"contact-edit",
"contacts",
"did",
] as const;
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
claim: z.object({
id: z.string(),
}),
"claim-add-raw": z.object({
id: z.string(),
claim: z.string().optional(),
claimJwtId: z.string().optional(),
}),
"claim-cert": z.object({
id: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
"contact-edit": z.object({
did: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
contacts: z.object({
contactJwt: z.string().optional(),
inviteJwt: z.string().optional(),
}),
did: z.object({
did: z.string(),
}),
"invite-one-accept": z.object({
// optional because A) it could be a query param, and B) the page displays an input if things go wrong
jwt: z.string().optional(),
}),
"onboard-meeting-members": z.object({
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"user-profile": z.object({
id: z.string(),
}),
};
// Create a type from the array
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
@@ -53,50 +81,10 @@ export const baseUrlSchema = z.object({
queryParams: z.record(z.string()).optional(),
});
// Use the type to ensure route validation
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
"user-profile": z.object({
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
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(),
}),
contacts: z.object({
contacts: z.string(), // JSON string of contacts array
}),
did: z.object({
did: z.string(),
}),
};
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = Object.keys(
deepLinkSchemas,
) as readonly (keyof typeof deepLinkSchemas)[];
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
@@ -106,3 +94,8 @@ export interface DeepLinkError extends Error {
code: string;
details?: unknown;
}
// Use the type to ensure route validation
export const routeSchema = z.enum(
VALID_DEEP_LINK_ROUTES as [string, ...string[]],
);

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -5,9 +5,6 @@ import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.info("[Web] PWA enabled", { pwa_enabled });
logger.info("[Web] Platform", { platform });
// Only import service worker for web builds
if (platform !== "electron" && pwa_enabled) {
import("./registerServiceWorker"); // Web PWA support
@@ -31,7 +28,7 @@ function sqlInit() {
if (platform === "web" || platform === "development") {
sqlInit();
} else {
logger.info("[Web] SQL not initialized for platform", { platform });
logger.warn("[Web] SQL not initialized for platform", { platform });
}
app.mount("#app");

View File

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

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: 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);
@@ -46,6 +44,8 @@
*/
import { Router } from "vue-router";
import { z } from "zod";
import {
deepLinkSchemas,
baseUrlSchema,
@@ -55,6 +55,38 @@ import {
import { logConsoleAndDb } from "../db/databaseUtil";
import type { DeepLinkError } from "../interfaces/deepLinks";
// Helper function to extract the first key from a Zod object schema
function getFirstKeyFromZodObject(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: z.ZodObject<any>,
): string | undefined {
const shape = schema.shape;
const keys = Object.keys(shape);
return keys.length > 0 ? keys[0] : undefined;
}
/**
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
*
* It's an object where keys are the deep link routes and values are objects with 'name' and 'paramKey'.
*
* The paramKey is used to extract the parameter from the route path,
* because "router.replace" expects the right parameter name for the route.
*/
export const ROUTE_MAP: Record<string, { name: string; paramKey?: string }> =
Object.entries(deepLinkSchemas).reduce(
(acc, [routeName, schema]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject<any>);
acc[routeName] = {
name: routeName,
paramKey,
};
return acc;
},
{} as Record<string, { name: string; paramKey?: string }>,
);
/**
* Handles processing and routing of deep links in the application.
* Provides validation, error handling, and routing for deep link URLs.
@@ -71,35 +103,13 @@ export class DeepLinkHandler {
}
/**
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
*
* The paramKey is used to extract the parameter from the route path,
* because "router.replace" expects the right parameter name for the route.
* The default is "id".
*/
private readonly ROUTE_MAP: Record<
string,
{ name: string; paramKey?: string }
> = {
claim: { name: "claim" },
"claim-add-raw": { name: "claim-add-raw" },
"claim-cert": { name: "claim-cert" },
"confirm-gift": { name: "confirm-gift" },
did: { name: "did", paramKey: "did" },
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
"onboard-meeting-members": { name: "onboard-meeting-members" },
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
project: { name: "project" },
"user-profile": { name: "user-profile" },
};
/**
* Parses deep link URL into path, params and query components.
* Validates URL structure using Zod schemas.
*
* @param url - The deep link URL to parse (format: scheme://path[?query])
* @throws {DeepLinkError} If URL format is invalid
* @returns Parsed URL components (path, params, query)
* @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
*/
private parseDeepLink(url: string) {
const parts = url.split("://");
@@ -115,10 +125,10 @@ export class DeepLinkHandler {
});
const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/");
const [routePath, ...pathParams] = path.split("/");
// Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) {
if (!ROUTE_MAP[routePath]) {
throw {
code: "INVALID_ROUTE",
message: `Invalid route path: ${routePath}`,
@@ -134,45 +144,19 @@ 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;
const routeConfig = ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
}
// logConsoleAndDb(
// `[DeepLink] Debug: Route Path: ${routePath} Path Params: ${JSON.stringify(params)} Query String: ${JSON.stringify(query)}`,
// false,
// );
return { path: routePath, params, query };
}
/**
* 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.
@@ -193,7 +177,7 @@ export class DeepLinkHandler {
try {
// Validate route exists
const validRoute = routeSchema.parse(path) as DeepLinkRoute;
routeName = this.ROUTE_MAP[validRoute].name;
routeName = ROUTE_MAP[validRoute].name;
} catch (error) {
// Log the invalid route attempt
logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true);
@@ -201,48 +185,98 @@ export class DeepLinkHandler {
// Redirect to error page with information about the invalid link
await this.router.replace({
name: "deep-link-error",
params,
query: {
originalPath: path,
errorCode: "INVALID_ROUTE",
message: `The link you followed (${path}) is not supported`,
errorMessage: `The link you followed (${path}) is not supported`,
...query,
},
});
throw {
code: "INVALID_ROUTE",
message: `Unsupported route: ${path}`,
};
// This previously threw an error but we're redirecting so there's no need.
return;
}
// Continue with parameter validation as before...
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
let validatedParams, validatedQuery;
try {
const validatedParams = await schema.parseAsync({
...params,
...query,
});
await this.router.replace({
name: routeName,
params: validatedParams,
query,
});
validatedParams = await schema.parseAsync(params);
validatedQuery = await schema.parseAsync(query);
} catch (error) {
// For parameter validation errors, provide specific error feedback
logConsoleAndDb(
`[DeepLink] Invalid parameters for route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with params: ${JSON.stringify(params)} ... and query: ${JSON.stringify(query)}`,
true,
);
await this.router.replace({
name: "deep-link-error",
params,
query: {
originalPath: path,
errorCode: "INVALID_PARAMETERS",
message: `The link parameters are invalid: ${(error as Error).message}`,
errorMessage: `The link parameters are invalid: ${(error as Error).message}`,
...query,
},
});
// This previously threw an error but we're redirecting so there's no need.
return;
}
try {
await this.router.replace({
name: routeName,
params: validatedParams,
query: validatedQuery,
});
} catch (error) {
logConsoleAndDb(
`[DeepLink] Error routing to route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with validated params: ${JSON.stringify(validatedParams)} ... and validated query: ${JSON.stringify(validatedQuery)}`,
true,
);
// For parameter validation errors, provide specific error feedback
await this.router.replace({
name: "deep-link-error",
params: validatedParams,
query: {
originalPath: path,
errorCode: "ROUTING_ERROR",
errorMessage: `Error routing to ${routeName}: ${JSON.stringify(error)}`,
...validatedQuery,
},
});
}
}
/**
* 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 {
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.details}`,
true,
);
throw {
code: "INVALID_PARAMETERS",
message: (error as Error).message,
details: error,
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
};
}
}

View File

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

View File

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

View File

@@ -1,6 +1,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) => {
@@ -52,23 +52,18 @@ export const logger = {
}
},
warn: (message: string, ...args: unknown[]) => {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor" ||
process.env.VITE_PLATFORM === "electron"
) {
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
}
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
},
error: (message: string, ...args: unknown[]) => {
// Errors will always be logged
// eslint-disable-next-line no-console
console.error(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
const messageString = safeStringify(message);
const argsString = args.length > 0 ? safeStringify(args) : "";
logToDb(messageString + argsString);
},
};

View File

@@ -349,8 +349,9 @@
</div>
<div v-if="includeUserProfileLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
For your security, choose a location nearby but not exactly at your
place.
The location you choose will be shared with the world until you remove
this checkbox. For your security, choose a location nearby but not
exactly at your true location, like at your town center.
</p>
<l-map
@@ -435,11 +436,11 @@
<p class="text-sm">
You have done
<b
>{{ endorserLimits?.doneClaimsThisWeek || "?" }} claim{{
>{{ endorserLimits?.doneClaimsThisWeek ?? "?" }} claim{{
endorserLimits?.doneClaimsThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this
out of <b>{{ endorserLimits?.maxClaimsPerWeek ?? "?" }}</b> for this
week. Your claims counter resets at
<b class="whitespace-nowrap">{{
readableDate(endorserLimits?.nextWeekBeginDateTime)
@@ -449,14 +450,14 @@
You have done
<b
>{{
endorserLimits?.doneRegistrationsThisMonth || "?"
endorserLimits?.doneRegistrationsThisMonth ?? "?"
}}
registration{{
endorserLimits?.doneRegistrationsThisMonth === 1 ? "" : "s"
}}</b
>
out of
<b>{{ endorserLimits?.maxRegistrationsPerMonth || "?" }}</b> for this
<b>{{ endorserLimits?.maxRegistrationsPerMonth ?? "?" }}</b> for this
this month.
<i>(You cannot register anyone on your first day.)</i>
Your registration counter resets at
@@ -467,11 +468,11 @@
<p class="mt-3 text-sm">
You have uploaded
<b
>{{ imageLimits?.doneImagesThisWeek || "?" }} image{{
>{{ imageLimits?.doneImagesThisWeek ?? "?" }} image{{
imageLimits?.doneImagesThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this
out of <b>{{ imageLimits?.maxImagesPerWeek ?? "?" }}</b> for this
week. Your image counter resets at
<b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -12,6 +12,7 @@ Raymer * @version 1.0.0 */
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
{{ AppString.APP_NAME }}
<span class="text-xs text-gray-500">{{ package.version }}</span>
</h1>
<OnboardingDialog ref="onboardingDialog" />
@@ -353,6 +354,7 @@ import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "../interfaces/give";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import * as Package from "../../package.json";
interface Claim {
claim?: Claim; // For nested claims in Verifiable Credentials
@@ -443,11 +445,13 @@ export default class HomeView extends Vue {
AppString = AppString;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
package = Package;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
blockedContactDids: Array<string> = [];
feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string;
feedLastViewedClaimId?: string;
@@ -519,7 +523,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 +555,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logConsoleAndDb(
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
);
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve settings: ${error}`,
@@ -571,25 +571,14 @@ export default class HomeView extends Vue {
// Load contacts with graceful fallback
try {
const platformService = PlatformServiceFactory.getInstance();
const dbContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
this.allContacts = databaseUtil.mapQueryResultToValues(
dbContacts,
) as Contact[];
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
logConsoleAndDb(
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
);
this.loadContacts();
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve contacts: ${error}`,
true,
);
this.allContacts = []; // Ensure we have a valid empty array
this.blockedContactDids = [];
this.$notify(
{
group: "alert",
@@ -641,9 +630,6 @@ export default class HomeView extends Vue {
});
}
this.isRegistered = true;
logConsoleAndDb(
`[HomeView] User ${this.activeDid} is now registered`,
);
}
} catch (error) {
logConsoleAndDb(
@@ -685,11 +671,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(
@@ -761,6 +742,9 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
this.blockedContactDids = this.allContacts
.filter((c) => !c.iViewContent)
.map((c) => c.did);
}
/**
@@ -1028,6 +1012,7 @@ export default class HomeView extends Vue {
);
if (results.data.length > 0) {
endOfResults = false;
// gather any contacts that user has blocked from view
await this.processFeedResults(results.data);
await this.updateFeedLastViewedId(results.data);
}
@@ -1215,7 +1200,7 @@ export default class HomeView extends Vue {
}
/**
* Checks if record should be included based on filters
* Checks if record should be included based on filters & preferences
*
* @internal
* @callGraph
@@ -1241,6 +1226,10 @@ export default class HomeView extends Vue {
record: GiveSummaryRecord,
fulfillsPlan?: FulfillsPlan,
): boolean {
if (this.blockedContactDids.includes(record.issuerDid)) {
return false;
}
if (!this.isAnyFeedFilterOn) {
return true;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,9 @@ import path from "path";
import { fileURLToPath } from 'url';
// Load environment variables
dotenv.config();
console.log('NODE_ENV:', process.env.NODE_ENV)
dotenv.config({ path: `.env.${process.env.NODE_ENV}` })
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);