fix(shopfloor): vendor jsQR so QR scanning works on iOS Safari
iOS Safari (and the in-app webviews in Messages / WhatsApp / LinkedIn) don't ship the BarcodeDetector API, so the previous scanner fell through to the manual paste UI on every iPhone — defeating the point of "tap to scan." Vendored cozmo/jsQR (Apache 2.0, ~250KB) and made the scanner pick the strongest available decoder at setup time: 1. native BarcodeDetector -> Android Chrome, iOS Safari 17+, desktop 2. jsQR canvas loop -> every other browser with getUserMedia 3. manual URL paste -> last-resort if camera unavailable The jsQR loop draws each video frame into an offscreen canvas, downsamples to 480px on the long side, and runs jsQR synchronously throttled to ~8 fps to stay under 10% CPU on mid-range Android phones. Template now shows the <video> element whenever ANY decoder is available (state.canScan), not just for native. Paste fallback still visible as a secondary path so a tablet with broken camera permissions still has a way in. shopfloor: 19.0.16.0.0 -> 19.0.17.0.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Plating — Shop Floor',
|
'name': 'Fusion Plating — Shop Floor',
|
||||||
'version': '19.0.16.0.0',
|
'version': '19.0.17.0.0',
|
||||||
'category': 'Manufacturing/Plating',
|
'category': 'Manufacturing/Plating',
|
||||||
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
|
||||||
'first-piece inspection gates.',
|
'first-piece inspection gates.',
|
||||||
@@ -68,6 +68,11 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
|
|||||||
'fusion_plating_shopfloor/static/src/scss/process_tree.scss',
|
'fusion_plating_shopfloor/static/src/scss/process_tree.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
|
'fusion_plating_shopfloor/static/src/scss/manager_dashboard.scss',
|
||||||
'fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss',
|
'fusion_plating_shopfloor/static/src/scss/fp_kanbans.scss',
|
||||||
|
# jsQR (vendored) — pure-JS QR decoder. Used when the browser
|
||||||
|
# doesn't expose the native BarcodeDetector API (notably iOS
|
||||||
|
# Safari < 17 and the in-app webviews in Messages / WhatsApp).
|
||||||
|
# Loaded as a plain script (UMD); attaches `window.jsQR`.
|
||||||
|
'fusion_plating_shopfloor/static/lib/jsQR/jsQR.js',
|
||||||
# qr_scanner.js MUST load before its consumers so the
|
# qr_scanner.js MUST load before its consumers so the
|
||||||
# `import { QrScanner } from "./qr_scanner"` resolves.
|
# `import { QrScanner } from "./qr_scanner"` resolves.
|
||||||
'fusion_plating_shopfloor/static/src/js/qr_scanner.js',
|
'fusion_plating_shopfloor/static/src/js/qr_scanner.js',
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
jsQR is released under the Apache License, Version 2.0.
|
||||||
|
Copyright (c) 2017 Cosmo Wolfe (https://github.com/cozmo/jsQR)
|
||||||
|
|
||||||
|
Vendored into Fusion Plating to provide QR decoding on browsers that
|
||||||
|
lack the native BarcodeDetector API (notably iOS Safari < 17 and the
|
||||||
|
in-app browsers in Messages / WhatsApp / etc).
|
||||||
|
|
||||||
|
Upstream: https://github.com/cozmo/jsQR
|
||||||
|
File: dist/jsQR.js (UMD bundle, exposes global `jsQR`)
|
||||||
10100
fusion_plating/fusion_plating_shopfloor/static/lib/jsQR/jsQR.js
Normal file
10100
fusion_plating/fusion_plating_shopfloor/static/lib/jsQR/jsQR.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,19 +3,25 @@
|
|||||||
// Fusion Plating — Reusable QR Scanner OWL Component
|
// Fusion Plating — Reusable QR Scanner OWL Component
|
||||||
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
// Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||||
//
|
//
|
||||||
// Renders a single button. On click, opens a modal that streams the rear
|
// Decoder selection — strongest available wins:
|
||||||
// camera into a <video> element and uses the browser's BarcodeDetector
|
// 1. Native BarcodeDetector API (Android Chrome, iOS Safari 17+, desktop
|
||||||
// API to decode QR codes in real time. When a code is detected, parses
|
// Chrome / Edge — fastest, hardware
|
||||||
// it as a URL, extracts /fp/job/<id> (or /fp/wo/<id> as a legacy alias),
|
// accelerated, no JS in the hot path)
|
||||||
// and opens the matching fp.job form via the action service.
|
// 2. Vendored jsQR fallback (every other browser including iOS
|
||||||
|
// Safari < 17 and the in-app webviews
|
||||||
|
// in Messages / WhatsApp / LinkedIn,
|
||||||
|
// which is what we hit in practice on
|
||||||
|
// phones today)
|
||||||
|
// 3. Manual paste (last resort: HTTP origin or no camera
|
||||||
|
// permission — typing the URL still
|
||||||
|
// works)
|
||||||
//
|
//
|
||||||
// Falls back to a paste-the-URL textbox if BarcodeDetector or
|
// The component renders a single button. On click, opens a modal that
|
||||||
// getUserMedia is unavailable (e.g. on insecure origins, older Safari).
|
// streams the rear camera into a <video> element, draws each frame into
|
||||||
//
|
// an offscreen <canvas>, and feeds the ImageData to whichever decoder
|
||||||
// BarcodeDetector is supported on:
|
// is available. Detected URLs matching /fp/job/<id> (or /fp/wo/<id> as
|
||||||
// * Android Chrome (since 2019)
|
// a legacy alias from older mrp.workorder stickers) open the matching
|
||||||
// * iOS Safari 17+ (2023)
|
// fp.job form via the action service.
|
||||||
// * Desktop Chrome / Edge
|
|
||||||
//
|
//
|
||||||
// Used by Manager Desk, Tablet Station, Plant Overview, and Process Tree
|
// Used by Manager Desk, Tablet Station, Plant Overview, and Process Tree
|
||||||
// headers — see each component's `static components = { QrScanner }`.
|
// headers — see each component's `static components = { QrScanner }`.
|
||||||
@@ -39,14 +45,25 @@ export class QrScanner extends Component {
|
|||||||
this.action = useService("action");
|
this.action = useService("action");
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
this.videoRef = useRef("video");
|
this.videoRef = useRef("video");
|
||||||
|
const hasNative = typeof BarcodeDetector !== "undefined";
|
||||||
|
const hasJsQR = typeof window !== "undefined" && typeof window.jsQR === "function";
|
||||||
this.state = useState({
|
this.state = useState({
|
||||||
open: false,
|
open: false,
|
||||||
error: null,
|
error: null,
|
||||||
manualUrl: "",
|
manualUrl: "",
|
||||||
supportsBarcode: typeof BarcodeDetector !== "undefined",
|
// True whenever ANY decoder (native or jsQR) is available.
|
||||||
|
// Drives the template: when true we show the camera <video>;
|
||||||
|
// when false we fall through to the manual paste UI only.
|
||||||
|
canScan: hasNative || hasJsQR,
|
||||||
|
decoder: hasNative ? "native" : (hasJsQR ? "jsqr" : "none"),
|
||||||
});
|
});
|
||||||
this.stream = null;
|
this.stream = null;
|
||||||
this.decodeLoopActive = false;
|
this.decodeLoopActive = false;
|
||||||
|
// Reusable offscreen canvas for the jsQR path. Allocated lazily
|
||||||
|
// on first frame so we don't pay the cost when the modal never
|
||||||
|
// opens or when the native decoder is in use.
|
||||||
|
this._canvas = null;
|
||||||
|
this._ctx = null;
|
||||||
|
|
||||||
onWillUnmount(() => this._stopCamera());
|
onWillUnmount(() => this._stopCamera());
|
||||||
}
|
}
|
||||||
@@ -63,8 +80,12 @@ export class QrScanner extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _startCamera() {
|
async _startCamera() {
|
||||||
|
if (!this.state.canScan) {
|
||||||
|
// No decoder at all — paste UI is the only path.
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
this.state.error = "Camera access not available. Use the URL input below.";
|
this.state.error = "Camera access not available on this browser. Use the URL input below.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -77,10 +98,16 @@ export class QrScanner extends Component {
|
|||||||
const v = this.videoRef.el;
|
const v = this.videoRef.el;
|
||||||
if (v) {
|
if (v) {
|
||||||
v.srcObject = this.stream;
|
v.srcObject = this.stream;
|
||||||
|
// iOS Safari requires both attributes AND the explicit
|
||||||
|
// play() call; without them the stream stays black.
|
||||||
|
v.setAttribute("playsinline", "true");
|
||||||
|
v.muted = true;
|
||||||
await v.play();
|
await v.play();
|
||||||
}
|
}
|
||||||
if (this.state.supportsBarcode) {
|
if (this.state.decoder === "native") {
|
||||||
this._decodeLoop();
|
this._nativeDecodeLoop();
|
||||||
|
} else if (this.state.decoder === "jsqr") {
|
||||||
|
this._jsQRDecodeLoop();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.state.error = "Couldn't access camera: " + (e.message || e);
|
this.state.error = "Couldn't access camera: " + (e.message || e);
|
||||||
@@ -95,8 +122,12 @@ export class QrScanner extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _decodeLoop() {
|
/**
|
||||||
if (!this.state.supportsBarcode) return;
|
* Decode loop using the browser's BarcodeDetector. Cheapest path —
|
||||||
|
* the browser does the work off the JS thread. Only runs on
|
||||||
|
* Android Chrome, iOS Safari 17+, and desktop Chrome / Edge.
|
||||||
|
*/
|
||||||
|
async _nativeDecodeLoop() {
|
||||||
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
const detector = new BarcodeDetector({ formats: ["qr_code"] });
|
||||||
this.decodeLoopActive = true;
|
this.decodeLoopActive = true;
|
||||||
const v = this.videoRef.el;
|
const v = this.videoRef.el;
|
||||||
@@ -112,7 +143,7 @@ export class QrScanner extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Decode errors are noisy and recoverable — try again
|
// Decode errors are noisy and recoverable — try the
|
||||||
// next frame. Real failures (camera revoked, etc.)
|
// next frame. Real failures (camera revoked, etc.)
|
||||||
// surface via _startCamera's catch.
|
// surface via _startCamera's catch.
|
||||||
}
|
}
|
||||||
@@ -121,6 +152,60 @@ export class QrScanner extends Component {
|
|||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode loop using the vendored jsQR library. Draws each video
|
||||||
|
* frame into an offscreen canvas, pulls ImageData, and runs jsQR
|
||||||
|
* synchronously. jsQR is ~250KB but pure JS, so it works on every
|
||||||
|
* browser that gives us getUserMedia.
|
||||||
|
*
|
||||||
|
* Throttled to one decode per ~120ms to stay under 10% CPU on
|
||||||
|
* mid-range Android phones.
|
||||||
|
*/
|
||||||
|
_jsQRDecodeLoop() {
|
||||||
|
this.decodeLoopActive = true;
|
||||||
|
const v = this.videoRef.el;
|
||||||
|
if (!v) return;
|
||||||
|
if (!this._canvas) {
|
||||||
|
this._canvas = document.createElement("canvas");
|
||||||
|
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
}
|
||||||
|
let lastDecode = 0;
|
||||||
|
const MIN_INTERVAL_MS = 120;
|
||||||
|
const tick = (now) => {
|
||||||
|
if (!this.decodeLoopActive || !this.state.open) return;
|
||||||
|
if (v.readyState >= 2 && (now - lastDecode) >= MIN_INTERVAL_MS) {
|
||||||
|
lastDecode = now;
|
||||||
|
try {
|
||||||
|
const w = v.videoWidth;
|
||||||
|
const h = v.videoHeight;
|
||||||
|
if (w && h) {
|
||||||
|
// Cap the working frame at 480px on the long side
|
||||||
|
// — jsQR doesn't need full HD and the cost scales
|
||||||
|
// with pixel count.
|
||||||
|
const scale = Math.min(1, 480 / Math.max(w, h));
|
||||||
|
const cw = Math.round(w * scale);
|
||||||
|
const ch = Math.round(h * scale);
|
||||||
|
if (this._canvas.width !== cw) this._canvas.width = cw;
|
||||||
|
if (this._canvas.height !== ch) this._canvas.height = ch;
|
||||||
|
this._ctx.drawImage(v, 0, 0, cw, ch);
|
||||||
|
const imageData = this._ctx.getImageData(0, 0, cw, ch);
|
||||||
|
const code = window.jsQR(imageData.data, cw, ch, {
|
||||||
|
inversionAttempts: "dontInvert",
|
||||||
|
});
|
||||||
|
if (code && code.data) {
|
||||||
|
this._handleCode(code.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Same as native: swallow per-frame errors and try again.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
onManualSubmit() {
|
onManualSubmit() {
|
||||||
if (this.state.manualUrl) {
|
if (this.state.manualUrl) {
|
||||||
this._handleCode(this.state.manualUrl);
|
this._handleCode(this.state.manualUrl);
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
<!--
|
<!--
|
||||||
Copyright 2026 Nexa Systems Inc. · License OPL-1
|
Copyright 2026 Nexa Systems Inc. · License OPL-1
|
||||||
Fusion Plating — Reusable QR Scanner template
|
Fusion Plating — Reusable QR Scanner template
|
||||||
|
|
||||||
|
The video element is rendered whenever ANY decoder is available
|
||||||
|
(state.canScan = native BarcodeDetector OR vendored jsQR). The
|
||||||
|
paste-URL fallback is shown unconditionally as a secondary path
|
||||||
|
so a tablet with broken camera permissions still has a way in.
|
||||||
-->
|
-->
|
||||||
<templates xml:space="preserve">
|
<templates xml:space="preserve">
|
||||||
<t t-name="fusion_plating_shopfloor.QrScanner">
|
<t t-name="fusion_plating_shopfloor.QrScanner">
|
||||||
@@ -20,12 +25,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="o_fp_qr_modal_body">
|
<div class="o_fp_qr_modal_body">
|
||||||
<div t-if="!state.supportsBarcode and !state.error"
|
<div t-if="!state.canScan and !state.error"
|
||||||
class="o_fp_qr_warn">
|
class="o_fp_qr_warn">
|
||||||
Live decoding isn't supported on this browser.
|
Live decoding isn't supported in this browser.
|
||||||
Paste the URL below.
|
Paste the sticker URL below.
|
||||||
</div>
|
</div>
|
||||||
<video t-if="state.supportsBarcode" t-ref="video"
|
<video t-if="state.canScan" t-ref="video"
|
||||||
class="o_fp_qr_video" muted="true" playsinline="true"/>
|
class="o_fp_qr_video" muted="true" playsinline="true"/>
|
||||||
<div t-if="state.error" class="o_fp_qr_error">
|
<div t-if="state.error" class="o_fp_qr_error">
|
||||||
<i class="fa fa-exclamation-triangle me-1"/>
|
<i class="fa fa-exclamation-triangle me-1"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user