52daaf8611
Das QR-Blatt benutzte dm-speicherleiste als reine Gestaltung eines Knopfpaares, obwohl dort nichts gespeichert wird - vom Eigentuemer richtiggestellt. Der Name machte eine Pruefung unbrauchbar, die genau danach suchte. Jetzt trennt .dm-knopfpaar (zwei gleich breite Knoepfe) von .dm-speicherleiste (dasselbe Paar als klebende Leiste fuer ein Formular mit Speichern-Schritt). "RS 6 Avant performance" ist aus der Modellauswahl entfernt; "RS 6 Avant" stand bereits darin, die Ausfuehrung traegt der Nutzer selbst ein. Es war der einzige Eintrag, der in der auf 180px vereinheitlichten Auswahl abgeschnitten wurde. Ein Profil mit dem alten Wert behaelt ihn - die Auswahl stellt einen nicht gelisteten Titel als eigene Option voran. Nachgemessen am laufenden System: .dm-knopfpaar flex mit 12px Abstand und zwei gleich breiten Knoepfen, .dm-speicherleiste weiterhin klebend; zehn Modelle, laengster 115px bei 142px Platz. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
/**
|
|
* QR-Code scannen, um den Zugangs-Token nicht abtippen zu müssen.
|
|
*
|
|
* WARUM KEIN NATIVES SCANNER-PLUGIN
|
|
* ---------------------------------
|
|
* `@capacitor-mlkit/barcode-scanning` wäre der bequemere Weg (native
|
|
* Scanner-Oberfläche, schneller), zieht aber GoogleMLKit über CocoaPods nach -
|
|
* mehrere Dutzend Megabyte und ein `pod install`, das auf dem Mac gelingen
|
|
* muss. Genau der Xcode-Lauf, der die App überhaupt erst wieder installierbar
|
|
* macht (Abschnitt CD), darf daran nicht scheitern. jsQR sind 30 KB
|
|
* JavaScript, kein Pod, kein nativer Code - der einzige native Anteil ist
|
|
* `NSCameraUsageDescription` in der Info.plist, und den setzt
|
|
* `ios-signieren.sh` wie schon den Standort- und die Kalender-Schlüssel.
|
|
*
|
|
* WKWebView liefert `getUserMedia` seit iOS 14.3 aus; die App läuft unter
|
|
* `capacitor://localhost` und damit in einem sicheren Kontext.
|
|
*
|
|
* WAS IM CODE STEHT
|
|
* -----------------
|
|
* Home Assistant zeigt den Token beim Anlegen selbst als QR-Code an, und der
|
|
* trägt **nur den Token**, nicht die Adresse der Instanz (deshalb ist die
|
|
* eigene QR-Seite unter `/local/` im Umsetzungsplan ersatzlos entfallen).
|
|
* Steht im Code trotzdem eine http-Adresse, ist es offensichtlich die
|
|
* Server-Adresse - dann wandert sie in das andere Feld, statt als Token
|
|
* abgelehnt zu werden.
|
|
*
|
|
* ZWEITER WEG: EIN GESPEICHERTES BILD
|
|
* -----------------------------------
|
|
* Der QR-Code steht in Home Assistant auf demselben Bildschirm, auf dem der
|
|
* Token entsteht - und das ist beim Einrichten meist der Rechner, nicht das
|
|
* Telefon. Ein abfotografierter oder als Bildschirmfoto gesicherter Code ist
|
|
* dann der kuerzere Weg, als das Telefon vor den Monitor zu halten - und ohne
|
|
* Kamera (Simulator, Testumgebung) der einzige. jsQR bekommt dafuer dieselben
|
|
* Bilddaten wie aus dem Kamerabild: derselbe Leser, nur eine andere Quelle.
|
|
*/
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
|
|
import { ActionButton, Sheet } from "@audi-dash/ui"
|
|
|
|
export type QrFund = { art: "token" | "adresse"; wert: string }
|
|
|
|
/** Entscheidet, wofür der gelesene Text taugt. */
|
|
export function qrFundDeuten(roh: string): QrFund | null {
|
|
const t = (roh ?? "").trim()
|
|
if (!t) return null
|
|
if (/^https?:\/\//i.test(t)) return { art: "adresse", wert: t }
|
|
// Ein Langzeit-Token von Home Assistant ist ein JWT: drei mit Punkt
|
|
// getrennte Teile, durchgehend URL-sicheres Base64. Kürzeres kann kein
|
|
// Token sein - dann lieber nichts übernehmen als ein falsches Feld füllen.
|
|
if (t.length < 40 || /\s/.test(t)) return null
|
|
return { art: "token", wert: t }
|
|
}
|
|
|
|
/**
|
|
* Laengste Kante, auf die ein Foto vor dem Lesen verkleinert wird. Ein
|
|
* Handyfoto hat leicht 4000 px, und jsQR laeuft darueber Pixel fuer Pixel;
|
|
* ein QR-Code bleibt auch bei 2000 px klar genug. Deutlich kleiner waere
|
|
* riskant - dann verschwimmen die Module eines nicht formatfuellend
|
|
* aufgenommenen Codes.
|
|
*/
|
|
const MAX_KANTE = 2000
|
|
|
|
/**
|
|
* Dekodiert die Datei zu etwas, das auf eine Leinwand gezeichnet werden kann.
|
|
*
|
|
* `createImageBitmap` ist dafuer der vorgesehene Weg und nimmt einen Blob
|
|
* direkt. Der Ruckfall ist bewusst das `load`-Ereignis und NICHT `decode()`:
|
|
* am 04.09.2026 an der Musterseite gemessen, dass `decode()` bei einem GIF in
|
|
* Chromium nie aufloest - das Bild war laengst geladen (294x294), das
|
|
* Versprechen blieb offen. Das Blatt haette dann ohne Fehlermeldung ewig
|
|
* gewartet, und genau das ist schlimmer als eine klare Absage.
|
|
*/
|
|
async function bildDekodieren(datei: File): Promise<CanvasImageSource & { width: number; height: number }> {
|
|
if (typeof createImageBitmap === "function") {
|
|
try {
|
|
return await createImageBitmap(datei)
|
|
} catch {
|
|
// Weiter mit dem Rueckfall - manche Formate kennt nur der <img>-Pfad.
|
|
}
|
|
}
|
|
const url = URL.createObjectURL(datei)
|
|
try {
|
|
const bild = new Image()
|
|
await new Promise<void>((fertig, fehler) => {
|
|
bild.onload = () => fertig()
|
|
bild.onerror = () => fehler(new Error("Bild nicht lesbar"))
|
|
bild.src = url
|
|
})
|
|
return Object.assign(bild, { width: bild.naturalWidth, height: bild.naturalHeight })
|
|
} finally {
|
|
// Ohne das haelt der Browser die Datei bis zum Neuladen im Speicher.
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Liest den QR-Code aus einer gespeicherten Bilddatei - `null`, wenn keiner
|
|
* darin steht. Wirft, wenn der Browser das Bildformat gar nicht dekodieren
|
|
* kann (HEIC am Rechner ist der praktische Fall).
|
|
*/
|
|
export async function qrAusDatei(datei: File): Promise<string | null> {
|
|
const bild = await bildDekodieren(datei)
|
|
try {
|
|
const faktor = Math.min(1, MAX_KANTE / Math.max(bild.width, bild.height))
|
|
const breite = Math.max(1, Math.round(bild.width * faktor))
|
|
const hoehe = Math.max(1, Math.round(bild.height * faktor))
|
|
const leinwand = document.createElement("canvas")
|
|
leinwand.width = breite
|
|
leinwand.height = hoehe
|
|
const stift = leinwand.getContext("2d", { willReadFrequently: true })
|
|
if (!stift) return null
|
|
stift.drawImage(bild, 0, 0, breite, hoehe)
|
|
const { default: jsQR } = await import("jsqr")
|
|
const daten = stift.getImageData(0, 0, breite, hoehe)
|
|
// attemptBoth, anders als im Kamerabild: ein Bildschirmfoto aus dem
|
|
// Dunkelmodus zeigt den Code hell auf dunkel, also invertiert.
|
|
const treffer = jsQR(daten.data, daten.width, daten.height, {
|
|
inversionAttempts: "attemptBoth",
|
|
})
|
|
return treffer ? treffer.data : null
|
|
} finally {
|
|
if ("close" in bild && typeof bild.close === "function") bild.close()
|
|
}
|
|
}
|
|
|
|
/** Wie oft ein Bild ausgewertet wird. Öfter kostet Akku, ohne schneller zu treffen. */
|
|
const TAKT_MS = 250
|
|
|
|
export function QrScanner({
|
|
offen,
|
|
schliessen,
|
|
gefunden,
|
|
}: {
|
|
offen: boolean
|
|
schliessen: () => void
|
|
gefunden: (fund: QrFund) => void
|
|
}) {
|
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
|
const dateiRef = useRef<HTMLInputElement | null>(null)
|
|
const [fehler, setzeFehler] = useState<string | null>(null)
|
|
// Ohne Kamera waere das schwarze Quadrat totes Beiwerk - dann bleibt der
|
|
// Weg ueber die Bilddatei uebrig, und der soll die Flaeche haben.
|
|
const [kameraAus, setzeKameraAus] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (!offen) return
|
|
let abgebrochen = false
|
|
let strom: MediaStream | null = null
|
|
let takt: ReturnType<typeof setInterval> | null = null
|
|
|
|
const starten = async () => {
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
setzeKameraAus(true)
|
|
setzeFehler("Dieses Gerät stellt der App keine Kamera zur Verfügung.")
|
|
return
|
|
}
|
|
try {
|
|
strom = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: "environment" },
|
|
audio: false,
|
|
})
|
|
} catch {
|
|
setzeKameraAus(true)
|
|
setzeFehler(
|
|
"Kein Zugriff auf die Kamera. In den Einstellungen des Geräts unter „DataMetric360“ freigeben.",
|
|
)
|
|
return
|
|
}
|
|
if (abgebrochen) {
|
|
strom.getTracks().forEach((s) => s.stop())
|
|
return
|
|
}
|
|
const video = videoRef.current
|
|
if (!video) return
|
|
video.srcObject = strom
|
|
await video.play().catch(() => undefined)
|
|
|
|
const { default: jsQR } = await import("jsqr")
|
|
const leinwand = document.createElement("canvas")
|
|
const stift = leinwand.getContext("2d", { willReadFrequently: true })
|
|
if (!stift) return
|
|
|
|
takt = setInterval(() => {
|
|
if (!video.videoWidth) return
|
|
leinwand.width = video.videoWidth
|
|
leinwand.height = video.videoHeight
|
|
stift.drawImage(video, 0, 0, leinwand.width, leinwand.height)
|
|
const bild = stift.getImageData(0, 0, leinwand.width, leinwand.height)
|
|
const treffer = jsQR(bild.data, bild.width, bild.height)
|
|
if (!treffer) return
|
|
const fund = qrFundDeuten(treffer.data)
|
|
if (!fund) {
|
|
setzeFehler("Dieser Code enthält weder einen Token noch eine Adresse.")
|
|
return
|
|
}
|
|
gefunden(fund)
|
|
schliessen()
|
|
}, TAKT_MS)
|
|
}
|
|
|
|
void starten()
|
|
return () => {
|
|
abgebrochen = true
|
|
if (takt) clearInterval(takt)
|
|
// Ohne das bleibt die Kameraleuchte an, auch wenn das Blatt zu ist.
|
|
strom?.getTracks().forEach((s) => s.stop())
|
|
}
|
|
}, [offen, gefunden, schliessen])
|
|
|
|
const bildLesen = async (datei: File | undefined) => {
|
|
if (!datei) return
|
|
setzeFehler(null)
|
|
let text: string | null
|
|
try {
|
|
text = await qrAusDatei(datei)
|
|
} catch {
|
|
setzeFehler(
|
|
"Dieses Bildformat kann der Browser nicht lesen. Bitte als JPEG, PNG oder WebP sichern.",
|
|
)
|
|
return
|
|
}
|
|
if (!text) {
|
|
setzeFehler(
|
|
"In diesem Bild ist kein QR-Code zu erkennen. Am besten den Code formatfüllend aufnehmen.",
|
|
)
|
|
return
|
|
}
|
|
const fund = qrFundDeuten(text)
|
|
if (!fund) {
|
|
setzeFehler("Dieser Code enthält weder einen Token noch eine Adresse.")
|
|
return
|
|
}
|
|
gefunden(fund)
|
|
schliessen()
|
|
}
|
|
|
|
return (
|
|
<Sheet
|
|
open={offen}
|
|
onClose={schliessen}
|
|
title="QR-Code scannen"
|
|
description="Den QR-Code aus Home Assistant in den Rahmen halten - oder ein gespeichertes Bild davon auswählen."
|
|
>
|
|
{!kameraAus && (
|
|
<div className="dm-qr">
|
|
<video ref={videoRef} className="dm-qr__bild" muted playsInline />
|
|
<span className="dm-qr__rahmen" aria-hidden="true" />
|
|
</div>
|
|
)}
|
|
{fehler && (
|
|
<p className="dm-fehler" role="alert">
|
|
{fehler}
|
|
</p>
|
|
)}
|
|
<input
|
|
ref={dateiRef}
|
|
type="file"
|
|
accept="image/*"
|
|
hidden
|
|
onChange={(e) => {
|
|
const datei = e.target.files?.[0]
|
|
// Zuruecksetzen, damit dasselbe Bild ein zweites Mal gewaehlt werden
|
|
// kann - sonst feuert onChange beim gleichen Namen nicht.
|
|
e.target.value = ""
|
|
void bildLesen(datei)
|
|
}}
|
|
/>
|
|
{/* Knopfpaar, keine Speicherleiste: das gewählte Bild wirkt sofort. */}
|
|
<div className="dm-knopfpaar">
|
|
<ActionButton onClick={schliessen}>Abbrechen</ActionButton>
|
|
<ActionButton variant="primary" onClick={() => dateiRef.current?.click()}>
|
|
Bild auswählen
|
|
</ActionButton>
|
|
</div>
|
|
</Sheet>
|
|
)
|
|
}
|