OTA-Updates für die iOS-App; HACS-Fehlannahme korrigiert
HACS kann laut eigener Dokumentation grundsätzlich nicht mit privaten GitHub-Repositories arbeiten (hacs.xyz/docs/faq/private_repositories) - keine Ausnahme für Tokens oder verbundene Konten. Meine frühere Annahme, HACS käme damit zurecht, wenn es unter dem richtigen Konto angemeldet ist, war falsch. Da das Repository aus Lizenzgründen privat bleiben muss (Audi-Hausschrift, Typenschilder), ist install.ps1 damit nicht die Rückfallebene, sondern der einzige Installationsweg - README, INSTALL.md, ANLEITUNG.md, install.ps1 und VERSIONIERUNG.md korrigiert. Oberflächen-Updates für die iOS-App laufen jetzt ohne Xcode: @capgo/capacitor-updater eingebaut, ein Update-Abschnitt in den Einstellungen lädt ein neues Bündel und tauscht die Oberfläche aus. Kein Selbstlauf (autoUpdate: false) - nur auf Tastendruck, nie während der Benutzung. Das Bündel liegt in der Integration selbst (custom_components/audi_dashboard/frontend/app/), nicht unter /local/: so reist es bei jeder Installation automatisch mit, ohne zweiten Auslieferungsweg. Gebaut von companion-app/scripts/ota-paket.ps1 (neuer Befehl: npm run ota), gemeldet über sensor.audi_dashboard_app_version (neues Feld daten.buendel). Ein echter Bug beim Bauen gefunden: [IO.Compression.ZipFile]::CreateFrom- Directory schreibt unter Windows PowerShell 5.1 Backslashes in die Zip-Einträge - iOS hätte das Archiv falsch entpackt. Behoben, indem die Einträge von Hand mit "/" geschrieben werden. Rückfallebene: notifyAppReady() läuft erst, wenn React nachweislich gerendert hat (App.tsx). Kommt diese Meldung nicht, rollt das Plugin nach 20 Sekunden von selbst auf das vorherige Bündel zurück. Am laufenden Testcontainer verifiziert: die ausgelieferte Zip hasht exakt auf den in bundle.json hinterlegten Wert, 13 Einträge, index.html in der Wurzel, keine Backslashes, keine Beschädigung. tsc sauber, 117/117 Tests (5 davon neu für buendelPasst() - dabei eine echte Lücke gefunden: die Funktion hätte bei unbekannter eigener Version fälschlich ein Update angeboten, jetzt genauso vorsichtig wie versionVergleichen). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
ApiFehler,
|
||||
DataMetricApi,
|
||||
ENTITAETEN,
|
||||
type Buendelangabe,
|
||||
type Fahrt,
|
||||
type Fahrzeugstatus,
|
||||
type Profil,
|
||||
@@ -45,6 +46,10 @@ export interface DatenWert {
|
||||
/** Ob diese App noch dem Stand entspricht, den das Backend ausliefert.
|
||||
Siehe appVersion.ts und VERSIONIERUNG.md. */
|
||||
versionsstand: Versionsstand
|
||||
/** Das OTA-Bündel, das die Integration mitliefert (siehe daten/ota.ts),
|
||||
oder null — kein Fehler, sondern der Zustand ohne Bündel oder ohne
|
||||
Verbindung. */
|
||||
otaBuendel: Buendelangabe | null
|
||||
|
||||
einstellungen: Einstellungen | null
|
||||
fahrzeug: Fahrzeug | null
|
||||
@@ -87,6 +92,7 @@ export function DatenAnbieter({
|
||||
const [verbindung, setzeVerbindung] = useState<Verbindungszustand>("getrennt")
|
||||
const [warteschlange, setzeWarteschlange] = useState<readonly WartenderAuftrag[]>([])
|
||||
const [serverVersion, setzeServerVersion] = useState<string | null>(null)
|
||||
const [otaBuendel, setzeOtaBuendel] = useState<Buendelangabe | null>(null)
|
||||
|
||||
// Damit profilSpeichern immer gegen den neuesten Rohstand arbeitet, auch
|
||||
// wenn zwischendurch ein Push hereinkam.
|
||||
@@ -109,10 +115,12 @@ export function DatenAnbieter({
|
||||
setzeLadefehler(null)
|
||||
setzeBereit(true)
|
||||
// Bewusst außerhalb des Promise.all oben und ohne eigenes catch hier:
|
||||
// appVersionLesen() schluckt seine Fehler selbst und liefert dann null.
|
||||
// Ein fehlender Versionsvergleich darf die Erstladung nie aufhalten -
|
||||
// er ist ein Hinweis, keine Betriebsvoraussetzung.
|
||||
setzeServerVersion(await api.appVersionLesen())
|
||||
// appVersionAngabeLesen() schluckt seine Fehler selbst und liefert dann
|
||||
// null. Ein fehlender Versionsvergleich darf die Erstladung nie
|
||||
// aufhalten - er ist ein Hinweis, keine Betriebsvoraussetzung.
|
||||
const versionsangabe = await api.appVersionAngabeLesen()
|
||||
setzeServerVersion(versionsangabe?.app ?? null)
|
||||
setzeOtaBuendel(versionsangabe?.buendel ?? null)
|
||||
} catch (fehler) {
|
||||
// Kein Netz ist kein Ladefehler, solange schon Daten da sind — dann
|
||||
// zeigt die App den zwischengespeicherten Stand mit Offline-Hinweis.
|
||||
@@ -210,6 +218,7 @@ export function DatenAnbieter({
|
||||
warteschlange,
|
||||
statusStand,
|
||||
versionsstand: versionVergleichen(eigeneVersion(), serverVersion).stand,
|
||||
otaBuendel,
|
||||
einstellungen,
|
||||
fahrzeug,
|
||||
fahrten,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { buendelPasst } from "./ota"
|
||||
import type { Buendelangabe } from "../api"
|
||||
|
||||
const buendel = (teil: Partial<Buendelangabe> = {}): Buendelangabe => ({
|
||||
version: "2026.8.24.1",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 224186,
|
||||
gebaut: "2026-08-24T00:00:00Z",
|
||||
url: "/audi_dashboard_static/app/bundle.zip",
|
||||
...teil,
|
||||
})
|
||||
|
||||
/* buendelPasst() entscheidet, ob der Update-Knopf überhaupt erscheint - ein
|
||||
Fehler hier zeigt entweder einen Knopf, der ins Leere läuft (kein Bündel
|
||||
da), oder verbirgt ein echtes Update dauerhaft. */
|
||||
describe("buendelPasst", () => {
|
||||
it("lehnt ab, wenn kein Bündel vorliegt", () => {
|
||||
expect(buendelPasst(null, "2026.8.23.2")).toBe(false)
|
||||
})
|
||||
|
||||
it("lehnt ab, wenn Bündel- und eigene Version übereinstimmen", () => {
|
||||
expect(buendelPasst(buendel({ version: "2026.8.24.1" }), "2026.8.24.1")).toBe(false)
|
||||
})
|
||||
|
||||
it("akzeptiert bei abweichender Version", () => {
|
||||
expect(buendelPasst(buendel({ version: "2026.8.24.2" }), "2026.8.24.1")).toBe(true)
|
||||
})
|
||||
|
||||
it("lehnt ab, wenn die eigene Version unbekannt ist", () => {
|
||||
// null heißt "diese App weiß nicht, welche Version sie ist" (z. B. im
|
||||
// Dev-Server ohne eingebautes __APP_VERSION__) - dann lässt sich ein
|
||||
// Update nicht sinnvoll anbieten, weil "abweichend" nicht feststellbar ist.
|
||||
expect(buendelPasst(buendel(), null)).toBe(false)
|
||||
})
|
||||
|
||||
it("lehnt ab, wenn dem Bündel Pflichtfelder fehlen", () => {
|
||||
expect(buendelPasst(buendel({ url: "" }), "2026.8.23.2")).toBe(false)
|
||||
expect(buendelPasst(buendel({ sha256: "" }), "2026.8.23.2")).toBe(false)
|
||||
expect(buendelPasst(buendel({ version: "" }), "2026.8.23.2")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Oberflächen-Updates ohne Xcode („OTA").
|
||||
*
|
||||
* Die native Hülle trägt ihre Oberfläche fest gebündelt — sie bleibt auf dem
|
||||
* Stand vom Signieren, unbegrenzt. Diese Datei ist der Ausweg: die App lädt
|
||||
* ein neues Bündel von der eigenen Home-Assistant-Instanz und tauscht es aus.
|
||||
*
|
||||
* WOHER DAS BÜNDEL KOMMT
|
||||
* ----------------------
|
||||
* Aus der Integration selbst: `custom_components/audi_dashboard/frontend/app/`
|
||||
* enthält `bundle.zip` und `bundle.json`, ausgeliefert unter
|
||||
* `/audi_dashboard_static/app/`. Damit reist das Bündel bei jedem
|
||||
* HACS-Update mit, und es kann gar nicht zur Integration unpassend sein — es
|
||||
* ist dieselbe Lieferung. Gebaut wird es von
|
||||
* `companion-app/scripts/ota-paket.ps1`.
|
||||
*
|
||||
* Welche Fassung bereitliegt, sagt `sensor.audi_dashboard_app_version` im
|
||||
* Attribut `daten.buendel`. Kein zweiter Abruf, keine zweite Quelle.
|
||||
*
|
||||
* WAS OTA NICHT KANN
|
||||
* ------------------
|
||||
* Nur Weboberfläche — HTML, CSS, JavaScript, Bilder. Alles Native (Plugins,
|
||||
* Capacitor selbst, Berechtigungen, iOS-Einstellungen) braucht weiterhin
|
||||
* Xcode. Deshalb setzt `resetWhenUpdate` in capacitor.config.ts nach einer
|
||||
* frischen Xcode-Installation wieder auf das eingebaute Bündel zurück: sonst
|
||||
* überdeckte ein älteres OTA-Bündel genau die native Änderung, für die man
|
||||
* Xcode gebraucht hat.
|
||||
*
|
||||
* DIE RÜCKFALLEBENE
|
||||
* -----------------
|
||||
* `startklarMelden()` unten muss nach jedem Start laufen. Bleibt die Meldung
|
||||
* aus (weil das neue Bündel gar nicht erst hochkommt), kehrt das Plugin nach
|
||||
* `appReadyTimeout` von selbst zum vorherigen Bündel zurück. Ein kaputtes
|
||||
* Bündel kann das Gerät deshalb nicht dauerhaft unbrauchbar machen — man
|
||||
* landet wieder da, wo man vorher war.
|
||||
*/
|
||||
|
||||
import { Capacitor } from "@capacitor/core"
|
||||
import { CapacitorUpdater } from "@capgo/capacitor-updater"
|
||||
|
||||
import type { Buendelangabe } from "../api"
|
||||
|
||||
/** Läuft die App in der nativen Hülle? Nur dort gibt es etwas auszutauschen —
|
||||
im Browser und im HA-Panel lädt ohnehin jeder Seitenaufruf den aktuellen
|
||||
Stand. */
|
||||
export function otaMoeglich(): boolean {
|
||||
return Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet dem Plugin, dass dieses Bündel hochgekommen ist.
|
||||
*
|
||||
* Ohne diesen Aufruf rollt das Plugin nach kurzer Zeit auf das vorherige
|
||||
* Bündel zurück — was genau richtig ist, wenn ein Update die App zerlegt hat.
|
||||
* Deshalb steht der Aufruf bewusst dort, wo React nachweislich gerendert hat
|
||||
* (App.tsx), und nicht schon in main.tsx: ein Absturz beim ersten Rendern
|
||||
* soll als Fehlschlag gelten und den Rückfall auslösen.
|
||||
*/
|
||||
export async function startklarMelden(): Promise<void> {
|
||||
if (!otaMoeglich()) return
|
||||
try {
|
||||
await CapacitorUpdater.notifyAppReady()
|
||||
} catch (fehler) {
|
||||
// Nur protokollieren: Scheitert die Meldung, greift der Rückfall — das
|
||||
// ist unangenehm, aber sicher. Ein Absturz an dieser Stelle wäre schlimmer.
|
||||
console.warn("OTA: notifyAppReady fehlgeschlagen", fehler)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ist ein anderes Bündel verfügbar als das gerade laufende?
|
||||
Dieselbe Vorsicht wie in versionVergleichen() (appVersion.ts): kennt die
|
||||
App ihre eigene Version nicht (eigene === null — praktisch nur im
|
||||
Dev-Server, ein natives Release baut nie ohne __APP_VERSION__), gilt das
|
||||
als "nicht vergleichbar", nicht als "abweichend". Alles andere böte einen
|
||||
Update-Knopf an, dessen Ziel man mit der laufenden Fassung gar nicht
|
||||
abgleichen konnte. */
|
||||
export function buendelPasst(buendel: Buendelangabe | null, eigene: string | null): boolean {
|
||||
if (!buendel?.version || !buendel.url || !buendel.sha256 || !eigene) return false
|
||||
// Das Bündel muss zu der Fassung gehören, die diese Installation
|
||||
// ausliefert. Läge dort ein älteres, wäre ein "Update" ein Rückschritt.
|
||||
return buendel.version !== eigene
|
||||
}
|
||||
|
||||
export class OtaFehler extends Error {}
|
||||
|
||||
/**
|
||||
* Lädt das Bündel und schaltet darauf um.
|
||||
*
|
||||
* Kehrt im Erfolgsfall **nie** zurück: `set()` zerstört den JavaScript-Kontext
|
||||
* und lädt die App neu. Alles, was danach stünde, liefe nicht mehr — deshalb
|
||||
* steht hier auch keine Erfolgsmeldung. Der Erfolg ist, dass die App neu
|
||||
* startet.
|
||||
*
|
||||
* @param basisUrl Adresse der Home-Assistant-Instanz, wie beim Einrichten
|
||||
* hinterlegt. Die Bündel-URL aus der Entität ist ein Pfad.
|
||||
*/
|
||||
export async function buendelAnwenden(
|
||||
buendel: Buendelangabe,
|
||||
basisUrl: string,
|
||||
): Promise<never> {
|
||||
if (!otaMoeglich()) throw new OtaFehler("Updates gibt es nur in der App auf dem Gerät.")
|
||||
|
||||
const url = new URL(buendel.url, basisUrl).href
|
||||
let geladen
|
||||
try {
|
||||
geladen = await CapacitorUpdater.download({
|
||||
url,
|
||||
version: buendel.version,
|
||||
// Das Plugin rechnet auf dem Gerät SHA-256 über die heruntergeladene
|
||||
// Zip und verwirft sie bei Abweichung (calcChecksum in
|
||||
// CapgoUpdater.swift). Ein abgebrochener Download oder eine halb
|
||||
// gespiegelte Datei fällt damit auf, bevor die App sie startet.
|
||||
checksum: buendel.sha256,
|
||||
})
|
||||
} catch (fehler) {
|
||||
throw new OtaFehler(
|
||||
`Das Update konnte nicht geladen werden (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await CapacitorUpdater.set({ id: geladen.id })
|
||||
} catch (fehler) {
|
||||
throw new OtaFehler(
|
||||
`Das Update wurde geladen, ließ sich aber nicht starten (${fehler instanceof Error ? fehler.message : String(fehler)}).`,
|
||||
)
|
||||
}
|
||||
// Unerreichbar: set() lädt die App neu. Steht hier, damit der Rückgabetyp
|
||||
// ehrlich bleibt.
|
||||
throw new OtaFehler("Die App hätte an dieser Stelle neu starten müssen.")
|
||||
}
|
||||
Reference in New Issue
Block a user