Fix Quick Snap opening gallery instead of camera on native (v1.0.70)
Root cause: quickCapture.ts relied on window.Capacitor.Plugins.Camera,
which is only populated once something imports '@capacitor/camera'. Since
we deliberately never import that package (to avoid the old white-screen
crash), the plugin was never registered, the native check failed, and Quick
Snap fell through to the web file input — opening the gallery.
Fix: use Capacitor's official registerPlugin('Camera') from the bundled
@capacitor/core. This returns a proxy that dispatches to the native Camera
implementation over the bridge without needing the JS @capacitor/camera
package to self-register, and without putting a bare '@capacitor/camera'
specifier in the bundle. Applied the same pattern to CameraCapture.tsx.
Also: on a permission error, show a clear "enable camera access" toast
instead of silently opening the gallery.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
6d0480961c
commit
939a101b38
4 changed files with 102 additions and 57 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.69",
|
"version": "1.0.70",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,17 @@
|
||||||
* CameraCapture — unified camera for browser + native Capacitor app
|
* CameraCapture — unified camera for browser + native Capacitor app
|
||||||
*
|
*
|
||||||
* On native (Android / iOS via Capacitor):
|
* On native (Android / iOS via Capacitor):
|
||||||
* → Uses @capacitor/camera which calls the OS native camera directly.
|
* → Uses the Camera plugin (via registerPlugin from @capacitor/core) which
|
||||||
* No WebView permission popup, no getUserMedia, no dialog needed.
|
* calls the OS native camera directly. No WebView permission popup, no
|
||||||
* The user sees the full native camera UI, then returns here with the photo.
|
* getUserMedia, no dialog needed. The user sees the full native camera UI
|
||||||
|
* (including its own preview + retake), then returns here with the photo.
|
||||||
*
|
*
|
||||||
* On browser (desktop / mobile web):
|
* On browser (desktop / mobile web):
|
||||||
* → Falls back to getUserMedia stream inside this dialog component.
|
* → Falls back to getUserMedia stream inside this dialog component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -30,48 +32,56 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// ─── Capacitor detection ────────────────────────────────────────────────────
|
// ─── Capacitor detection ────────────────────────────────────────────────────
|
||||||
// We dynamically import Capacitor to avoid breaking the web build.
|
// `@capacitor/core` is bundled and always installed, so we import it directly.
|
||||||
// On native this module exists; on web it throws/returns isNativePlatform=false.
|
// `isNativePlatform()` is web-safe (returns false in a browser).
|
||||||
|
|
||||||
async function isNative(): Promise<boolean> {
|
function isNative(): boolean {
|
||||||
try {
|
try {
|
||||||
const { Capacitor } = await import('@capacitor/core');
|
|
||||||
return Capacitor.isNativePlatform();
|
return Capacitor.isNativePlatform();
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Camera plugin is provided by Capacitor at runtime on native. We access it
|
// The Camera plugin is accessed via Capacitor's official `registerPlugin`
|
||||||
// through the runtime-injected `window.Capacitor.Plugins.Camera` rather than a
|
// factory (from the bundled @capacitor/core), NOT via a bare
|
||||||
// static/dynamic `import('@capacitor/camera')`, so the bundle contains no bare
|
// `import('@capacitor/camera')`. registerPlugin returns a proxy that dispatches
|
||||||
// `@capacitor/camera` specifier (which a native WebView can't resolve, and which
|
// to the native Camera implementation over the Capacitor bridge, so:
|
||||||
// isn't installed on every build machine). CameraResultType/CameraSource are
|
// • the bundle contains no `@capacitor/camera` specifier (native WebViews
|
||||||
// plain string enums in the plugin, so we pass their string values directly.
|
// can't resolve it, and it isn't installed on every build machine), and
|
||||||
|
// • the plugin is reachable even though nothing ever imports the JS package
|
||||||
|
// to trigger its side-effect self-registration (that was the bug that made
|
||||||
|
// Quick Snap open the gallery instead of the camera).
|
||||||
|
// CameraResultType/CameraSource are plain string enums, so we pass their string
|
||||||
|
// values directly.
|
||||||
interface CapacitorCameraPlugin {
|
interface CapacitorCameraPlugin {
|
||||||
getPhoto(options: {
|
getPhoto(options: {
|
||||||
resultType: string;
|
resultType: string;
|
||||||
source: string;
|
source: string;
|
||||||
quality: number;
|
quality: number;
|
||||||
correctOrientation: boolean;
|
correctOrientation: boolean;
|
||||||
|
saveToGallery?: boolean;
|
||||||
|
allowEditing?: boolean;
|
||||||
}): Promise<{ dataUrl?: string }>;
|
}): Promise<{ dataUrl?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNativeCamera(): CapacitorCameraPlugin | null {
|
let cameraPlugin: CapacitorCameraPlugin | null = null;
|
||||||
const cap = (window as unknown as {
|
function getNativeCamera(): CapacitorCameraPlugin {
|
||||||
Capacitor?: { Plugins?: { Camera?: CapacitorCameraPlugin } };
|
if (!cameraPlugin) {
|
||||||
}).Capacitor;
|
cameraPlugin = registerPlugin<CapacitorCameraPlugin>('Camera');
|
||||||
return cap?.Plugins?.Camera ?? null;
|
}
|
||||||
|
return cameraPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureNative(): Promise<File | null> {
|
async function captureNative(): Promise<File | null> {
|
||||||
const Camera = getNativeCamera();
|
const Camera = getNativeCamera();
|
||||||
if (!Camera) throw new Error('Camera plugin unavailable');
|
|
||||||
const photo = await Camera.getPhoto({
|
const photo = await Camera.getPhoto({
|
||||||
resultType: 'dataUrl', // CameraResultType.DataUrl
|
resultType: 'dataUrl', // CameraResultType.DataUrl
|
||||||
source: 'CAMERA', // CameraSource.Camera
|
source: 'CAMERA', // CameraSource.Camera
|
||||||
quality: 90,
|
quality: 90,
|
||||||
correctOrientation: true,
|
correctOrientation: true,
|
||||||
|
saveToGallery: false,
|
||||||
|
allowEditing: false,
|
||||||
});
|
});
|
||||||
if (!photo.dataUrl) return null;
|
if (!photo.dataUrl) return null;
|
||||||
const res = await fetch(photo.dataUrl);
|
const res = await fetch(photo.dataUrl);
|
||||||
|
|
@ -179,18 +189,13 @@ export function CameraCapture({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
if (isNative()) {
|
||||||
isNative().then((native) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
if (native) {
|
|
||||||
handleNativeCapture();
|
handleNativeCapture();
|
||||||
} else {
|
} else {
|
||||||
startCamera();
|
startCamera();
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
|
||||||
stopCamera();
|
stopCamera();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,74 @@
|
||||||
/**
|
/**
|
||||||
* Quick Snap capture helper — platform-aware, camera-first.
|
* Quick Snap capture helper — platform-aware, camera-first.
|
||||||
*
|
*
|
||||||
* NATIVE (Capacitor APK): uses the runtime-injected Camera plugin
|
* NATIVE (Capacitor APK): uses Capacitor's official `registerPlugin('Camera')`
|
||||||
* (window.Capacitor.Plugins.Camera) with source:'CAMERA', which forces the
|
* from `@capacitor/core` (which is bundled and always installed). This returns
|
||||||
* real OS camera and ships its own preview + retake UI. We read the plugin
|
* a plugin PROXY that dispatches straight to the natively-registered Camera
|
||||||
* off the runtime object rather than importing '@capacitor/camera', so no
|
* implementation over the Capacitor bridge — it does NOT require the JS
|
||||||
* bare specifier ends up in the bundle (that was the white-screen cause).
|
* `@capacitor/camera` package to have loaded and run its side-effect
|
||||||
|
* registration first. That distinction is the whole fix:
|
||||||
|
*
|
||||||
|
* Before, this helper read `window.Capacitor.Plugins.Camera`, which is only
|
||||||
|
* populated once something imports '@capacitor/camera'. Because we
|
||||||
|
* deliberately never import that package (to avoid the old white-screen
|
||||||
|
* crash), the plugin was never registered, `Plugins.Camera` was undefined,
|
||||||
|
* `isNativeCapture()` returned false, and Quick Snap fell through to the web
|
||||||
|
* file input → the GALLERY opened instead of the camera.
|
||||||
|
*
|
||||||
|
* `registerPlugin` sidesteps all of that: `@capacitor/core` guarantees the
|
||||||
|
* proxy, and the native side provides the real implementation. No bare
|
||||||
|
* `@capacitor/camera` specifier ends up in the bundle.
|
||||||
*
|
*
|
||||||
* WEB: there is no reliable way to force the camera from JS, so the caller
|
* WEB: there is no reliable way to force the camera from JS, so the caller
|
||||||
* falls back to a file input with capture="environment" (a hint the browser
|
* falls back to a file input with capture="environment". This module only
|
||||||
* may honor). This module only handles the native path; isNativeCapture()
|
* handles the native path; isNativeCapture() tells the caller which route to
|
||||||
* tells the caller which route to take.
|
* take.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||||
|
|
||||||
interface CapacitorCameraPlugin {
|
interface CapacitorCameraPlugin {
|
||||||
getPhoto(options: {
|
getPhoto(options: {
|
||||||
resultType: string;
|
resultType: string;
|
||||||
source: string;
|
source: string;
|
||||||
quality: number;
|
quality: number;
|
||||||
correctOrientation: boolean;
|
correctOrientation: boolean;
|
||||||
|
saveToGallery?: boolean;
|
||||||
|
allowEditing?: boolean;
|
||||||
}): Promise<{ dataUrl?: string }>;
|
}): Promise<{ dataUrl?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNativeCamera(): CapacitorCameraPlugin | null {
|
// Lazily create the plugin proxy once. `registerPlugin` is a pure factory — it
|
||||||
const cap = (window as unknown as {
|
// just builds a proxy object and never touches native APIs at call time, so
|
||||||
Capacitor?: {
|
// this is safe on web too (the proxy simply has no native impl there).
|
||||||
isNativePlatform?: () => boolean;
|
let cameraPlugin: CapacitorCameraPlugin | null = null;
|
||||||
Plugins?: { Camera?: CapacitorCameraPlugin };
|
function getCameraPlugin(): CapacitorCameraPlugin {
|
||||||
};
|
if (!cameraPlugin) {
|
||||||
}).Capacitor;
|
cameraPlugin = registerPlugin<CapacitorCameraPlugin>('Camera');
|
||||||
if (!cap?.isNativePlatform?.()) return null;
|
}
|
||||||
return cap.Plugins?.Camera ?? null;
|
return cameraPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the native Camera plugin is available (i.e. inside the app). */
|
/**
|
||||||
|
* True when we're running inside a native Capacitor shell, where the Camera
|
||||||
|
* plugin proxy can reach a real native implementation. On web this is false and
|
||||||
|
* the caller uses the file-input fallback.
|
||||||
|
*/
|
||||||
export function isNativeCapture(): boolean {
|
export function isNativeCapture(): boolean {
|
||||||
return getNativeCamera() !== null;
|
try {
|
||||||
|
return Capacitor.isNativePlatform();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the native camera and return the captured photo as a File.
|
* Open the native camera and return the captured photo as a File.
|
||||||
* Returns null if the user cancels. Throws if the plugin is unavailable or
|
* Returns null if the user cancels. Throws if the plugin errors for any other
|
||||||
* errors (caller should fall back to the web file input).
|
* reason (caller should fall back to the web file input).
|
||||||
*/
|
*/
|
||||||
export async function captureFromNativeCamera(): Promise<File | null> {
|
export async function captureFromNativeCamera(): Promise<File | null> {
|
||||||
const Camera = getNativeCamera();
|
const Camera = getCameraPlugin();
|
||||||
if (!Camera) throw new Error('Native camera unavailable');
|
|
||||||
|
|
||||||
let photo: { dataUrl?: string };
|
let photo: { dataUrl?: string };
|
||||||
try {
|
try {
|
||||||
|
|
@ -54,10 +77,12 @@ export async function captureFromNativeCamera(): Promise<File | null> {
|
||||||
source: 'CAMERA', // CameraSource.Camera — forces the camera, not the gallery
|
source: 'CAMERA', // CameraSource.Camera — forces the camera, not the gallery
|
||||||
quality: 90,
|
quality: 90,
|
||||||
correctOrientation: true,
|
correctOrientation: true,
|
||||||
|
saveToGallery: false,
|
||||||
|
allowEditing: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Capacitor throws with a "cancel" message when the user backs out — treat
|
// Capacitor throws with a "cancel"/"cancelled" message when the user backs
|
||||||
// that as a benign null rather than an error.
|
// out — treat that as a benign null rather than an error.
|
||||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||||
if (msg.includes('cancel')) return null;
|
if (msg.includes('cancel')) return null;
|
||||||
throw err;
|
throw err;
|
||||||
|
|
|
||||||
|
|
@ -770,9 +770,24 @@ export default function MapPage() {
|
||||||
try {
|
try {
|
||||||
const file = await captureFromNativeCamera();
|
const file = await captureFromNativeCamera();
|
||||||
if (file) handleQuickCameraCapture(file);
|
if (file) handleQuickCameraCapture(file);
|
||||||
return; // handled natively
|
return; // handled natively (user cancel returns null → do nothing)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[QuickSnap] native camera failed, falling back to file input:', err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn('[QuickSnap] native camera failed:', msg);
|
||||||
|
// A permission error means the OS blocked the camera. Don't silently
|
||||||
|
// open the gallery — that's confusing and looks like the bug. Tell the
|
||||||
|
// user how to fix it and stop here.
|
||||||
|
if (/permission|denied|not authorized|unavailable/i.test(msg)) {
|
||||||
|
toast({
|
||||||
|
title: 'Camera permission needed',
|
||||||
|
description:
|
||||||
|
'Allow camera access for SkateSpots in your device Settings → Apps → SkateSpots → Permissions, then try again.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any other native error → fall back to the photo picker so the user
|
||||||
|
// can still add a spot.
|
||||||
toast({
|
toast({
|
||||||
title: 'Camera unavailable',
|
title: 'Camera unavailable',
|
||||||
description: 'Opening the photo picker instead.',
|
description: 'Opening the photo picker instead.',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue