Browse Source

refactor(qr): improve QR code scanning robustness and error handling

- Enhance JWT extraction with unified path handling and validation
- Add debouncing to prevent duplicate scans
- Improve error handling and logging throughout QR flow
- Add proper TypeScript interfaces for QR scan results
- Implement mobile app lifecycle handlers (pause/resume)
- Enhance logging with structured data and consistent levels
- Clean up scanner resources properly on component destroy
- Split contact handling into separate method for better organization
- Add proper type for UserNameDialog ref

This commit improves the reliability and maintainability of the QR code
scanning functionality while adding better error handling and logging.
pull/132/head
Matthew Raymer 4 weeks ago
parent
commit
30e448faf8
  1. 12
      src/components/QRScanner/QRScannerDialog.vue
  2. 58
      src/libs/crypto/index.ts
  3. 6
      src/libs/endorserServer.ts
  4. 13
      src/services/QRScanner/CapacitorQRScanner.ts
  5. 22
      src/services/QRScanner/QRScannerFactory.ts
  6. 11
      src/utils/logger.ts
  7. 340
      src/views/ContactQRScanShowView.vue

12
src/components/QRScanner/QRScannerDialog.vue

@ -92,9 +92,9 @@ interface ScanProps {
},
})
export default class QRScannerDialog extends Vue {
@Prop({ type: Function, required: true }) onScan!: ScanProps['onScan'];
@Prop({ type: Function }) onError?: ScanProps['onError'];
@Prop({ type: Object }) options?: ScanProps['options'];
@Prop({ type: Function, required: true }) onScan!: ScanProps["onScan"];
@Prop({ type: Function }) onError?: ScanProps["onError"];
@Prop({ type: Object }) options?: ScanProps["options"];
visible = true;
error: string | null = null;
@ -132,7 +132,8 @@ export default class QRScannerDialog extends Vue {
await promise;
this.error = null;
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
const wrappedError =
error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message;
if (this.onError) {
this.onError(wrappedError);
@ -146,7 +147,8 @@ export default class QRScannerDialog extends Vue {
this.onScan(result);
this.close();
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
const wrappedError =
error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message;
if (this.onError) {
this.onError(wrappedError);

58
src/libs/crypto/index.ts

@ -9,6 +9,7 @@ import {
createEndorserJwtForDid,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
} from "../../libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { logger } from "../../utils/logger";
@ -104,34 +105,41 @@ export const accessToken = async (did?: string) => {
};
/**
@return payload of JWT pulled out of any recognized URL path (if any)
* Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT
* @returns The extracted JWT or null if not found
*/
export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
let jwtText = jwtUrlText;
const appImportConfirmUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
);
if (appImportConfirmUrlLoc > -1) {
jwtText = jwtText.substring(
appImportConfirmUrlLoc +
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI.length,
);
}
const appImportOneUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
);
if (appImportOneUrlLoc > -1) {
jwtText = jwtText.substring(
appImportOneUrlLoc + CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI.length,
);
}
const endorserUrlPathLoc = jwtText.indexOf(CONTACT_URL_PATH_ENDORSER_CH_OLD);
if (endorserUrlPathLoc > -1) {
jwtText = jwtText.substring(
endorserUrlPathLoc + CONTACT_URL_PATH_ENDORSER_CH_OLD.length,
);
try {
let jwtText = jwtUrlText;
// Try to extract JWT from URL paths
const paths = [
CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_URL_PATH_ENDORSER_CH_OLD,
];
for (const path of paths) {
const pathIndex = jwtText.indexOf(path);
if (pathIndex > -1) {
jwtText = jwtText.substring(pathIndex + path.length);
break;
}
}
// Validate JWT format
if (!jwtText.match(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/)) {
logger.error("Invalid JWT format in URL:", jwtUrlText);
return null;
}
return jwtText;
} catch (error) {
logger.error("Error extracting JWT from URL:", error);
return null;
}
return jwtText;
};
export const nextDerivationPath = (origDerivPath: string) => {

6
src/libs/endorserServer.ts

@ -86,6 +86,12 @@ export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=";
*/
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";
/**
* URL path suffix for contact confirmation
* @constant {string}
*/
export const CONTACT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact/confirm/";
/**
* The prefix for handle IDs, the permanent ID for claims on Endorser
* @constant {string}

13
src/services/QRScanner/CapacitorQRScanner.ts

@ -88,11 +88,14 @@ export class CapacitorQRScanner implements QRScannerService {
logger.log("Scanner options:", scanOptions);
// Add listener for barcode scans
const handle = await BarcodeScanner.addListener('barcodeScanned', (result) => {
if (this.scanListener) {
this.scanListener.onScan(result.barcode.rawValue);
}
});
const handle = await BarcodeScanner.addListener(
"barcodeScanned",
(result) => {
if (this.scanListener) {
this.scanListener.onScan(result.barcode.rawValue);
}
},
);
this.listenerHandles.push(handle.remove);
// Start continuous scanning

22
src/services/QRScanner/QRScannerFactory.ts

@ -13,13 +13,18 @@ export class QRScannerFactory {
private static isNativePlatform(): boolean {
// Debug logging for build flags
logger.log("Build flags:", {
IS_MOBILE: typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : 'undefined',
USE_QR_READER: typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : 'undefined',
IS_MOBILE:
typeof __IS_MOBILE__ !== "undefined" ? __IS_MOBILE__ : "undefined",
USE_QR_READER:
typeof __USE_QR_READER__ !== "undefined"
? __USE_QR_READER__
: "undefined",
VITE_PLATFORM: process.env.VITE_PLATFORM,
});
const capacitorNative = Capacitor.isNativePlatform();
const isMobile = typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : capacitorNative;
const isMobile =
typeof __IS_MOBILE__ !== "undefined" ? __IS_MOBILE__ : capacitorNative;
const platform = Capacitor.getPlatform();
logger.log("Platform detection:", {
@ -37,7 +42,10 @@ export class QRScannerFactory {
// For other platforms, use native if available
const useNative = capacitorNative || isMobile;
logger.log("Platform decision:", { useNative, reason: useNative ? "capacitorNative/isMobile" : "web" });
logger.log("Platform decision:", {
useNative,
reason: useNative ? "capacitorNative/isMobile" : "web",
});
return useNative;
}
@ -55,7 +63,11 @@ export class QRScannerFactory {
if (isNative) {
logger.log("Using native MLKit scanner");
this.instance = new CapacitorQRScanner();
} else if (typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : !isNative) {
} else if (
typeof __USE_QR_READER__ !== "undefined"
? __USE_QR_READER__
: !isNative
) {
logger.log("Using web QR scanner");
this.instance = new WebDialogQRScanner();
} else {

11
src/utils/logger.ts

@ -31,6 +31,17 @@ export const logger = {
logToDb(message + argsString);
}
},
info: (message: string, ...args: unknown[]) => {
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor"
) {
// eslint-disable-next-line no-console
console.info(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
}
},
warn: (message: string, ...args: unknown[]) => {
if (
process.env.NODE_ENV !== "production" ||

340
src/views/ContactQRScanShowView.vue

@ -78,7 +78,9 @@
<div class="text-center">
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
<div v-if="isScanning" class="relative aspect-square">
<div class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none"></div>
<div
class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none"
></div>
</div>
<div v-else>
<button
@ -122,6 +124,15 @@ import { Router } from "vue-router";
import { logger } from "../utils/logger";
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
interface QRScanResult {
rawValue?: string;
barcode?: string;
}
interface IUserNameDialog {
open: (callback: (name: string) => void) => void;
}
@Component({
components: {
QRCodeVue3,
@ -144,6 +155,11 @@ export default class ContactQRScanShow extends Vue {
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
// Add new properties to track scanning state
private lastScannedValue: string = "";
private lastScanTime: number = 0;
private readonly SCAN_DEBOUNCE_MS = 2000; // Prevent duplicate scans within 2 seconds
async created() {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
@ -173,6 +189,8 @@ export default class ContactQRScanShow extends Vue {
try {
this.error = null;
this.isScanning = true;
this.lastScannedValue = "";
this.lastScanTime = 0;
const scanner = QRScannerFactory.getInstance();
@ -189,7 +207,7 @@ export default class ContactQRScanShow extends Vue {
// Add scan listener
scanner.addListener({
onScan: this.onScanDetect,
onError: this.onScanError
onError: this.onScanError,
});
// Start scanning
@ -205,10 +223,11 @@ export default class ContactQRScanShow extends Vue {
try {
const scanner = QRScannerFactory.getInstance();
await scanner.stopScan();
this.isScanning = false;
this.lastScannedValue = "";
this.lastScanTime = 0;
} catch (error) {
logger.error("Error stopping scan:", error);
} finally {
this.isScanning = false;
}
}
@ -225,116 +244,104 @@ export default class ContactQRScanShow extends Vue {
}
/**
* Handle QR code scan result
* Handle QR code scan result with debouncing to prevent duplicate scans
*/
async onScanDetect(result: string) {
async onScanDetect(result: string | QRScanResult) {
try {
let newContact: Contact;
const jwt = getContactJwtFromJwtUrl(result);
if (!jwt) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
// Extract raw value from different possible formats
const rawValue = typeof result === 'string' ? result : (result?.rawValue || result?.barcode);
if (!rawValue) {
logger.warn("Invalid scan result - no value found:", result);
return;
}
const { payload } = decodeEndorserJwt(jwt);
newContact = {
did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
};
if (!newContact.did) {
this.danger("There is no DID.", "Incomplete Contact");
// Debounce duplicate scans
const now = Date.now();
if (
rawValue === this.lastScannedValue &&
now - this.lastScanTime < this.SCAN_DEBOUNCE_MS
) {
logger.info("Ignoring duplicate scan:", rawValue);
return;
}
if (!isDid(newContact.did)) {
this.danger("The DID must begin with 'did:'", "Invalid DID");
// Update scan tracking
this.lastScannedValue = rawValue;
this.lastScanTime = now;
logger.info("Processing QR code scan result:", rawValue);
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
});
return;
}
try {
await db.open();
await db.contacts.add(newContact);
let addedMessage;
if (this.activeDid) {
await this.setVisibility(newContact, true);
newContact.seesMe = true; // didn't work inside setVisibility
addedMessage =
"They were added, and your activity is visible to them.";
} else {
addedMessage = "They were added.";
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: addedMessage,
},
3000,
);
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
if (this.isRegistered) {
if (!this.hideRegisterPromptOnNewContact && !newContact.registered) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
}
} catch (e) {
logger.error("Error saving contact info:", e);
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact info. Check if it already exists.",
},
5000,
);
const contactInfo = decodedJwt.payload.own;
if (!contactInfo.did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Stop scanning after successful scan
// Create contact object
const contact = {
did: contactInfo.did,
name: contactInfo.name || "",
email: contactInfo.email || "",
phone: contactInfo.phone || "",
company: contactInfo.company || "",
title: contactInfo.title || "",
notes: contactInfo.notes || "",
};
// Add contact and stop scanning
logger.info("Adding new contact to database:", {
did: contact.did,
name: contact.name,
});
await this.addNewContact(contact);
await this.stopScanning();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
logger.error("Error processing scan result:", error);
logger.error("Error processing contact QR code:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text:
error instanceof Error
? error.message
: "Could not process QR code. Please try again.",
});
}
}
@ -350,11 +357,15 @@ export default class ContactQRScanShow extends Vue {
if (result.error) {
this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) {
logger.error("Got strange result from setting visibility:", result);
logger.warn("Unexpected result from setting visibility:", result);
}
}
async register(contact: Contact) {
logger.info("Submitting contact registration", {
did: contact.did,
name: contact.name,
});
this.$notify(
{
group: "alert",
@ -375,6 +386,7 @@ export default class ContactQRScanShow extends Vue {
if (regResult.success) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
logger.info("Contact registration successful", { did: contact.did });
this.$notify(
{
@ -400,12 +412,21 @@ export default class ContactQRScanShow extends Vue {
);
}
} catch (error) {
logger.error("Error when registering:", error);
logger.error("Error registering contact:", {
did: contact.did,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data && typeof serverError.response.data === 'object' && 'message' in serverError.response.data) {
userMessage = (serverError.response.data as {message: string}).message;
if (
serverError.response?.data &&
typeof serverError.response.data === "object" &&
"message" in serverError.response.data
) {
userMessage = (serverError.response.data as { message: string })
.message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
@ -429,7 +450,10 @@ export default class ContactQRScanShow extends Vue {
onScanError(error: Error) {
this.error = error.message;
logger.error("Scan error:", error);
logger.error("QR code scan error:", {
error: error.message,
stack: error.stack,
});
}
onCopyUrlToClipboard() {
@ -468,15 +492,117 @@ export default class ContactQRScanShow extends Vue {
}
openUserNameDialog() {
(this.$refs.userNameDialog as any).open((name: string) => {
(this.$refs.userNameDialog as IUserNameDialog).open((name: string) => {
this.givenName = name;
});
}
beforeDestroy() {
// Clean up scanner when component is destroyed
logger.info("Cleaning up QR scanner resources");
this.stopScanning(); // Ensure scanner is stopped
QRScannerFactory.cleanup();
}
async addNewContact(contact: Contact) {
try {
logger.info("Opening database connection for new contact");
await db.open();
await db.contacts.add(contact);
if (this.activeDid) {
logger.info("Setting contact visibility", { did: contact.did });
await this.setVisibility(contact, true);
contact.seesMe = true;
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: this.activeDid
? "They were added, and your activity is visible to them."
: "They were added.",
},
3000,
);
if (
this.isRegistered &&
!this.hideRegisterPromptOnNewContact &&
!contact.registered
) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(contact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
} catch (error) {
logger.error("Error saving contact to database:", {
did: contact.did,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
this.$notify(
{
group: "alert",
type: "danger",
title: "Contact Error",
text: "Could not save contact. Check if it already exists.",
},
5000,
);
}
}
// Add pause/resume handlers for mobile
mounted() {
document.addEventListener("pause", this.handleAppPause);
document.addEventListener("resume", this.handleAppResume);
}
beforeUnmount() {
document.removeEventListener("pause", this.handleAppPause);
document.removeEventListener("resume", this.handleAppResume);
}
handleAppPause() {
logger.info("App paused, stopping scanner");
this.stopScanning();
}
handleAppResume() {
logger.info("App resumed, scanner can be restarted by user");
// Don't auto-restart scanning - let user initiate it
this.isScanning = false;
}
}
</script>

Loading…
Cancel
Save