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:
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user