From f981f41d94ad74d9761604aa497204b6938c7f14 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 30 Jul 2026 11:34:37 +0200 Subject: [PATCH] Einzelstueck-QR ueber Custom-Scheme statt Universal Link Universal Links (Associated Domains) sind an eine feste Domain gebunden - fuer einen selbstgehosteten Dienst untauglich (jede Instanz hat eine andere URL) und die Domain stand im offenen Quellcode (Leak). Jetzt tragen Einzelstueck-QRs das Custom-Scheme vorrania://i/: Die normale Kamera-App oeffnet die App weiterhin, aber ohne Domain-Bindung und ohne Server im Code. applinks-Eintrag entfernt. Web: itemDeepLink()-Helfer, genutzt in Einzelstuecke-Liste/-Etiketten und im P-touch-Export. Lagerort-QRs bleiben unveraendert (https). iOS: Router erkennt vorrania://i/ und oeffnet das Einzelstueck am aktiven Server. Co-Authored-By: Claude Opus 4.8 --- ios/Sources/Vorrania.entitlements | 4 ---- ios/Sources/VorraniaApp.swift | 26 ++++++++++++++++++++------ web/src/components/Einzelstuecke.jsx | 5 +++-- web/src/pages/Transfer.jsx | 7 ++++--- web/src/qr.jsx | 9 +++++++++ 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/ios/Sources/Vorrania.entitlements b/ios/Sources/Vorrania.entitlements index 4e3230e..6631ffa 100644 --- a/ios/Sources/Vorrania.entitlements +++ b/ios/Sources/Vorrania.entitlements @@ -2,9 +2,5 @@ - com.apple.developer.associated-domains - - applinks:vorrania-ch.scarriffle.com - diff --git a/ios/Sources/VorraniaApp.swift b/ios/Sources/VorraniaApp.swift index 7830317..7608b21 100644 --- a/ios/Sources/VorraniaApp.swift +++ b/ios/Sources/VorraniaApp.swift @@ -30,17 +30,31 @@ final class Router: ObservableObject { route = .expiring } - /// vorrania://checkin bzw. vorrania://checkout – oder ein Universal Link - /// (https://…/i/), der das Einzelstück direkt öffnet. + /// vorrania://checkin | checkout … (Aktionen) oder vorrania://i/ + /// (Einzelstück – aus einem gescannten QR, auch über die normale Kamera-App). + /// + /// Früher lief das Einzelstück über einen Universal Link auf eine feste Domain. + /// Das war für einen selbstgehosteten Dienst untauglich (jede Instanz hat eine + /// andere URL, und die Domain stünde im offenen Quellcode). Das Custom-Scheme + /// braucht keine Domain: Die Kamera-App öffnet den QR trotzdem, und der Code + /// verrät keinen Server. func handle(url: URL) { if url.scheme == "vorrania" { - let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased() - if let route = Route(rawValue: target) { self.route = route } + // Host + Pfad zu Bestandteilen ohne Trenner: ["i","ABC123"] bzw. ["checkin"]. + let comps = ([url.host].compactMap { $0 } + url.pathComponents) + .filter { $0 != "/" && !$0.isEmpty } + // Einzelstück: vorrania://i/ – am aktuell aktiven Server öffnen. + if let i = comps.firstIndex(where: { $0.lowercased() == "i" }), i + 1 < comps.count { + openItemUid = comps[i + 1].uppercased() + return + } + if let route = comps.first.flatMap({ Route(rawValue: $0.lowercased()) }) { self.route = route } return } - let parts = url.pathComponents // z.B. ["/", "i", "ABC123"] + // Alt-Fall: Universal Link (…/i/) – nur noch relevant, falls je wieder + // eine Associated Domain hinterlegt würde (dann Serverwechsel per Host). + let parts = url.pathComponents if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count { - // Auf den Server aus dem Link wechseln, damit die UID dort gesucht wird. Session.shared.switchToProfile(matching: url) openItemUid = parts[idx + 1].uppercased() } diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx index 6ee6a4c..4c76606 100644 --- a/web/src/components/Einzelstuecke.jsx +++ b/web/src/components/Einzelstuecke.jsx @@ -6,6 +6,7 @@ import { useToast } from "../toast"; import Icon from "./Icon"; import { REASONS } from "./ObjektBestand"; import { locationOptions, locationPathById } from "../locationPath"; +import { itemDeepLink } from "../qr"; const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v; @@ -224,7 +225,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh async function druckeEtiketten() { const labels = await Promise.all(items.map(async (it) => { - const url = await QRCode.toDataURL(`${origin}/i/${it.uid}`, { margin: 1, width: 260 }); + const url = await QRCode.toDataURL(itemDeepLink(it.uid), { margin: 1, width: 260 }); const name = (product.name || "").replace(/[<>&]/g, ""); return `
${it.uid}
${name}
`; })); @@ -270,7 +271,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh {items.map((it) => ( - + {it.uid} {isAdmin ? ( diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index 9fb49ba..67f347d 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -3,10 +3,11 @@ import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; import { asTree } from "../categoryTree"; +import { itemDeepLink } from "../qr"; // Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute). // QR-Inhalt = Link aufs Stück. -function buildLabelCsv(rows, origin, delim = ",") { +function buildLabelCsv(rows, delim = ",") { const head = ["QR-Inhalt", "UID", "Produkt", "Marke", "Kategorie", "Lagerort"]; const delimRe = delim === "\t" ? "\\t" : delim; const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`); @@ -20,7 +21,7 @@ function buildLabelCsv(rows, origin, delim = ",") { }; const lines = [head.map(esc).join(delim)]; for (const r of rows) { - lines.push([`${origin}/i/${r.uid}`, r.uid, r.product, r.brand, r.category, r.location] + lines.push([itemDeepLink(r.uid), r.uid, r.product, r.brand, r.category, r.location] .map(esc).join(delim)); } return "" + lines.join("\r\n"); @@ -161,7 +162,7 @@ export default function Transfer() { try { const rows = await api.labelRows([...selCats]); if (!rows.length) { setLabelInfo("Keine Einzelstücke in der Auswahl."); return; } - const csv = buildLabelCsv(rows, window.location.origin, labelDelim); + const csv = buildLabelCsv(rows, labelDelim); const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); diff --git a/web/src/qr.jsx b/web/src/qr.jsx index c8859e1..1c82a38 100644 --- a/web/src/qr.jsx +++ b/web/src/qr.jsx @@ -1,6 +1,15 @@ import { useEffect, useState } from "react"; import QRCode from "qrcode"; +/** + * QR-Inhalt für ein Einzelstück. Bewusst das Custom-Scheme `vorrania://` statt + * einer https-URL: Die normale Kamera-App öffnet damit trotzdem die App, aber + * OHNE an eine feste Domain gebunden zu sein. Das ist für einen selbstgehosteten + * Dienst nötig (jede Instanz hat eine andere URL) und der QR verrät keinen Server. + * Die App öffnet die UID am gerade aktiven Server. + */ +export const itemDeepLink = (uid) => `vorrania://i/${uid}`; + /** Kleines QR-Bild für einen Wert (asynchron erzeugt). */ export function QrImg({ text, size = 60 }) { const [url, setUrl] = useState(null);