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:
gsinghpal
2026-04-25 12:54:34 -04:00
parent 74db636458
commit c27e8a109c
5 changed files with 10228 additions and 24 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.16.0.0',
'version': '19.0.17.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'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/manager_dashboard.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
# `import { QrScanner } from "./qr_scanner"` resolves.
'fusion_plating_shopfloor/static/src/js/qr_scanner.js',

View File

@@ -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`)

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,25 @@
// Fusion Plating — Reusable QR Scanner OWL Component
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// Renders a single button. On click, opens a modal that streams the rear
// camera into a <video> element and uses the browser's BarcodeDetector
// API to decode QR codes in real time. When a code is detected, parses
// it as a URL, extracts /fp/job/<id> (or /fp/wo/<id> as a legacy alias),
// and opens the matching fp.job form via the action service.
// Decoder selection — strongest available wins:
// 1. Native BarcodeDetector API (Android Chrome, iOS Safari 17+, desktop
// Chrome / Edge — fastest, hardware
// accelerated, no JS in the hot path)
// 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
// getUserMedia is unavailable (e.g. on insecure origins, older Safari).
//
// BarcodeDetector is supported on:
// * Android Chrome (since 2019)
// * iOS Safari 17+ (2023)
// * Desktop Chrome / Edge
// The component renders a single button. On click, opens a modal that
// streams the rear camera into a <video> element, draws each frame into
// an offscreen <canvas>, and feeds the ImageData to whichever decoder
// is available. Detected URLs matching /fp/job/<id> (or /fp/wo/<id> as
// a legacy alias from older mrp.workorder stickers) open the matching
// fp.job form via the action service.
//
// Used by Manager Desk, Tablet Station, Plant Overview, and Process Tree
// headers — see each component's `static components = { QrScanner }`.
@@ -39,14 +45,25 @@ export class QrScanner extends Component {
this.action = useService("action");
this.notification = useService("notification");
this.videoRef = useRef("video");
const hasNative = typeof BarcodeDetector !== "undefined";
const hasJsQR = typeof window !== "undefined" && typeof window.jsQR === "function";
this.state = useState({
open: false,
error: null,
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.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());
}
@@ -63,8 +80,12 @@ export class QrScanner extends Component {
}
async _startCamera() {
if (!this.state.canScan) {
// No decoder at all — paste UI is the only path.
return;
}
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;
}
try {
@@ -77,10 +98,16 @@ export class QrScanner extends Component {
const v = this.videoRef.el;
if (v) {
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();
}
if (this.state.supportsBarcode) {
this._decodeLoop();
if (this.state.decoder === "native") {
this._nativeDecodeLoop();
} else if (this.state.decoder === "jsqr") {
this._jsQRDecodeLoop();
}
} catch (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"] });
this.decodeLoopActive = true;
const v = this.videoRef.el;
@@ -112,7 +143,7 @@ export class QrScanner extends Component {
}
}
} 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.)
// surface via _startCamera's catch.
}
@@ -121,6 +152,60 @@ export class QrScanner extends Component {
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() {
if (this.state.manualUrl) {
this._handleCode(this.state.manualUrl);

View File

@@ -2,6 +2,11 @@
<!--
Copyright 2026 Nexa Systems Inc. · License OPL-1
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">
<t t-name="fusion_plating_shopfloor.QrScanner">
@@ -20,12 +25,12 @@
</button>
</div>
<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">
Live decoding isn't supported on this browser.
Paste the URL below.
Live decoding isn't supported in this browser.
Paste the sticker URL below.
</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"/>
<div t-if="state.error" class="o_fp_qr_error">
<i class="fa fa-exclamation-triangle me-1"/>