Browse Source

add first cut at deep-link redirecting, with one example contact-import that works on mobile

deep-link-redirect-server
Trent Larson 2 days ago
parent
commit
3fd6c2b80d
  1. 1
      doc/DEEP_LINKS.md
  2. 4
      src/db/databaseUtil.ts
  3. 52
      src/interfaces/deepLinks.ts
  4. 7
      src/main.capacitor.ts
  5. 5
      src/router/index.ts
  6. 5
      src/services/api.ts
  7. 39
      src/services/deepLinks.ts
  8. 7
      src/utils/logger.ts
  9. 3
      src/views/ContactsView.vue
  10. 11
      src/views/DeepLinkErrorView.vue
  11. 221
      src/views/DeepLinkRedirectView.vue
  12. 15
      src/views/HomeView.vue

1
doc/DEEP_LINKS.md

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

4
src/db/databaseUtil.ts

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

52
src/interfaces/deepLinks.ts

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

7
src/main.capacitor.ts

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

5
src/router/index.ts

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

5
src/services/api.ts

@ -6,7 +6,7 @@
*/ */
import { AxiosError } from "axios"; 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. * 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) => { export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") { 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, message: error.message,
status: error.response?.status, status: error.response?.status,
data: error.response?.data, data: error.response?.data,

39
src/services/deepLinks.ts

@ -27,18 +27,16 @@
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2] * timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
* *
* Supported Routes: * 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: View claim
* - claim-cert: View claim certificate
* - claim-add-raw: Add raw claim * - claim-add-raw: Add raw claim
* - contact-edit: Edit contact * - claim-cert: View claim certificate
* - contacts: View contacts * - confirm-gift
* - contact-import: Import contacts
* - did: View DID * - did: View DID
* - invite-one-accept: Accept invitation
* - onboard-meeting-members
* - project: View project details
* - user-profile: View user profile
* *
* @example * @example
* const handler = new DeepLinkHandler(router); * const handler = new DeepLinkHandler(router);
@ -54,6 +52,7 @@ import {
} from "../interfaces/deepLinks"; } from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db/databaseUtil"; import { logConsoleAndDb } from "../db/databaseUtil";
import type { DeepLinkError } from "../interfaces/deepLinks"; import type { DeepLinkError } from "../interfaces/deepLinks";
import { logger } from "@/utils/logger";
/** /**
* Handles processing and routing of deep links in the application. * Handles processing and routing of deep links in the application.
@ -81,14 +80,15 @@ export class DeepLinkHandler {
string, string,
{ name: string; paramKey?: string } { name: string; paramKey?: string }
> = { > = {
// note that similar lists are in src/interfaces/deepLinks.ts
claim: { name: "claim" }, claim: { name: "claim" },
"claim-add-raw": { name: "claim-add-raw" }, "claim-add-raw": { name: "claim-add-raw" },
"claim-cert": { name: "claim-cert" }, "claim-cert": { name: "claim-cert" },
"confirm-gift": { name: "confirm-gift" }, "confirm-gift": { name: "confirm-gift" },
"contact-import": { name: "contact-import", paramKey: "jwt" },
did: { name: "did", paramKey: "did" }, did: { name: "did", paramKey: "did" },
"invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" }, "invite-one-accept": { name: "invite-one-accept", paramKey: "jwt" },
"onboard-meeting-members": { name: "onboard-meeting-members" }, "onboard-meeting-members": { name: "onboard-meeting-members" },
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
project: { name: "project" }, project: { name: "project" },
"user-profile": { name: "user-profile" }, "user-profile": { name: "user-profile" },
}; };
@ -99,7 +99,7 @@ export class DeepLinkHandler {
* *
* @param url - The deep link URL to parse (format: scheme://path[?query]) * @param url - The deep link URL to parse (format: scheme://path[?query])
* @throws {DeepLinkError} If URL format is invalid * @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) { private parseDeepLink(url: string) {
const parts = url.split("://"); const parts = url.split("://");
@ -115,7 +115,16 @@ export class DeepLinkHandler {
}); });
const [path, queryString] = parts[1].split("?"); const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/"); const [routePath, ...pathParams] = path.split("/");
// logger.log(
// "[DeepLink] Debug:",
// "Route Path:",
// routePath,
// "Path Params:",
// pathParams,
// "Query String:",
// queryString,
// );
// Validate route exists before proceeding // Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) { if (!this.ROUTE_MAP[routePath]) {
@ -134,10 +143,10 @@ export class DeepLinkHandler {
} }
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (param) { if (pathParams) {
// Now we know routePath exists in ROUTE_MAP // Now we know routePath exists in ROUTE_MAP
const routeConfig = this.ROUTE_MAP[routePath]; const routeConfig = this.ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = param; params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
} }
return { path: routePath, params, query }; return { path: routePath, params, query };
} }
@ -243,6 +252,8 @@ export class DeepLinkHandler {
code: "INVALID_PARAMETERS", code: "INVALID_PARAMETERS",
message: (error as Error).message, message: (error as Error).message,
details: error, details: error,
params: params,
query: query,
}; };
} }
} }

7
src/utils/logger.ts

@ -1,6 +1,6 @@
import { logToDb } from "../db/databaseUtil"; import { logToDb } from "../db/databaseUtil";
function safeStringify(obj: unknown) { export function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => { return JSON.stringify(obj, (_key, value) => {
@ -67,8 +67,9 @@ export const logger = {
// Errors will always be logged // Errors will always be logged
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(message, ...args); console.error(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const messageString = safeStringify(message);
logToDb(message + argsString); const argsString = args.length > 0 ? safeStringify(args) : "";
logToDb(messageString + argsString);
}, },
}; };

3
src/views/ContactsView.vue

@ -1397,7 +1397,8 @@ export default class ContactsView extends Vue {
const contactsJwt = await createEndorserJwtForDid(this.activeDid, { const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts, contacts: selectedContacts,
}); });
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt; const contactsJwtUrl =
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
useClipboard() useClipboard()
.copy(contactsJwtUrl) .copy(contactsJwtUrl)
.then(() => { .then(() => {

11
src/views/DeepLinkErrorView.vue

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

221
src/views/DeepLinkRedirectView.vue

@ -0,0 +1,221 @@
<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;
this.destinationUrl = fullPath;
this.deepLinkUrl = `timesafari://${fullPath}`;
this.webUrl = `${APP_SERVER}/${fullPath}`;
// Log for debugging
logger.info("Deep link processing:", {
fullPath,
deepLinkUrl: this.deepLinkUrl,
webUrl: this.webUrl,
userAgent: this.userAgent,
});
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;
}
logger.info("Attempting deep link redirect:", {
deepLinkUrl: this.deepLinkUrl,
webUrl: this.webUrl,
isMobile: this.isMobile,
userAgent: this.userAgent,
});
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);
logger.info("Fallback link click completed");
} 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>

15
src/views/HomeView.vue

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

Loading…
Cancel
Save