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 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 09:20:14 +02:00
parent d8b12da36d
commit 1354ca6b06
22 changed files with 695 additions and 99 deletions
+28
View File
@@ -38,6 +38,34 @@ const konfiguration: CapacitorConfig = {
// CORS-Prüfung — die App braucht dadurch keinerlei CORS-Einstellung am
// Server. Die WebSocket-Verbindung läuft unberührt weiter.
CapacitorHttp: { enabled: true },
// Oberflächen-Updates ohne Xcode (siehe src/daten/ota.ts und
// ../VERSIONIERUNG.md). Das Bündel liegt in der Integration und wird
// über Home Assistant ausgeliefert — keine fremde Cloud beteiligt.
CapacitorUpdater: {
// Kein Selbstlauf: die App lädt nur, wenn der Benutzer den Knopf in den
// Einstellungen drückt. Ein Update, das ungefragt die Oberfläche
// austauscht, während jemand gerade eine Fahrt einträgt, wäre die
// schlechtere Sorte Hilfsbereitschaft.
autoUpdate: false,
// Kommt eine frisch signierte App über Xcode aufs Gerät, gilt wieder
// deren eingebautes Bündel — sonst liefe ein älteres OTA-Bündel weiter
// und überdeckte genau die native Änderung, für die Xcode nötig war.
resetWhenUpdate: true,
// Meldet sich die App nach einem Wechsel nicht binnen dieser Zeit als
// startklar (notifyAppReady in src/main.tsx), kehrt das Plugin von
// selbst zum vorherigen Bündel zurück. Das ist die Rückfallebene: ein
// kaputtes Bündel kann das Gerät nicht dauerhaft lahmlegen. Bewusst
// großzügiger als die 10 Sekunden Vorgabe — die Erstladung holt Profil,
// Fahrten und Tankvorgänge, und ein langsames Netz darf nicht wie ein
// defektes Bündel aussehen.
appReadyTimeout: 20000,
// Ein Bündel, das beim Start durchgefallen ist, wird nicht aufbewahrt.
autoDeleteFailed: true,
},
},
}
+2
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit",
"smoke": "node --experimental-strip-types scripts/smoke.ts",
"smoke:auth": "node --experimental-strip-types scripts/smoke-auth.ts",
"ota": "powershell -ExecutionPolicy Bypass -File scripts/ota-paket.ps1 -Bauen",
"test": "vitest run"
},
"dependencies": {
@@ -19,6 +20,7 @@
"@capacitor/android": "^8.5.0",
"@capacitor/core": "^8.5.0",
"@capacitor/ios": "^8.5.0",
"@capgo/capacitor-updater": "^8.51.14",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
+153
View File
@@ -0,0 +1,153 @@
<#
.SYNOPSIS
Packt die gebaute Companion-App als OTA-Bündel in die Integration.
.BESCHREIBUNG
Erzeugt aus companion-app/dist zwei Dateien:
custom_components/audi_dashboard/frontend/app/bundle.zip
custom_components/audi_dashboard/frontend/app/bundle.json
Die iOS-App lädt die Zip zur Laufzeit nach und tauscht ihre Oberfläche aus,
ohne dass Xcode gebraucht wird (siehe companion-app/src/daten/ota.ts).
WARUM DAS BÜNDEL IN DER INTEGRATION LIEGT
-----------------------------------------
Weil es dann von selbst dorthin kommt, wo die App es sucht. install.ps1
(der einzige Installationsweg - HACS scheidet aus, siehe VERSIONIERUNG.md)
kopiert den Ordner custom_components/audi_dashboard/ vollständig - das
Bündel reist bei jedem Lauf mit, ohne zweiten Auslieferungsweg. Ein
Ablageort unter /config/www/ hätte einen eigenen Weg dorthin gebraucht, den
man beim Ausliefern hätte vergessen können.
Der Nebeneffekt ist der eigentliche Gewinn: Bündel und Integration können
gar nicht auseinanderlaufen, weil sie dieselbe Datei sind.
DREI DINGE, DIE HIER BEWUSST SO SIND
------------------------------------
1. **Sourcemaps fliegen raus.** Sie machen rund zwei Drittel von dist/ aus
(1,4 von 2,0 MB) und werden auf dem Gerät nie gebraucht. Sie mitzuladen
hieße, bei jedem Update das Dreifache über die Leitung zu schicken.
2. **Die Einträge werden von Hand geschrieben, nicht von einer Bequem-API.**
Die Zip-Spezifikation verlangt "/" als Trennzeichen. Windows PowerShell
5.1 läuft auf dem .NET Framework, und dort schreiben SOWOHL
Compress-Archive ALS AUCH [IO.Compression.ZipFile]::CreateFromDirectory
Backslashes in die Einträge von Unterordnern (in .NET Core behoben, hier
aber nicht verfügbar). Nachgemessen: die erste Fassung dieses Skripts
erzeugte "assets\index-*.js" statt "assets/index-*.js" - iOS hätte daraus
Dateien mit Backslash im Namen gemacht statt einen Ordner, und die App
wäre mit fehlenden Assets gestartet.
Deshalb ZipFile::Open plus CreateEntryFromFile mit selbst gebautem
Eintragsnamen: dann steht genau das im Archiv, was hier steht. Der Inhalt
liegt ohne umschließenden Ordner in der Wurzel, index.html also direkt
dort - genau das erwartet das Plugin; fehlt es, wirft set() "no index.html
file inside the bundle folder".
3. **SHA-256 über die Zip, klein geschrieben.** Das Plugin rechnet auf dem
Gerät denselben Hash über die heruntergeladene Datei (calcChecksum in
CapgoUpdater.swift) und verwirft das Bündel bei Abweichung. Ein
abgeschnittener Download oder eine halb gespiegelte Datei fällt damit
auf, bevor die App sie startet.
.PARAMETER Bauen
Vorher "npm run build" ausführen. Ohne das wird der Inhalt von dist/
genommen, wie er gerade daliegt.
.BEISPIEL
.\ota-paket.ps1 -Bauen
npm run ota
#>
param(
[switch]$Bauen
)
$ErrorActionPreference = "Stop"
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$hier = $PSScriptRoot
$app = Split-Path $hier -Parent
$projekt = Split-Path $app -Parent
$dist = Join-Path $app "dist"
$manifest = Join-Path $projekt "custom_components\audi_dashboard\manifest.json"
$ziel = Join-Path $projekt "custom_components\audi_dashboard\frontend\app"
function Gut($t) { Write-Host " [ok] $t" -ForegroundColor Green }
function Info($t) { Write-Host " [info] $t" -ForegroundColor DarkGray }
Write-Host ""
Write-Host " OTA-Bündel bauen" -ForegroundColor White
Write-Host " ================" -ForegroundColor White
if ($Bauen) {
Info "npm run build ..."
Push-Location $app
try { npm run build; if ($LASTEXITCODE -ne 0) { throw "npm run build fehlgeschlagen" } }
finally { Pop-Location }
}
if (-not (Test-Path (Join-Path $dist "index.html"))) {
throw "$dist\index.html fehlt - erst 'npm run build' laufen lassen (oder -Bauen benutzen)."
}
$version = (Get-Content $manifest -Raw | ConvertFrom-Json).version
if (-not $version) { throw "Kein 'version'-Feld in $manifest" }
Gut "Version: $version"
# In einen Zwischenordner spiegeln, damit die Sourcemaps nicht mitwandern -
# dist/ selbst bleibt unangetastet (die Maps sind beim Entwickeln nützlich).
$tmp = Join-Path ([IO.Path]::GetTempPath()) ("dm360-ota-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
try {
Copy-Item "$dist\*" $tmp -Recurse -Force
$maps = Get-ChildItem $tmp -Recurse -File -Filter "*.map"
$maps | ForEach-Object { Remove-Item $_.FullName -Force }
Info "$($maps.Count) Sourcemap(s) ausgelassen"
if (-not (Test-Path (Join-Path $tmp "index.html"))) {
throw "index.html liegt nicht in der Wurzel des Bündels - das Plugin würde es ablehnen."
}
New-Item -ItemType Directory -Force -Path $ziel | Out-Null
$zip = Join-Path $ziel "bundle.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
# Eintragsnamen selbst bauen (siehe Punkt 2 im Kopfkommentar): relativer
# Pfad ab dem Zwischenordner, Backslashes durch "/" ersetzt.
$archiv = [IO.Compression.ZipFile]::Open($zip, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($datei in Get-ChildItem $tmp -Recurse -File | Sort-Object FullName) {
$name = $datei.FullName.Substring($tmp.Length + 1).Replace("\", "/")
[void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
$archiv, $datei.FullName, $name, [IO.Compression.CompressionLevel]::Optimal)
}
}
finally { $archiv.Dispose() }
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
$groesse = (Get-Item $zip).Length
Gut ("bundle.zip {0:N0} Bytes" -f $groesse)
Gut "sha256 $hash"
# Kleingeschriebener Hex-Hash, weil das Plugin auf dem Geraet ebenfalls
# kleingeschrieben vergleicht.
$daten = [ordered]@{
version = $version
sha256 = $hash
bytes = $groesse
gebaut = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
}
$json = ($daten | ConvertTo-Json -Compress)
[IO.File]::WriteAllText((Join-Path $ziel "bundle.json"), $json, (New-Object Text.UTF8Encoding($false)))
Gut "bundle.json geschrieben"
}
finally {
Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
Write-Host ""
Write-Host " Fertig. Das Bündel wird mit der Integration ausgeliefert -" -ForegroundColor Green
Write-Host " homeassistant\installationspaket\Installieren.cmd nimmt es automatisch mit."
Write-Host ""
+10
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { DataMetricApi, zugangLesen } from "./api"
import { DatenAnbieter, useDaten } from "./daten/DatenKontext"
import { startklarMelden } from "./daten/ota"
import { Shell } from "./Shell"
import type { Route, SeitenName } from "./navigation"
import { useTabBeschriftung, useTheme } from "./theme"
@@ -65,6 +66,15 @@ function AngemeldeteApp({ beiAbmeldung }: { beiAbmeldung: () => void }) {
if (daten.verbindung === "nicht_angemeldet") beiAbmeldung()
}, [daten.verbindung, beiAbmeldung])
// Meldet dem OTA-Plugin, dass diese Fassung hochgekommen ist (siehe
// daten/ota.ts). Bewusst hier und nicht in main.tsx: erst wenn diese
// Komponente rendert, hat React tatsächlich etwas auf den Schirm gebracht.
// Ein Absturz vor diesem Punkt bleibt unbestätigt, und das Plugin rollt
// nach appReadyTimeout von selbst auf das vorherige Bündel zurück.
useEffect(() => {
void startklarMelden()
}, [])
return (
<Shell
route={route}
+17 -7
View File
@@ -14,7 +14,14 @@ export * from "./warteschlange.ts";
export * from "./ablageNativ.ts";
import { DIENST_DOMAIN, ENTITAETEN } from "./types.ts";
import type { Fahrt, Fahrzeugstatus, ImportErgebnis, Profil, Tankvorgang } from "./types.ts";
import type {
AppVersionAngabe,
Fahrt,
Fahrzeugstatus,
ImportErgebnis,
Profil,
Tankvorgang,
} from "./types.ts";
import { HassRest } from "./rest.ts";
import { HassLive } from "./live.ts";
import { Warteschlange } from "./warteschlange.ts";
@@ -172,16 +179,19 @@ export class DataMetricApi {
return this.rest.dienstAufrufen(DIENST_DOMAIN, "jetzt_aktualisieren");
}
/** Welchen Oberflächen-Stand das Backend ausliefert (Version der Integration, siehe
VERSIONIERUNG.md). `null`, wenn die Entität fehlt — etwa weil das Backend
älter ist als diese Funktion; dann wird nichts verglichen und nichts
/** Nutzlast von sensor.audi_dashboard_app_version: welchen Stand das
Backend ausliefert (`app`, siehe VERSIONIERUNG.md) und ob ein
OTA-Bündel bereitliegt (`buendel`, siehe daten/ota.ts). Beides kommt
aus derselben Entität und wird deshalb in einem Aufruf gelesen statt in
zweien. `null` bei jedem Fehler — fehlende Entität (älteres Backend),
Netzproblem, was auch immer; dann wird nichts verglichen und nichts
gemeldet, statt einen Fehlalarm auszulösen. */
async appVersionLesen(): Promise<string | null> {
async appVersionAngabeLesen(): Promise<AppVersionAngabe | null> {
try {
const zustand = await this.rest.zustandLesen<{ daten?: { app?: string | null } }>(
const zustand = await this.rest.zustandLesen<{ daten?: AppVersionAngabe }>(
ENTITAETEN.appVersion,
);
return zustand.attributes?.daten?.app ?? null;
return zustand.attributes?.daten ?? null;
} catch {
return null;
}
+22
View File
@@ -156,6 +156,28 @@ export interface ImportErgebnis {
meldung?: string;
}
/** Das OTA-Bündel, das die Integration mit ausliefert (siehe
custom_components/audi_dashboard/koordinator.py `_buendel_lesen()` und
companion-app/scripts/ota-paket.ps1). `null`, solange keine
bundle.json neben der Integration liegt — ein völlig normaler Zustand,
kein Fehler: wer nur das Panel nutzt, braucht kein App-Bündel. */
export interface Buendelangabe {
version: string;
sha256: string;
bytes: number;
gebaut: string;
/** Pfad relativ zur Home-Assistant-Basis-URL, z. B.
"/audi_dashboard_static/app/bundle.zip". Von der Integration
angehängt, steht nicht in bundle.json selbst. */
url: string;
}
/** Nutzlast von sensor.audi_dashboard_app_version. */
export interface AppVersionAngabe {
app: string | null;
buendel: Buendelangabe | null;
}
/* -------------------------------------------------- Entitäts-Verzeichnis */
/** Alle vom Backend veröffentlichten Entitäten an einer Stelle.
+13 -4
View File
@@ -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,
+43
View File
@@ -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)
})
})
+131
View File
@@ -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.")
}
+62 -1
View File
@@ -8,8 +8,10 @@ import { useRef, useState } from "react"
import { ActionButton, Feld, Seg, Switch, Tile } from "@audi-dash/ui"
import { DIENST_DOMAIN, zugangVerwerfen } from "../api"
import { DIENST_DOMAIN, zugangLesen, zugangVerwerfen } from "../api"
import { eigeneVersion } from "../daten/appVersion"
import { useDaten } from "../daten/DatenKontext"
import { OtaFehler, buendelAnwenden, buendelPasst, otaMoeglich } from "../daten/ota"
import type { Einstellungen as EinstellungenWerte } from "../daten/profilAdapter"
import { datumZeit, de, isoTag } from "../format"
import { useTheme } from "../theme"
@@ -35,6 +37,7 @@ export function Einstellungen({
fahrten,
tankvorgaenge,
rohprofil,
otaBuendel,
api,
profilSpeichern,
jetztAktualisieren,
@@ -44,8 +47,31 @@ export function Einstellungen({
const [laeuft, setzeLaeuft] = useState(false)
const [gespeichert, setzeGespeichert] = useState(false)
const [importOffen, setzeImportOffen] = useState(false)
const [otaLaeuft, setzeOtaLaeuft] = useState(false)
const [otaFehler, setzeOtaFehler] = useState<string | null>(null)
const dateiwahl = useRef<HTMLInputElement | null>(null)
const otaUpdateVerfuegbar = otaMoeglich() && buendelPasst(otaBuendel, eigeneVersion())
const otaAusloesen = async () => {
if (!otaBuendel) return
setzeOtaLaeuft(true)
setzeOtaFehler(null)
try {
const zugang = await zugangLesen()
if (!zugang) throw new OtaFehler("Kein Zugang eingerichtet.")
// Kehrt im Erfolgsfall nicht zurück - set() lädt die App neu, siehe
// daten/ota.ts. Der catch-Block unten fängt sowohl echte Fehlschläge
// als auch den defensiven letzten throw in buendelAnwenden() ab.
await buendelAnwenden(otaBuendel, zugang.basisUrl)
} catch (fehler) {
setzeOtaFehler(
fehler instanceof Error ? fehler.message : "Das Update konnte nicht installiert werden.",
)
setzeOtaLaeuft(false)
}
}
if (!einstellungen || !fahrzeug) return null
const werte = entwurf ?? einstellungen
@@ -349,6 +375,41 @@ export function Einstellungen({
</div>
</Tile>
{otaMoeglich() && (
<Tile>
<span className="ads-eyebrow">App-Update</span>
{otaUpdateVerfuegbar && otaBuendel ? (
<>
<Werteliste
kinder={
<Wertzeile
label="Verfügbare Version"
wert={otaBuendel.version}
zusatz={`${Math.round(otaBuendel.bytes / 1024)} KB`}
/>
}
/>
<p className="dm-fussnote">
Lädt die Oberfläche neu und startet die App neu. Fahrzeugprofil, Fahrten und
Tankvorgänge sind davon nicht betroffen die liegen in Home Assistant.
</p>
<div className="dm-knopfreihe">
<ActionButton onClick={() => void otaAusloesen()} disabled={otaLaeuft}>
{otaLaeuft ? "Lade Update …" : "Update installieren"}
</ActionButton>
</div>
{otaFehler && <p className="dm-fehler">{otaFehler}</p>}
</>
) : (
<p className="dm-fussnote">
{otaBuendel
? `Diese App ist aktuell (${eigeneVersion() ?? "unbekannte Version"}).`
: "Der Server hat gerade kein Update-Bündel hinterlegt."}
</p>
)}
</Tile>
)}
<Tile>
<span className="ads-eyebrow">Zugang</span>
<Werteliste