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/<UID>: 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/<UID> und oeffnet das Einzelstueck am aktiven Server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,5 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.associated-domains</key>
|
|
||||||
<array>
|
|
||||||
<string>applinks:vorrania-ch.scarriffle.com</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -30,17 +30,31 @@ final class Router: ObservableObject {
|
|||||||
route = .expiring
|
route = .expiring
|
||||||
}
|
}
|
||||||
|
|
||||||
/// vorrania://checkin bzw. vorrania://checkout – oder ein Universal Link
|
/// vorrania://checkin | checkout … (Aktionen) oder vorrania://i/<UID>
|
||||||
/// (https://…/i/<UID>), der das Einzelstück direkt öffnet.
|
/// (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) {
|
func handle(url: URL) {
|
||||||
if url.scheme == "vorrania" {
|
if url.scheme == "vorrania" {
|
||||||
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased()
|
// Host + Pfad zu Bestandteilen ohne Trenner: ["i","ABC123"] bzw. ["checkin"].
|
||||||
if let route = Route(rawValue: target) { self.route = route }
|
let comps = ([url.host].compactMap { $0 } + url.pathComponents)
|
||||||
|
.filter { $0 != "/" && !$0.isEmpty }
|
||||||
|
// Einzelstück: vorrania://i/<UID> – am aktuell aktiven Server öffnen.
|
||||||
|
if let i = comps.firstIndex(where: { $0.lowercased() == "i" }), i + 1 < comps.count {
|
||||||
|
openItemUid = comps[i + 1].uppercased()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let parts = url.pathComponents // z.B. ["/", "i", "ABC123"]
|
if let route = comps.first.flatMap({ Route(rawValue: $0.lowercased()) }) { self.route = route }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Alt-Fall: Universal Link (…/i/<UID>) – 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 {
|
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)
|
Session.shared.switchToProfile(matching: url)
|
||||||
openItemUid = parts[idx + 1].uppercased()
|
openItemUid = parts[idx + 1].uppercased()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useToast } from "../toast";
|
|||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import { REASONS } from "./ObjektBestand";
|
import { REASONS } from "./ObjektBestand";
|
||||||
import { locationOptions, locationPathById } from "../locationPath";
|
import { locationOptions, locationPathById } from "../locationPath";
|
||||||
|
import { itemDeepLink } from "../qr";
|
||||||
|
|
||||||
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
|
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() {
|
async function druckeEtiketten() {
|
||||||
const labels = await Promise.all(items.map(async (it) => {
|
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, "");
|
const name = (product.name || "").replace(/[<>&]/g, "");
|
||||||
return `<div class="label"><img src="${url}"/><div class="uid">${it.uid}</div><div class="name">${name}</div></div>`;
|
return `<div class="label"><img src="${url}"/><div class="uid">${it.uid}</div><div class="name">${name}</div></div>`;
|
||||||
}));
|
}));
|
||||||
@@ -270,7 +271,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
|
|||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<Fragment key={it.id}>
|
<Fragment key={it.id}>
|
||||||
<tr className="einzel-mainrow">
|
<tr className="einzel-mainrow">
|
||||||
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
|
<td><QrImg text={itemDeepLink(it.uid)} size={56} /></td>
|
||||||
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
<td data-label="UID" className="strong nowrap">{it.uid}</td>
|
||||||
<td data-label="Lagerort">
|
<td data-label="Lagerort">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { api } from "../api";
|
|||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import { asTree } from "../categoryTree";
|
import { asTree } from "../categoryTree";
|
||||||
|
import { itemDeepLink } from "../qr";
|
||||||
|
|
||||||
// Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute).
|
// Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute).
|
||||||
// QR-Inhalt = Link aufs Stück.
|
// 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 head = ["QR-Inhalt", "UID", "Produkt", "Marke", "Kategorie", "Lagerort"];
|
||||||
const delimRe = delim === "\t" ? "\\t" : delim;
|
const delimRe = delim === "\t" ? "\\t" : delim;
|
||||||
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
|
const needsQuote = new RegExp(`[${delimRe}"\\n\\r]`);
|
||||||
@@ -20,7 +21,7 @@ function buildLabelCsv(rows, origin, delim = ",") {
|
|||||||
};
|
};
|
||||||
const lines = [head.map(esc).join(delim)];
|
const lines = [head.map(esc).join(delim)];
|
||||||
for (const r of rows) {
|
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));
|
.map(esc).join(delim));
|
||||||
}
|
}
|
||||||
return "" + lines.join("\r\n");
|
return "" + lines.join("\r\n");
|
||||||
@@ -161,7 +162,7 @@ export default function Transfer() {
|
|||||||
try {
|
try {
|
||||||
const rows = await api.labelRows([...selCats]);
|
const rows = await api.labelRows([...selCats]);
|
||||||
if (!rows.length) { setLabelInfo("Keine Einzelstücke in der Auswahl."); return; }
|
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 blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import QRCode from "qrcode";
|
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). */
|
/** Kleines QR-Bild für einen Wert (asynchron erzeugt). */
|
||||||
export function QrImg({ text, size = 60 }) {
|
export function QrImg({ text, size = 60 }) {
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user