Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { ButtonAction, CameraPermissionDialog } from "$lib/ui";
import BottomSheet from "$lib/ui/BottomSheet/BottomSheet.svelte";
import { createCameraPermissionManager } from "$lib/utils";
import { PERSONAL_BINDING_MAX_LENGTH } from "$lib/utils/personalBinding";
import { Cancel01Icon, Delete02Icon } from "@hugeicons/core-free-icons";
import {
CameraRotated01Icon,
Cancel01Icon,
Delete02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/svelte";

interface IAddPhotoSheetProps {
Expand Down Expand Up @@ -45,6 +49,15 @@ let canvas = $state<HTMLCanvasElement | null>(null);
let stream: MediaStream | null = null;
let galleryInput = $state<HTMLInputElement | null>(null);

// Which camera to use. The back camera is the default; users can flip to
// the front (selfie) camera from the viewfinder. On desktop "environment"
// is advisory and resolves to the only available camera anyway.
let facingMode = $state<"user" | "environment">("environment");
// Bumped on every (re)start so a slow getUserMedia belonging to a
// superseded request (e.g. a rapid flip) can detect it lost the race
// and release its stream instead of binding a stale one.
let streamToken = 0;

Comment on lines +52 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Default camera and preference persistence contradict the linked issue's requirements. The linked issue states the front camera should default on mobile and the selected camera should be maintained throughout the capture flow, but this implementation defaults to and re-forces the back camera on every open.

  • infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L52-L60: change the initial facingMode default (and the doc comment) so front is the default on mobile, reserving "environment" as the fallback for desktop where "user"-facing has no meaning.
  • infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L212-L224: drop the unconditional facingMode = "environment" reset in chooseCamera() so a user's prior front/back selection carries over between opens, per the "maintained throughout the capture flow" requirement.
📍 Affects 1 file
  • infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L52-L60 (this comment)
  • infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte#L212-L224
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@infrastructure/eid-wallet/src/routes/`(app)/personal/components/AddPhotoSheet.svelte
around lines 52 - 60, Update AddPhotoSheet.svelte so facingMode defaults to the
front camera on mobile, using the environment camera only as the desktop
fallback, and revise its comment accordingly; at chooseCamera, remove the
unconditional environment reset so the user’s selected camera persists across
opens. Apply these changes at the facingMode declaration (lines 52-60) and
chooseCamera (lines 212-224).

const cameraPermission = createCameraPermissionManager();
const { checkAndRequestPermission, openSettings } = cameraPermission;
let showPermissionDialog = $state(false);
Expand Down Expand Up @@ -86,38 +99,134 @@ function stopStream() {
stream = null;
}

const BACK_LABEL = /back|rear|environment/i;
const FRONT_LABEL = /front|face|user|selfie/i;

// The main rear sensor, resolved once and cached — flipping back to it then
// costs a single getUserMedia instead of re-probing every camera.
let backDeviceId: string | undefined;

// Open a working stream for the requested facing.
// - Front: a plain facingMode constraint is reliable and fast.
// - Back: the default rear device is often a secondary sensor (ultrawide /
// depth) that streams a black frame, so we locate the *main* rear sensor
// by its torch capability (the technique the KYC scanner uses) and cache
// it. The probe stream for the winning camera is reused, so the main
// camera is opened only once.
async function openCameraStream(
facing: "user" | "environment",
): Promise<MediaStream> {
if (facing === "user") {
return navigator.mediaDevices.getUserMedia({
video: { facingMode: "user" },
});
}
if (backDeviceId) {
return navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: backDeviceId } },
});
}

let cams = (await navigator.mediaDevices.enumerateDevices()).filter(
(d) => d.kind === "videoinput",
);
// Labels stay empty until the session has been granted a getUserMedia;
// prime with a throwaway stream so we can tell rear cameras from front.
if (cams.length && cams.every((d) => !d.label)) {
try {
const primer = await navigator.mediaDevices.getUserMedia({
video: true,
});
for (const t of primer.getTracks()) t.stop();
cams = (await navigator.mediaDevices.enumerateDevices()).filter(
(d) => d.kind === "videoinput",
);
} catch {
// Ignore — fall through to whatever labels we have.
}
}

// Candidate rear cameras: those labelled back, or (labels hidden)
// everything that isn't explicitly a front camera.
const labelledBack = cams.filter((d) => BACK_LABEL.test(d.label));
const candidates = labelledBack.length
? labelledBack
: cams.filter((d) => !FRONT_LABEL.test(d.label));

// The main rear sensor is the one exposing a torch. Probe each candidate
// and reuse the winner's stream rather than reopening it.
for (const cam of candidates) {
try {
const probe = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: cam.deviceId } },
});
const caps = probe.getVideoTracks()[0]?.getCapabilities?.();
if (caps && "torch" in caps) {
backDeviceId = cam.deviceId;
return probe;
}
for (const t of probe.getTracks()) t.stop();
} catch {
// Skip cameras we can't open.
}
}

// No torch found (desktop, or a webview that hides capabilities): fall
// back to the last rear candidate — conventionally the main camera — or a
// plain facingMode constraint if we couldn't enumerate anything.
const fallback = candidates.at(-1) ?? cams.at(-1);
backDeviceId = fallback?.deviceId;
return navigator.mediaDevices.getUserMedia({
video: fallback
? { deviceId: { exact: fallback.deviceId } }
: { facingMode: "environment" },
});
}
Comment on lines +102 to +184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file context =="
sed -n '80,195p' "infrastructure/eid-wallet/src/routes/(app)/personal/components/AddPhotoSheet.svelte" 2>/dev/null || true

echo
echo "== PR/objective references to platforms =="
rg -n -i "iOS|Apple|iPhone|iPad|Android|desktop|rear|torch|getCapabilities|facingMode" . \
  -g '!node_modules' -g '!dist' -g '!build' -g '!coverage' | head -200

echo
echo "== nearby usages of openCameraStream or AddPhotoSheet =="
rg -n "openCameraStream|AddPhotoSheet|enable.*camera|camera" "infrastructure/eid-wallet/src/routes/(app)/personal" 2>/dev/null || true

Repository: MetaState-Prototype-Project/prototype

Length of output: 26058


🌐 Web query:

Apple Developer Forums Safari WKWebView getCapabilities torch capability unsupported iOS

💡 Result:

Support for the 'torch' capability in WKWebView on iOS has been an evolving feature in WebKit [1][2]. While standard Web Media Capture APIs often report the 'torch' capability as unsupported or missing when accessed via getCapabilities() in older iOS versions or standard WKWebView implementations, recent updates have introduced support within the WebKit engine [1]. Key points regarding the current status: 1. Official WebKit Support: Support for the 'torch' capability was added to WebKit (the engine powering WKWebView) to expose it in MediaTrackCapabilities and MediaTrackSettings [1]. This allows web applications to potentially detect and control the torch if the underlying hardware and implementation permit [1]. 2. Limitations and Variability: Even with engine-level support, developers often find that the 'torch' capability is not consistently available or functional across all iOS devices, browser versions, or specific WKWebView configurations [3][4]. In many practical scenarios, especially for barcode scanning or camera-intensive web apps, getCapabilities() may still return an object that lacks the torch property or reports it as unsupported [3][5]. 3. Native Alternatives: Due to the historical inconsistency of the web-based Torch API on iOS, many developers use native integration to control the flashlight [6]. For hybrid apps (using frameworks like Capacitor, Cordova, or custom WKWebView wrappers), it is common practice to implement a native bridge that accesses the camera's torch functionality directly through Swift or Objective-C, bypassing the limitations of the web view's MediaStreamTrack API [5][6][2]. 4. Recent Updates: Apple continues to refine WKWebView and Safari's media capabilities [7]. For instance, recent release notes (e.g., Safari 18.4) have included fixes related to getUserMedia video track constraints and the reporting of torch settings [7]. If you are encountering issues, ensure your environment is updated, though native workarounds remain the most reliable method for production-grade torch control on iOS [5][6]. If you are developing a web application, it is recommended to feature-detect the 'torch' capability dynamically using track.getCapabilities().torch and provide a fallback or inform the user if it is unavailable [8][9].

Citations:


🌐 Web query:

Tauri 2 iOS WKWebView navigator.mediaDevices getUserMedia Torch enabled MediaTrackCapabilities torch

💡 Result:

In Tauri 2 iOS applications, you cannot reliably control the device torch directly through the browser-based navigator.mediaDevices API within the WKWebView [1]. While modern versions of WebKit have added experimental support for MediaTrackCapabilities including 'torch' [2][3], relying on this within a WKWebView container is often inconsistent or unsupported depending on the iOS version and specific platform implementation [1][4]. To achieve reliable torch control in a Tauri 2 iOS app, the recommended approach is to use a native plugin that interfaces directly with the iOS AVFoundation framework [5][6]. Key points for implementing torch functionality: 1. Native Plugin Requirement: Use a dedicated Tauri plugin such as tauri-plugin-torch [5][6]. These plugins bridge the Rust backend to the native iOS Swift code, providing a stable API to toggle the flashlight independently of the webview's media constraints [7][6]. 2. Limitations of Web APIs: Even if navigator.mediaDevices is available in your WKWebView, the torch constraint is frequently ignored or restricted by the iOS system for security and privacy reasons, making it unreliable for production applications [1][4]. 3. Permissions: Ensure your Info.plist includes the necessary privacy keys (e.g., NSCameraUsageDescription) to allow the app to access the camera hardware, which is required for torch control [8][1]. 4. Implementation: With a native plugin, you can expose methods to your frontend (e.g., toggle(), check()) that execute native commands, bypassing the limitations of the webview's media stream environment [5][6]. For camera preview functionality (if needed alongside the torch), you may also consider community plugins like tauri-plugin-nativecamera, which provide native-backed interfaces for camera operations on mobile platforms [9].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Tauri deps =="
sed -n '1,90p' infrastructure/eid-wallet/src-tauri/Cargo.toml
jq '.build, .bundle, .plugins, .identifier, .productName' infrastructure/eid-wallet/src-tauri/tauri.conf.json 2>/dev/null | head -220

echo
echo "== mobile capability =="
cat -n infrastructure/eid-wallet/src-tauri/capabilities/mobile.json

echo
echo "== lockfile relevant Tauri versions (concise) =="
rg -n "'(`@tauri-apps/`|Tauri|tauri)" pnpm-lock.yaml | head -160

Repository: MetaState-Prototype-Project/prototype

Length of output: 5545


🌐 Web query:

WebKit Safari MediaTrackCapabilities torch supported iOS version

💡 Result:

Support for the 'torch' constraint in MediaTrackCapabilities was added to WebKit in September 2023 [1]. While specific official Apple documentation detailing the exact release version is sparse, developer reports and technical tracking indicate that the 'torch' constraint became functional in Safari on iOS starting with iOS 17.4 [2]. Before this implementation, the 'torch' constraint was ignored on iOS Safari [3]. Even following its introduction, some users have reported ongoing issues with the consistency of the 'torch' setting, such as instances where getSettings may return stale or unexpected values in later versions, including iOS 18 [4]. To check if it is supported on a specific device or browser instance, developers should inspect the MediaStreamTrack.getCapabilities object to see if 'torch' is present in the returned list of supported constraints [5].

Citations:


Avoid relying on torch detection for iOS rear-camera selection.

getCapabilities() returns torch support inconsistently on iOS/WKWebView, so this path can fall back to the last rear candidate on devices where the heuristic should identify the main rear sensor. Test the fallback on iOS, or use an iOS-specific strategy such as a native camera/torch plugin or disabling the torch-based multi_probe path for that platform.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@infrastructure/eid-wallet/src/routes/`(app)/personal/components/AddPhotoSheet.svelte
around lines 102 - 184, Update openCameraStream so iOS/WKWebView rear-camera
selection does not depend on getCapabilities().torch or the multi-camera probing
heuristic. Detect the iOS environment and use an iOS-safe strategy, such as the
established native camera/torch integration, or bypass torch probing and select
the appropriate rear camera deterministically; preserve the existing torch-based
selection for supported non-iOS platforms.


// (Re)start the viewfinder stream for the current `facingMode`. Tears down
// any existing stream first so flipping cameras releases the old one.
async function startStream() {
const token = ++streamToken;
stopStream();
try {
const pending = await openCameraStream(facingMode);
// A superseded request (rapid flip / close) may have moved on while
// we were opening the camera — release the stream if we lost the race
// so we don't leak it or bind a stale one.
if (!isOpen || mode !== "capture" || token !== streamToken) {
for (const track of pending.getTracks()) track.stop();
return;
}
stream = pending;
if (video) {
video.srcObject = stream;
await video.play();
}
} catch (err) {
console.error("[personal] camera error", err);
showPermissionDialog = true;
mode = "pick";
}
}

async function chooseCamera() {
pendingSource = "camera";
const granted = await checkAndRequestPermission();
if (!granted) {
showPermissionDialog = true;
return;
}
// Default to the back camera each time the viewfinder is opened.
facingMode = "environment";
mode = "capture";
// Wait for the <video> element to mount before binding the stream.
queueMicrotask(async () => {
try {
const pending = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
// The sheet may have been closed while getUserMedia was pending
// — if mode is no longer "capture" we'd otherwise leak the
// stream after stopStream() ran with stream still null.
if (!isOpen || mode !== "capture") {
for (const track of pending.getTracks()) track.stop();
return;
}
stream = pending;
if (video) {
video.srcObject = stream;
await video.play();
}
} catch (err) {
console.error("[personal] camera error", err);
showPermissionDialog = true;
mode = "pick";
}
});
queueMicrotask(startStream);
}

// Toggle between front (selfie) and back cameras, restarting the stream.
function flipCamera() {
facingMode = facingMode === "user" ? "environment" : "user";
startStream();
}

function chooseGallery() {
Expand Down Expand Up @@ -258,6 +367,19 @@ const sheetTitle = $derived(editing ? "Edit photo mark" : "Add photo mark");
class="absolute inset-0 w-full h-full rounded-2xl object-cover bg-black"
></video>
<canvas bind:this={canvas} class="hidden"></canvas>
<button
type="button"
aria-label="Switch camera"
class="absolute top-3 right-3 w-10 h-10 rounded-full bg-black/50 flex items-center justify-center active:opacity-70"
onclick={flipCamera}
>
<HugeiconsIcon
icon={CameraRotated01Icon}
size={20}
strokeWidth={2}
color="white"
/>
</button>
</div>
<ButtonAction class="w-full" callback={capturePhoto}>
Capture
Expand Down
Loading