Browse Source

feat(qr): improve camera error feedback and robustness in QR scanner

- Display prominent, actionable error banners in QR scanner dialog for camera access issues
- Add troubleshooting tips for common camera errors (no device, denied, in use, HTTPS)
- Enhance error handling and logging in WebDialogQRScanner for device detection and permissions
- Use proper type narrowing for promise handling in QRScannerDialog to resolve linter errors
- Improve user experience and clarity when camera access fails or is unavailable
pull/133/head
Matthew Raymer 4 weeks ago
parent
commit
538cbef701
  1. 158
      src/components/QRScanner/QRScannerDialog.vue
  2. 71
      src/services/QRScanner/WebDialogQRScanner.ts

158
src/components/QRScanner/QRScannerDialog.vue

@ -13,7 +13,7 @@
> >
<div> <div>
<h3 class="text-lg font-medium text-gray-900">Scan QR Code</h3> <h3 class="text-lg font-medium text-gray-900">Scan QR Code</h3>
<span class="text-xs text-gray-500">v1.1.0 build 00000</span> <span class="text-xs text-gray-500">v1.1.0</span>
</div> </div>
<button <button
class="text-gray-400 hover:text-gray-500" class="text-gray-400 hover:text-gray-500"
@ -67,8 +67,7 @@
<path <path
class="opacity-75" class="opacity-75"
fill="currentColor" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
3.042 1.135 5.824 3 7.938l3-2.647z"
></path> ></path>
</svg> </svg>
<span>{{ initializationStatus }}</span> <span>{{ initializationStatus }}</span>
@ -138,8 +137,7 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/> />
<path <path
stroke-linecap="round" stroke-linecap="round"
@ -164,6 +162,19 @@
</div> </div>
</div> </div>
<!-- Error Banner -->
<div v-if="error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
<strong class="font-bold">Camera Error:</strong>
<span class="block sm:inline">{{ error }}</span>
<ul class="mt-2 text-sm text-red-600 list-disc list-inside">
<li v-if="error.includes('No camera found')">Check if your device has a camera and it is enabled.</li>
<li v-if="error.includes('denied')">Allow camera access in your browser settings and reload the page.</li>
<li v-if="error.includes('in use')">Close other applications that may be using the camera.</li>
<li v-if="error.includes('HTTPS')">Ensure you are using a secure (HTTPS) connection.</li>
<li v-if="!error.includes('No camera found') && !error.includes('denied') && !error.includes('in use') && !error.includes('HTTPS')">Try refreshing the page or using a different browser/device.</li>
</ul>
</div>
<!-- Footer --> <!-- Footer -->
<div class="p-4 border-t border-gray-200"> <div class="p-4 border-t border-gray-200">
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
@ -197,14 +208,6 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="debugMessage" class="bg-yellow-200 text-black p-2 m-2 rounded">
{{ debugMessage }}
</div>
<div class="bg-red-200 text-black p-2 m-2 rounded">
DEBUG PANEL: If you see this, the template is updating.
</div>
</div> </div>
</div> </div>
</template> </template>
@ -259,7 +262,6 @@ export default class QRScannerDialog extends Vue {
preferredCamera: "user" | "environment" = "environment"; preferredCamera: "user" | "environment" = "environment";
initializationStatus = "Checking camera access..."; initializationStatus = "Checking camera access...";
cameraStatus = "Initializing"; cameraStatus = "Initializing";
debugMessage = "";
created() { created() {
logger.log("QRScannerDialog platform detection:", { logger.log("QRScannerDialog platform detection:", {
@ -282,45 +284,105 @@ export default class QRScannerDialog extends Vue {
} }
} }
async onInit(promise: Promise<void>) { async onInit(promise: Promise<void>): Promise<void> {
alert("onInit called"); if (this.isNativePlatform) {
this.debugMessage = "onInit called"; logger.log("Closing QR dialog on native platform");
let timeoutHit = false; this.$nextTick(() => this.close());
const timeout = setTimeout(() => { return;
timeoutHit = true; }
this.isInitializing = false;
this.cameraStatus = "Ready (timeout fallback)"; this.isInitializing = true;
this.initializationStatus = "Camera ready (fallback)"; this.error = null;
alert("Timeout fallback triggered"); this.initializationStatus = "Checking camera access...";
this.debugMessage = "Timeout fallback triggered";
}, 4000);
try { try {
await promise; // First check if mediaDevices API is available
if (!timeoutHit) { if (!navigator.mediaDevices) {
clearTimeout(timeout); throw new Error(
this.isInitializing = false; "Camera API not available. Please ensure you're using HTTPS.",
this.cameraStatus = "Ready"; );
alert("Promise resolved before timeout");
this.debugMessage = "Promise resolved before timeout";
} }
} catch (error) {
clearTimeout(timeout); // Check for video devices
alert("Promise rejected: " + (error instanceof Error ? error.message : error)); const devices = await navigator.mediaDevices.enumerateDevices();
this.debugMessage = "Promise rejected: " + (error instanceof Error ? error.message : error); const videoDevices = devices.filter(device => device.kind === 'videoinput');
if (error instanceof Error) {
this.error = error.message; if (videoDevices.length === 0) {
this.cameraStatus = "Error"; throw new Error("No camera found on this device");
if (this.onError) { }
this.onError(error);
logger.log("Starting QR scanner initialization...", {
mediaDevices: !!navigator.mediaDevices,
getUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
videoDevices: videoDevices.length,
constraints: {
video: {
facingMode: this.preferredCamera,
width: { ideal: 1280 },
height: { ideal: 720 }
}
} }
logger.error("Error initializing QR scanner:", { });
error: error.message,
stack: error.stack, // Explicitly request camera permission first
this.initializationStatus = "Requesting camera permission...";
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: this.preferredCamera,
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// Stop the test stream immediately
stream.getTracks().forEach((track) => track.stop());
this.initializationStatus = "Camera permission granted...";
logger.log("Camera permission granted");
} catch (permissionError) {
const error = permissionError as Error;
logger.error("Camera permission error:", {
name: error.name, name: error.name,
type: error.constructor.name, message: error.message,
}); });
if (error.name === "NotAllowedError" || error.name === "PermissionDeniedError") {
throw new Error(
"Camera access denied. Please grant camera permission and try again.",
);
} else if (error.name === "NotFoundError" || error.name === "DevicesNotFoundError") {
throw new Error(
"No camera found. Please ensure your device has a camera.",
);
} else if (error.name === "NotReadableError" || error.name === "TrackStartError") {
throw new Error("Camera is in use by another application.");
} else {
throw new Error(`Camera error: ${error.message}`);
}
}
// Now initialize the QR scanner
this.initializationStatus = "Starting QR scanner...";
logger.log("Initializing QR scanner...");
await promise;
this.isInitializing = false;
this.cameraStatus = "Ready";
logger.log("QR scanner initialized successfully");
} catch (error) {
const wrappedError = error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message;
this.cameraStatus = "Error";
if (this.onError) {
this.onError(wrappedError);
} }
logger.error("Error initializing QR scanner:", {
error: wrappedError.message,
stack: wrappedError.stack,
name: wrappedError.name,
type: wrappedError.constructor.name,
});
} finally { } finally {
this.isInitializing = false; this.isInitializing = false;
} }
@ -353,8 +415,8 @@ export default class QRScannerDialog extends Vue {
} }
}; };
// Handle both promise and non-promise results // Use instanceof Promise for type narrowing
if (result && typeof result.then === "function") { if (result instanceof Promise) {
result result
.then(processResult) .then(processResult)
.catch((error: Error) => this.handleError(error)) .catch((error: Error) => this.handleError(error))

71
src/services/QRScanner/WebDialogQRScanner.ts

@ -28,26 +28,79 @@ export class WebDialogQRScanner implements QRScannerService {
async requestPermissions(): Promise<boolean> { async requestPermissions(): Promise<boolean> {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true }); // First check if we have any video devices
stream.getTracks().forEach((track) => track.stop()); const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
if (videoDevices.length === 0) {
logger.error('No video devices found');
throw new Error('No camera found on this device');
}
// Try to get a stream with specific constraints
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// Stop the test stream immediately
stream.getTracks().forEach(track => track.stop());
return true; return true;
} catch (error) { } catch (error) {
logger.error("Error requesting camera permissions:", { const wrappedError = error instanceof Error ? error : new Error(String(error));
error: error instanceof Error ? error.message : String(error), logger.error('Error requesting camera permissions:', {
stack: error instanceof Error ? error.stack : undefined, error: wrappedError.message,
stack: wrappedError.stack,
name: wrappedError.name
}); });
return false;
// Provide more specific error messages
if (wrappedError.name === 'NotFoundError' || wrappedError.name === 'DevicesNotFoundError') {
throw new Error('No camera found on this device');
} else if (wrappedError.name === 'NotAllowedError' || wrappedError.name === 'PermissionDeniedError') {
throw new Error('Camera access denied. Please grant camera permission and try again');
} else if (wrappedError.name === 'NotReadableError' || wrappedError.name === 'TrackStartError') {
throw new Error('Camera is in use by another application');
} else {
throw new Error(`Camera error: ${wrappedError.message}`);
}
} }
} }
async isSupported(): Promise<boolean> { async isSupported(): Promise<boolean> {
try {
// Check for secure context first // Check for secure context first
if (!window.isSecureContext) { if (!window.isSecureContext) {
logger.warn("Camera access requires HTTPS (secure context)"); logger.warn('Camera access requires HTTPS (secure context)');
return false;
}
// Check for camera API support
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
logger.warn('Camera API not supported in this browser');
return false;
}
// Check if we have any video devices
const devices = await navigator.mediaDevices.enumerateDevices();
const hasVideoDevices = devices.some(device => device.kind === 'videoinput');
if (!hasVideoDevices) {
logger.warn('No video devices found');
return false;
}
return true;
} catch (error) {
logger.error('Error checking camera support:', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
return false; return false;
} }
// Then check for camera API support
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
} }
async startScan(): Promise<void> { async startScan(): Promise<void> {

Loading…
Cancel
Save