4 Commits

Author SHA1 Message Date
schalli 7479cb485f docs: Desktop-App — Update in der App (Handbücher, CI-Secrets, CHANGELOG)
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m8s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 7m56s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
- Anwenderhandbuch: Menüeintrag "Update installieren", Ablauf per Klick
  unter Windows/Linux, Fehlerfall, https-Bedingung, einmaliger Wechsel für
  Clients bis 1.2.0
- Betriebshandbuch Kap. 10: Signierschlüssel (Secrets, Ablage, Sicherung,
  Verlust), Kontrollzeile /desktop/update, Manifest-Felder, zwei Fehlerbilder
- Entwicklungshandbuch: lokal `tauri build --no-sign`, Hinweise zu tauri dev
- ci-cd-setup.md: die zwei neuen Secrets, Bau-Schritte signieren,
  Fehlerbild "no private key"
- CHANGELOG: eine Zeile unter Unveröffentlicht → Neu

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:36:58 +02:00
schalli 7004b5b020 ci: Desktop-Pakete mit dem Updater-Schluessel signieren, Signatur und updateVersion ins Manifest
- ci.yml: Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD als env nur an den
  beiden tauri-build-Schritten des Jobs desktop (kein Job-env, kein
  --no-sign); alles andere strukturgleich
- desktop-collect.sh: liest <bundle>.sig (genau eine Base64-Zeile) als
  files.<p>.signature ins Manifest, schreibt updateVersion (X.Y.Z bzw.
  X.Y.Z-beta.g<sha7>); fehlende .sig bricht auf main/Tag oder bei
  gesetztem Schluessel ab, warnt sonst (dev, lokal --no-sign)
- desktop-stamp.sh check: kein Cache-Stand ohne updateVersion und
  Signaturen beider Plattformen
- .gitignore: *.key

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:34:45 +02:00
schalli de81c74e53 feat(api): GET /desktop/update — signierte Desktop-Pakete im Format des Tauri-Updaters ausliefern
- Neuer oeffentlicher Endpunkt (vor download/:platform): base-Origin wird
  per safeOrigin validiert (nur http/https, kein Pfad/Query/Fragment/
  Userinfo, sonst 400) und nur zum Bau der absoluten Download-URL genutzt,
  nie serverseitig abgerufen
- 204 ohne Body bei fremder Plattform/Architektur, fehlendem Manifest,
  fehlender Signatur oder fehlendem/ungueltigem updateVersion; sonst
  { version, pub_date (nur RFC 3339), url, signature, notes }
- Manifest-Felder signature (je Plattform) und updateVersion optional in
  @tessera/shared, Eintragspruefung akzeptiert nur String-Signaturen
- 10 neue Spec-Tests, Test 11 erweitert (23 gesamt); latest/download
  unveraendert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:32:52 +02:00
schalli 678ba51725 feat(desktop): Update in der App — Herunterladen, Installieren und Neustart per Klick im Infobereich (tauri-plugin-updater)
- tauri-plugin-updater 2.11 + semver eingebunden; pubkey, installMode passive
  und createUpdaterArtifacts in tauri.conf.json (kein endpoints, keine
  dangerous*-Schalter)
- Versionspruefung laeuft ueber updater_builder().check() gegen
  /api-proxy/desktop/update mit eigenem version_comparator (is_update_newer:
  hoehere Basis oder anderer Beta-Stempel beta.g<sha7>; gleiche Basis Live
  → kein Update); 15 s Pruefung, 600 s Download-Timeout
- Tray-Eintrag "Update installieren" laedt mit Fortschritt im Menuetext,
  prueft die minisign-Signatur, installiert (Windows NSIS passiv, Linux
  AppImage an Ort und Stelle) und startet neu; Fehler → Benachrichtigung
  und Rueckfall auf die Einstellungsseite im Browser
- http-Server im Release: Menuetext "Update nur über https möglich", gesperrt
- 15 neue Tests, 2 umgestellt (33 gesamt); gen/schemas vom Bau nachgezogen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:30:28 +02:00
20 changed files with 1379 additions and 96 deletions
+69 -5
View File
@@ -20,6 +20,21 @@
# Die Version kommt aus tauri.conf.json (von desktop-version.sh geschrieben
# oder als eingecheckte Basislinie vorhanden) -- dieses Skript liest sie nur,
# es schreibt sie nicht. Dieses Skript kennt kein Secret.
#
# Signatur (quick-260917-kgc): Die Tauri-CLI legt beim Bau mit
# `createUpdaterArtifacts` neben jedem Bundle eine `<bundle>.sig` ab
# (minisign, eine Base64-Zeile). Dieses Skript traegt deren INHALT als
# `files.<plattform>.signature` ins Manifest -- die `.sig`-Datei selbst wird
# nicht kopiert (die API liefert JSON). Pflichtregel: fehlt die `.sig`,
# bricht das Skript ab, wenn TAURI_SIGNING_PRIVATE_KEY gesetzt ist ODER der
# Kanal nicht `dev` ist (main/Tag -- dort sind Signaturen Pflicht,
# `--no-sign` gibt es nur lokal); sonst Warnung und das Feld entfaellt
# (lokaler Bau mit `tauri build --no-sign` bleibt moeglich, die API antwortet
# auf /desktop/update dann 204). Neues Feld `updateVersion` = die SemVer-Form
# fuer den Updater: X.Y.Z bei live/dev, X.Y.Z-beta.g<sha7> bei beta (das
# `g` ist Pflicht, ein rein numerischer SHA mit fuehrender Null waere kein
# gueltiger SemVer-Identifier). Der Schluessel wird nur auf Gesetztsein
# geprueft, nie gelesen oder ausgegeben.
set -eu
REQUIRE=""
@@ -79,6 +94,43 @@ esac
COMMIT="$(git rev-parse --short=7 HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
# SemVer-Form fuer den Updater (siehe Kopfkommentar): erst NACH der
# Versionspruefung bilden, SUFFIX/SHA_SHORT sind aus der Kanalentscheidung
# bekannt.
UPDATE_VERSION="$VERSION"
if [ "$CHANNEL" = beta ]; then
UPDATE_VERSION="$VERSION-beta.g$SHA_SHORT"
fi
SIGN_REQUIRED=0
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ "$CHANNEL" != dev ]; then
SIGN_REQUIRED=1
fi
# read_signature <bundle-pfad>: schreibt den Inhalt von <bundle-pfad>.sig
# nach stdout (genau eine Base64-Zeile). Fehlt die Datei: bei
# SIGN_REQUIRED=1 Fehler und exit 1, sonst Warnung und leere Ausgabe.
read_signature() {
SIG_FILE="$1.sig"
if [ -f "$SIG_FILE" ]; then
SIG_CONTENT="$(cat "$SIG_FILE")"
# grep prueft zeilenweise -- die Zeilenzahl deshalb getrennt erzwingen.
SIG_LINES="$(printf '%s\n' "$SIG_CONTENT" | wc -l | tr -d ' ')"
if [ "$SIG_LINES" -ne 1 ] || ! printf '%s' "$SIG_CONTENT" | grep -qE '^[A-Za-z0-9+/=]+$'; then
echo "Signaturdatei $SIG_FILE hat nicht die erwartete Form (genau eine Base64-Zeile)." >&2
exit 1
fi
printf '%s' "$SIG_CONTENT"
return 0
fi
if [ "$SIGN_REQUIRED" = 1 ]; then
echo "Signaturdatei $SIG_FILE fehlt. Im CI muessen TAURI_SIGNING_PRIVATE_KEY und TAURI_SIGNING_PRIVATE_KEY_PASSWORD an den tauri-build-Schritten gesetzt sein; --no-sign ist nur lokal erlaubt." >&2
exit 1
fi
echo "Warnung: Signaturdatei $SIG_FILE fehlt (Bau ohne Schluessel, Kanal dev) -- Feld signature entfaellt, kein Update in der App." >&2
return 0
}
mkdir -p "$DESKTOP_DIST"
# Alte Pakete/Manifest entfernen, Platzhalter (.gitkeep) bleibt erhalten.
rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$DESKTOP_DIST/manifest.json"
@@ -90,9 +142,11 @@ trap 'rm -f "$TMP_MANIFEST"' EXIT INT TERM
LINUX_NAME=""
LINUX_SIZE=""
LINUX_SHA=""
LINUX_SIG=""
WINDOWS_NAME=""
WINDOWS_SIZE=""
WINDOWS_SHA=""
WINDOWS_SIG=""
case ",$REQUIRE," in
*,linux,*)
@@ -107,7 +161,10 @@ case ",$REQUIRE," in
cp "$APPIMAGE_SRC" "$DESKTOP_DIST/$LINUX_NAME"
LINUX_SIZE="$(stat -c %s "$DESKTOP_DIST/$LINUX_NAME")"
LINUX_SHA="$(sha256sum "$DESKTOP_DIST/$LINUX_NAME" | cut -d' ' -f1)"
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA)"
LINUX_SIG="$(read_signature "$APPIMAGE_SRC")"
[ -n "$LINUX_SIG" ] || [ "$SIGN_REQUIRED" = 0 ] || exit 1
if [ -n "$LINUX_SIG" ]; then LINUX_SIGNED="signiert"; else LINUX_SIGNED="ohne Signatur"; fi
echo "linux: $LINUX_NAME (${LINUX_SIZE} Bytes, sha256 $LINUX_SHA, $LINUX_SIGNED)"
;;
esac
@@ -124,7 +181,10 @@ case ",$REQUIRE," in
cp "$NSIS_SRC" "$DESKTOP_DIST/$WINDOWS_NAME"
WINDOWS_SIZE="$(stat -c %s "$DESKTOP_DIST/$WINDOWS_NAME")"
WINDOWS_SHA="$(sha256sum "$DESKTOP_DIST/$WINDOWS_NAME" | cut -d' ' -f1)"
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA)"
WINDOWS_SIG="$(read_signature "$NSIS_SRC")"
[ -n "$WINDOWS_SIG" ] || [ "$SIGN_REQUIRED" = 0 ] || exit 1
if [ -n "$WINDOWS_SIG" ]; then WINDOWS_SIGNED="signiert"; else WINDOWS_SIGNED="ohne Signatur"; fi
echo "windows: $WINDOWS_NAME (${WINDOWS_SIZE} Bytes, sha256 $WINDOWS_SHA, $WINDOWS_SIGNED)"
;;
esac
@@ -132,25 +192,29 @@ esac
# manuelles String-Zusammenbauen von JSON).
jq -n \
--arg version "$VERSION" \
--arg updateVersion "$UPDATE_VERSION" \
--arg channel "$CHANNEL" \
--arg commit "$COMMIT" \
--arg buildTime "$BUILD_TIME" \
--arg linuxName "$LINUX_NAME" \
--argjson linuxSize "${LINUX_SIZE:-null}" \
--arg linuxSha "$LINUX_SHA" \
--arg linuxSig "$LINUX_SIG" \
--arg windowsName "$WINDOWS_NAME" \
--argjson windowsSize "${WINDOWS_SIZE:-null}" \
--arg windowsSha "$WINDOWS_SHA" \
--arg windowsSig "$WINDOWS_SIG" \
'{
version: $version,
updateVersion: $updateVersion,
channel: $channel,
commit: $commit,
buildTime: $buildTime,
files: (
{}
+ (if $linuxName != "" then { linux: { name: $linuxName, size: $linuxSize, sha256: $linuxSha } } else {} end)
+ (if $windowsName != "" then { windows: { name: $windowsName, size: $windowsSize, sha256: $windowsSha } } else {} end)
+ (if $linuxName != "" then { linux: ({ name: $linuxName, size: $linuxSize, sha256: $linuxSha } + (if $linuxSig != "" then { signature: $linuxSig } else {} end)) } else {} end)
+ (if $windowsName != "" then { windows: ({ name: $windowsName, size: $windowsSize, sha256: $windowsSha } + (if $windowsSig != "" then { signature: $windowsSig } else {} end)) } else {} end)
)
}' > "$DESKTOP_DIST/manifest.json"
echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, Kanal $CHANNEL)"
echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, updateVersion $UPDATE_VERSION, Kanal $CHANNEL)"
+16
View File
@@ -43,6 +43,13 @@
# liest ausserhalb von apps/desktop keine Werkstatt-Datei -- frontendDist ist
# ../src innerhalb von apps/desktop, keine Abhaengigkeit auf packages/*.
#
# quick-260917-kgc: `check` verlangt zusaetzlich `updateVersion` und je
# Plattform `files.<p>.signature` im gecachten Manifest -- ein Cache-Stand
# aus der Zeit vor der Update-Funktion (oder aus einem Bau mit --no-sign)
# wird nie uebernommen, sonst lieferte der Update-Endpunkt dauerhaft 204.
# Ein Schluesselwechsel (`pubkey` in tauri.conf.json unter apps/desktop)
# aendert den Stempel automatisch, weil apps/desktop in DESKTOP_PATHS liegt.
#
# Dieses Skript kennt kein Secret.
set -eu
@@ -112,6 +119,10 @@ cmd_check() {
if [ "$SHA" != "$EXP_SHA" ]; then
no_reuse "Pruefsumme von $2 weicht ab"
fi
SIG="$(jq -r ".files.$1.signature // empty" "$MANIFEST")"
if [ -z "$SIG" ]; then
no_reuse "Signatur fuer $2 fehlt im Manifest"
fi
}
if [ "$CACHE_HIT" != "true" ]; then
@@ -132,6 +143,11 @@ cmd_check() {
no_reuse "Version $MVERSION statt $STAMP_VERSION"
fi
UPDATE_VERSION="$(jq -r '.updateVersion // empty' "$MANIFEST")"
if [ -z "$UPDATE_VERSION" ]; then
no_reuse "updateVersion fehlt im Manifest (Stand vor der Update-Funktion)"
fi
LINUX_NAME="$(jq -r '.files.linux.name // empty' "$MANIFEST")"
WINDOWS_NAME="$(jq -r '.files.windows.name // empty' "$MANIFEST")"
if [ -z "$LINUX_NAME" ] || [ -z "$WINDOWS_NAME" ]; then
+9
View File
@@ -14,6 +14,9 @@
# Desktop-Pfaden, siehe .gitea/scripts/desktop-stamp.sh) bereits fertige
# Pakete im Zwischenspeicher des Runners liegen; Tags v* bauen immer neu;
# `publish` bleibt unveraendert.
# quick-260917-kgc: Die beiden tauri-build-Schritte signieren die Pakete mit dem
# Updater-Schluessel (Secrets TAURI_SIGNING_PRIVATE_KEY/_PASSWORD, nur an diesen
# zwei Schritten); desktop-collect.sh traegt die .sig-Inhalte ins Manifest.
name: Tessera CI/CD
on:
@@ -178,10 +181,16 @@ jobs:
- name: Linux-AppImage bauen
if: steps.reuse.outputs.reuse != 'true'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @tessera/desktop exec tauri build --bundles appimage
- name: Windows-Installer bauen (Cross-Bau)
if: steps.reuse.outputs.reuse != 'true'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @tessera/desktop exec tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
- name: Pakete einsammeln
+3
View File
@@ -43,3 +43,6 @@ user-files/
# Desktop-Pakete aus dem Bau (Phase 18)
desktop-dist/*
!desktop-dist/.gitkeep
# Privater Updater-Signierschluessel liegt ausserhalb des Repos (~/.tessera/desktop-updater/) -- nie einchecken
*.key
+1
View File
@@ -9,6 +9,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen
- Desktop-App: das Symbol im Infobereich zeigt den verbundenen Tessera-Server – im Hinweistext und als erste Zeile des Menüs; in der App auch unter Einstellungen → Desktop-App als „Verbunden mit: …“
- Desktop-App: Server-Adresse nachträglich änderbar über „Server-Adresse ändern…“ im Menü des Infobereich-Symbols – ohne Neustart
- Desktop-App: Update mit einem Klick – „Auf Version X.Y.Z aktualisieren“ im Menü des Infobereich-Symbols lädt das signierte Paket, installiert es und startet die App neu (Windows und Linux); Voraussetzung ist eine https-Adresse, bereits installierte Versionen bis 1.2.0 wechseln einmal noch über den Browser
### Geändert
+43 -3
View File
@@ -1,7 +1,17 @@
import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common';
import type { DesktopLatestResponse } from '@tessera/shared';
import {
BadRequestException,
Controller,
Get,
Inject,
Param,
Query,
Res,
StreamableFile,
} from '@nestjs/common';
import type { DesktopLatestResponse, DesktopUpdateResponse } from '@tessera/shared';
import type { Response } from 'express';
import { Public } from '../auth/decorators/public.decorator';
import { DesktopService } from './desktop.service';
import { DesktopService, safeOrigin } from './desktop.service';
@Controller('desktop')
export class DesktopController {
@@ -22,6 +32,36 @@ export class DesktopController {
return this.desktopService.getLatest();
}
// Bewusst oeffentlich (D-10, wie latest/download): der Desktop-Client prueft
// beim Start vor jeder Anmeldung. Statische Route VOR `download/:platform`
// (Konvention Route-Order, auch wenn sich die beiden hier nicht
// ueberschatten). 204 ist der vom Updater-Plugin definierte Status fuer
// "kein Update"; `passthrough` + `res.status(204)` funktioniert, weil Nest
// den Standardstatus VOR dem Handler setzt und die Antwort danach ohne
// eigenen Statuscode schreibt -- der Handler-Status gewinnt. `current`
// wird nicht deklariert, weil der Service es nicht auswertet.
@Public()
@Get('update')
update(
@Query('target') target: unknown,
@Query('arch') arch: unknown,
@Query('base') base: unknown,
@Res({ passthrough: true }) res: Response,
): DesktopUpdateResponse | undefined {
const origin = safeOrigin(base);
if (!origin) {
throw new BadRequestException(
'base must be an http(s) origin without path, query or credentials',
);
}
const result = this.desktopService.getUpdate({ target, arch, origin });
if (!result) {
res.status(204);
return undefined;
}
return result;
}
// Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung,
// gleicher Grund wie getLatest oben.
@Public()
+168 -3
View File
@@ -9,7 +9,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { IS_PUBLIC_KEY } from '../auth/decorators/public.decorator';
import { DesktopController } from './desktop.controller';
import { DesktopModule } from './desktop.module';
import { DesktopService } from './desktop.service';
import { DesktopService, safeOrigin } from './desktop.service';
/**
* DesktopService/DesktopController.spec — HTTP-Durchstich ueber
@@ -34,7 +34,23 @@ const PACKAGE_NAME = 'test-package.bin';
let packageSize: number;
let packageSha256: string;
function writeManifest(files: Record<string, { name: string; size: number; sha256: string }>) {
/** Origin des "eigenen" Servers, wie ihn der Desktop-Client als `base` mitschickt. */
const ORIGIN = 'https://tessera.example.com';
/** Beliebige Base64-Zeile -- die API reicht die Signatur nur durch, prueft sie nicht. */
const SIG = 'dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZQo=';
type ManifestHead = {
version?: string;
channel?: string;
commit?: string;
buildTime?: string;
updateVersion?: string;
};
function writeManifest(
files: Record<string, { name: string; size: number; sha256: string; signature?: string }>,
head: ManifestHead = {},
) {
fs.writeFileSync(
path.join(tempDir, 'manifest.json'),
JSON.stringify({
@@ -42,11 +58,29 @@ function writeManifest(files: Record<string, { name: string; size: number; sha25
channel: 'dev',
commit: 'abc1234',
buildTime: '2026-09-16T00:00:00Z',
...head,
files,
}),
);
}
/** Standard-Eintrag fuer linux, optional signiert. */
function linuxEntry(signature?: string) {
return {
linux: {
name: PACKAGE_NAME,
size: packageSize,
sha256: packageSha256,
...(signature === undefined ? {} : { signature }),
},
};
}
function updateUrl(query: Record<string, string>) {
const params = new URLSearchParams(query);
return `${baseUrl}/desktop/update?${params.toString()}`;
}
beforeAll(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-'));
const packageBytes = crypto.randomBytes(64 * 1024);
@@ -211,8 +245,139 @@ describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () =
expect(res.status).toBe(404);
});
it('Test 11 (bewusst oeffentlich): getLatest und download tragen @Public()', () => {
it('Test 11 (bewusst oeffentlich): getLatest, download und update tragen @Public()', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true);
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.update)).toBe(true);
});
it('Test 12 (update, beta, signiert): 200 im dynamischen Updater-Format mit absoluter URL aus base', async () => {
writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.gabc1234' });
const res = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.1.0', base: ORIGIN }),
);
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('application/json');
expect(await res.json()).toEqual({
version: '1.1.0-beta.gabc1234',
pub_date: '2026-09-16T00:00:00Z',
url: `${ORIGIN}/api-proxy/desktop/download/linux`,
signature: SIG,
notes: 'Tessera 1.1.0-beta.gabc1234',
});
});
it('Test 13 (update, live): version und notes tragen die reine X.Y.Z', async () => {
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
const res = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.version).toBe('1.1.0');
expect(body.notes).toBe('Tessera 1.1.0');
});
it('Test 14 (base mit Schlussstrich): url wird aus dem Origin ohne Schlussstrich gebildet', async () => {
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
const res = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: `${ORIGIN}/` }),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.url).toBe(`${ORIGIN}/api-proxy/desktop/download/linux`);
});
it('Test 15 (ohne Signatur): Standard-Manifest -> 204 ohne Body', async () => {
const res = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
);
expect(res.status).toBe(204);
expect(await res.text()).toBe('');
});
it('Test 16 (updateVersion fehlt oder ungueltig trotz Signatur): 204', async () => {
writeManifest(linuxEntry(SIG));
const resMissing = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
);
expect(resMissing.status).toBe(204);
writeManifest(linuxEntry(SIG), { channel: 'beta', updateVersion: '1.1.0-beta.abc1234' });
const resInvalid = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
);
expect(resInvalid.status).toBe(204);
});
it('Test 17 (Plattform/Architektur): darwin, windows ohne Eintrag, aarch64 und fehlendes target -> 204', async () => {
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
const cases: Record<string, string>[] = [
{ target: 'darwin', arch: 'x86_64', current: '1.0.0', base: ORIGIN },
{ target: 'windows', arch: 'x86_64', current: '1.0.0', base: ORIGIN },
{ target: 'linux', arch: 'aarch64', current: '1.0.0', base: ORIGIN },
{ arch: 'x86_64', current: '1.0.0', base: ORIGIN },
];
for (const query of cases) {
const res = await fetch(updateUrl(query));
expect(res.status, JSON.stringify(query)).toBe(204);
}
});
it('Test 18 (base-Validierung, HTTP): fehlendes, fremdes oder unreines base -> 400', async () => {
writeManifest(linuxEntry(SIG), { channel: 'live', updateVersion: '1.1.0' });
const missing = await fetch(updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0' }));
expect(missing.status).toBe(400);
const bad = [
'ftp://host',
'https://user:pw@host',
'https://host/pfad',
'https://host/?x=1',
'https://host/#f',
'kein url',
];
for (const base of bad) {
const res = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base }),
);
expect(res.status, base).toBe(400);
}
});
it('Test 19 (safeOrigin direkt): nur reine http(s)-Origins, kleingeschrieben, ohne Schlussstrich', () => {
expect(safeOrigin('https://tessera.example.com')).toBe('https://tessera.example.com');
expect(safeOrigin('http://localhost:3000/')).toBe('http://localhost:3000');
expect(safeOrigin('HTTPS://Tessera.Example.com')).toBe('https://tessera.example.com');
expect(safeOrigin(['https://a', 'https://b'])).toBeNull();
expect(safeOrigin(undefined)).toBeNull();
expect(safeOrigin('')).toBeNull();
expect(safeOrigin('https://host/pfad')).toBeNull();
expect(safeOrigin('javascript:alert(1)')).toBeNull();
});
it('Test 20 (Manifest fehlt): update -> 204', async () => {
fs.rmSync(path.join(tempDir, 'manifest.json'));
const res = await fetch(
updateUrl({ target: 'linux', arch: 'x86_64', current: '1.0.0', base: ORIGIN }),
);
expect(res.status).toBe(204);
});
it('Test 21 (Manifest-Validierung): signature als Zahl macht den Eintrag ungueltig -- download/linux 404', async () => {
// Am `writeManifest()`-Helper vorbei (dessen Typ verlangt einen String).
fs.writeFileSync(
path.join(tempDir, 'manifest.json'),
JSON.stringify({
version: '1.1.0',
channel: 'dev',
commit: 'abc1234',
buildTime: '2026-09-16T00:00:00Z',
files: {
linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256, signature: 123 },
},
}),
);
const res = await fetch(`${baseUrl}/desktop/download/linux`);
expect(res.status).toBe(404);
});
});
+115 -1
View File
@@ -4,6 +4,7 @@ import type {
DesktopManifest,
DesktopManifestFile,
DesktopPlatform,
DesktopUpdateResponse,
} from '@tessera/shared';
import * as fs from 'fs';
import * as path from 'path';
@@ -35,10 +36,58 @@ function isValidManifestFileEntry(entry: unknown): entry is DesktopManifestFile
typeof candidate.name === 'string' &&
typeof candidate.size === 'number' &&
typeof candidate.sha256 === 'string' &&
SHA256_HEX_RE.test(candidate.sha256)
SHA256_HEX_RE.test(candidate.sha256) &&
(candidate.signature === undefined || typeof candidate.signature === 'string')
);
}
/**
* Form von `updateVersion` im Manifest: `X.Y.Z` (live/dev) oder
* `X.Y.Z-beta.g<sha7>` (beta) -- exakt die Form, die desktop-collect.sh
* schreibt und die der Client-Comparator (`beta_commit`) erwartet.
*/
const UPDATE_VERSION_RE = /^\d+\.\d+\.\d+(-beta\.g[0-9a-f]{7})?$/;
/**
* RFC-3339-Zeitstempel. `pub_date` geht nur in die Antwort, wenn `buildTime`
* diese Form hat -- ein unparsebares Datum liesse `check()` im Client
* scheitern (das Plugin deserialisiert `pub_date` strikt).
*/
const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
/**
* Validiert den Query-Parameter `base` von `GET /desktop/update` (T-KGC-02):
* nur ein reiner http/https-Origin -- kein Pfad ausser `/`, keine Query, kein
* Fragment, keine Zugangsdaten. Rueckgabe ist `url.origin` (Host
* kleingeschrieben, ohne Schlussstrich), sonst `null`.
*
* `base` wird NUR zum Bauen der Rueckgabe-URL fuer den Anfragenden verwendet,
* nie serverseitig abgerufen (kein SSRF). Ein Angreifer koennte damit
* hoechstens seinen eigenen Client auf einen fremden Download lenken -- den
* die Signaturpruefung im Client ablehnt.
*/
export function safeOrigin(base: unknown): string | null {
if (typeof base !== 'string' || base.length === 0 || base.length > 2048) {
return null;
}
let url: URL;
try {
url = new URL(base);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
if (url.username !== '' || url.password !== '') {
return null;
}
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
return null;
}
return url.origin;
}
@Injectable()
export class DesktopService {
private readonly logger = new Logger(DesktopService.name);
@@ -78,6 +127,10 @@ export class DesktopService {
this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`);
return null;
}
if (parsed.updateVersion !== undefined && typeof parsed.updateVersion !== 'string') {
this.logger.warn(`manifest.json unter ${manifestPath} hat ein ungueltiges updateVersion`);
return null;
}
for (const platform of PLATFORMS) {
const entry = parsed.files[platform];
if (entry !== undefined && !isValidManifestFileEntry(entry)) {
@@ -120,6 +173,67 @@ export class DesktopService {
};
}
/**
* `GET /desktop/update` (quick-260917-kgc): Antwort im dynamischen Format
* von `tauri-plugin-updater` -- Pflichtfelder `version` (SemVer), `url`
* (absolut) und `signature`; optional `pub_date` (RFC 3339) und `notes`.
* `null` bedeutet "kein Update" und wird im Controller zu 204 ohne Body.
*
* `current` wird bewusst nicht ausgewertet: die Entscheidung "neuer?" trifft
* der Comparator im Client, die API kennt den Client-Commit nicht. Die
* absolute `url` entsteht aus dem validierten `origin` des Anfragenden
* (`safeOrigin`), nie aus einem serverseitigen Abruf. `/desktop/latest`
* bleibt fuer die Web-Oberflaeche mit relativen URLs.
*/
getUpdate(input: {
target: unknown;
arch: unknown;
origin: string;
}): DesktopUpdateResponse | null {
// (1) Plattform per Whitelist -- vor jedem Dateisystemzugriff.
if (!PLATFORMS.includes(input.target as DesktopPlatform)) {
return null;
}
const platform = input.target as DesktopPlatform;
// (2) Es gibt nur x86_64-Pakete.
if (input.arch !== 'x86_64') {
return null;
}
// (3) Manifest holen.
const manifest = this.getManifest();
if (!manifest) {
return null;
}
// (4) updateVersion muss vorhanden sein und die erwartete Form haben.
if (
typeof manifest.updateVersion !== 'string' ||
!UPDATE_VERSION_RE.test(manifest.updateVersion)
) {
return null;
}
// (5) Eintrag der Plattform mit Signatur -- ohne Signatur kein Update.
const entry = manifest.files[platform];
if (!entry || typeof entry.signature !== 'string' || entry.signature.length === 0) {
return null;
}
// (6) Antwort im Plugin-Format.
const response: DesktopUpdateResponse = {
version: manifest.updateVersion,
url: `${input.origin}/api-proxy/desktop/download/${platform}`,
signature: entry.signature,
notes: `Tessera ${manifest.updateVersion}`,
};
if (typeof manifest.buildTime === 'string' && RFC3339_RE.test(manifest.buildTime)) {
response.pub_date = manifest.buildTime;
}
return response;
}
/**
* `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die
* Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der
+310 -6
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -691,6 +700,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1004,6 +1024,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1884,6 +1914,36 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.118",
]
[[package]]
name = "jni-sys"
version = "0.3.1"
@@ -2087,6 +2147,12 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2366,6 +2432,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2499,6 +2577,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -2988,15 +3080,20 @@ dependencies = [
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3057,12 +3154,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
@@ -3072,6 +3182,33 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
@@ -3425,6 +3562,22 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
@@ -3647,7 +3800,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -3680,6 +3833,17 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -3703,7 +3867,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -3886,6 +4050,39 @@ dependencies = [
"tracing",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.4",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
@@ -3911,7 +4108,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -3934,7 +4131,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -4041,6 +4238,7 @@ name = "tessera-desktop"
version = "1.1.0"
dependencies = [
"reqwest 0.12.28",
"semver",
"serde",
"serde_json",
"tauri",
@@ -4049,6 +4247,7 @@ dependencies = [
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
]
@@ -4767,6 +4966,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5026,6 +5234,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -5059,13 +5276,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
]
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -5096,6 +5330,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -5108,6 +5348,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -5120,12 +5366,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -5138,6 +5396,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -5150,6 +5414,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -5162,6 +5432,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -5174,6 +5450,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.5.40"
@@ -5247,7 +5529,7 @@ dependencies = [
"gtk",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"libc",
"ndk",
"objc2",
@@ -5294,6 +5576,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.3"
@@ -5458,6 +5750,18 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+2
View File
@@ -20,3 +20,5 @@ reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
semver = "1"
File diff suppressed because one or more lines are too long
@@ -2822,6 +2822,60 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
@@ -2822,6 +2822,60 @@
"const": "store:deny-values",
"markdownDescription": "Denies the values command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
},
{
"description": "This permission set configures what kind of\noperations are available from the window state plugin.\n\n#### Granted Permissions\n\nAll operations are enabled by default.\n\n\n#### This default permission set includes:\n\n- `allow-filename`\n- `allow-restore-state`\n- `allow-save-window-state`",
"type": "string",
+369 -54
View File
@@ -1,3 +1,5 @@
use semver::Version;
use std::sync::Mutex;
use std::time::Duration;
use tauri::{
menu::{CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder},
@@ -8,24 +10,32 @@ use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_store::StoreExt;
use tauri_plugin_updater::{Update, UpdaterExt};
/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version
/// gefunden wurde. `spawn_version_check` setzt den Eintrag nach einem
/// Serverwechsel hierauf zurück, damit kein Hinweis des alten Servers stehen
/// bleibt.
const UPDATE_ITEM_DEFAULT: &str = "Update herunterladen";
/// gefunden wurde. Der Eintrag installiert das Update seit quick-260917-kgc
/// direkt in der App (Download, Signaturpruefung, Installation, Neustart);
/// den Browser oeffnet er nur noch als Rueckfall. `spawn_version_check`
/// setzt den Eintrag nach einem Serverwechsel hierauf zurück, damit kein
/// Hinweis des alten Servers stehen bleibt.
const UPDATE_ITEM_DEFAULT: &str = "Update installieren";
/// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt:
/// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte
/// (`Error::InsecureTransportProtocol`). Der Eintrag bleibt gesperrt, der
/// Text erklaert dauerhaft, warum -- keine Benachrichtigung bei jedem Start.
const UPDATE_ITEM_INSECURE: &str = "Update nur über https möglich";
#[derive(serde::Deserialize)]
struct VersionResponse {
version: String,
}
#[derive(serde::Deserialize)]
struct DesktopLatest {
version: String,
channel: String,
commit: String,
}
/// Gefundenes Update samt dem Menuetext, der zu ihm gehoert. Wird von
/// `spawn_version_check` befuellt und vom Tray-Klick "update" per `take()`
/// entnommen -- so kann ein Doppelklick keinen zweiten Download starten.
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
struct PendingUpdate(Mutex<Option<(Update, String)>>);
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
@@ -41,10 +51,10 @@ fn api_url(server: &str, path: &str) -> String {
/// NUR in die Navigation, niemals in den gespeicherten Server-Wert
/// (`server_url` im Store bleibt die reine Adresse); daraus liest die
/// Next.js-Middleware (apps/web/src/middleware.ts) das Cookie
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Die
/// Tray-URL "Update herunterladen" bekommt den Parameter bewusst NICHT --
/// sie oeffnet im System-Browser, dort muessen die Download-Links sichtbar
/// bleiben.
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Der
/// Browser-Rueckfall des Tray-Eintrags "Update installieren"
/// (`open_download_page`) bekommt den Parameter bewusst NICHT -- er oeffnet
/// im System-Browser, dort muessen die Download-Links sichtbar bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
let mut marked = url.clone();
marked.query_pairs_mut().append_pair("desktop", "1");
@@ -54,29 +64,105 @@ fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
/// Liefert (Menuetext, Benachrichtigungstext) fuer den Update-Hinweis. Der
/// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version (D-07,
/// desktop-collect.sh) -- hat sich nur der Commit geaendert, waere der reine
/// Versionstext ("Version 1.1.0 herunterladen") verwirrend, weil der Client
/// bereits genau diese Version hat. Der zweite Zweig nennt darum den
/// Commit-Stempel statt der unveraenderten Version.
/// Versionstext ("Auf Version 1.1.0 aktualisieren") verwirrend, weil der
/// Client bereits genau diese Version hat. Der zweite Zweig nennt darum den
/// Commit-Stempel statt der unveraenderten Version. Die Texte sprechen vom
/// Aktualisieren, nicht vom Herunterladen: der Klick installiert das Update
/// in der App (quick-260917-kgc).
fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String) {
if version_changed {
(
format!("Version {} herunterladen", version),
format!("Auf Version {} aktualisieren", version),
format!(
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
"Neue Version {} verfügbar – Aktualisieren über das Symbol im Infobereich.",
version
),
)
} else {
(
"Neuen Beta-Stand herunterladen".to_string(),
format!("Auf Beta-Stand {} aktualisieren", commit),
format!(
"Neuer Beta-Stand {} verfügbar – Download über das Symbol im Infobereich.",
"Neuer Beta-Stand {} verfügbar – Aktualisieren über das Symbol im Infobereich.",
commit
),
)
}
}
/// Liest den Beta-Stempel `beta.g<sha7>` aus dem Prerelease-Teil einer
/// Server-Version (`1.2.0-beta.gabc1234` → `abc1234`). Das `g` ist Pflicht:
/// ein rein numerischer SHA mit fuehrender Null (`0123456`) waere kein
/// gueltiger SemVer-Identifier, `check()` endete dann mit Err (Recherche
/// Pitfall 2). Live-Versionen ohne Prerelease und fremde Formate (`rc.1`)
/// liefern None.
fn beta_commit(release: &Version) -> Option<&str> {
release
.pre
.as_str()
.strip_prefix("beta.g")
.filter(|sha| !sha.is_empty())
}
/// Vergleichsregel des Updaters -- ersetzt den Standardvergleich des Plugins
/// vollstaendig (updater.rs Z. 576-579; SemVer allein saehe
/// `1.2.0-beta.gXXXX < 1.2.0` und ein Beta-Client bekaeme nie einen neueren
/// Beta-Bau). Regel:
/// - Basisversion X.Y.Z des Servers groesser → Update; kleiner → keins.
/// - Gleiche Basis mit Beta-Stempel `beta.g<sha7>` → Update genau dann,
/// wenn der Client einen Commit-Stempel kennt und er sich unterscheidet.
/// Der Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version
/// (D-07); ohne den Commit-Vergleich saehe ein Beta-Client zwischen zwei
/// Freigabe-Tags nie einen neueren Bau (WR-02, Code-Review Phase 18).
/// - Gleiche Basis ohne Prerelease (Live-Freigabe) → KEIN Update. Das ist die
/// bisherige Regel: ein Live-Client 1.2.0 bietet sich nie selbst 1.2.0 an
/// (keine Update-Schleife). Der Fall "Beta-Client wird auf einen Live-Server
/// derselben Basis umgestellt" ist client-seitig nicht entscheidbar (der
/// Client kennt nur CARGO_PKG_VERSION und APP_COMMIT, nicht den Tag-Commit
/// der Freigabe) und bekommt das Update mit der naechsten Freigabe.
/// - Leerer Client-Commit (Quell-Tarball ohne git) → nur Basisvergleich.
fn is_update_newer(current: &Version, current_commit: &str, release: &Version) -> bool {
let base = |v: &Version| (v.major, v.minor, v.patch);
if base(release) > base(current) {
return true;
}
if base(release) < base(current) {
return false;
}
match beta_commit(release) {
Some(sha) => !current_commit.is_empty() && sha != current_commit,
None => false,
}
}
/// Baut die Endpunkt-Adresse fuer das Updater-Plugin aus der gespeicherten
/// Server-Adresse. Die Platzhalter `{{target}}`, `{{arch}}` und
/// `{{current_version}}` stehen als Rohtext im Wert; `query_pairs_mut`
/// kodiert sie zu `%7B%7B…%7D%7D`, und das Plugin ersetzt beide Schreibweisen
/// (updater.rs Z. 476-486). `base` ist der Origin, aus dem die API die
/// absolute Download-Adresse bildet -- das Plugin verlangt eine `url::Url`,
/// relative Pfade scheitern. `api_url` bleibt die einzige Stelle mit dem
/// `/api-proxy`-Praefix.
fn update_endpoint(server: &str) -> Option<tauri::Url> {
let mut url = tauri::Url::parse(&api_url(server, "/desktop/update")).ok()?;
url.query_pairs_mut()
.append_pair("target", "{{target}}")
.append_pair("arch", "{{arch}}")
.append_pair("current", "{{current_version}}")
.append_pair("base", server.trim_end_matches('/'));
Some(url)
}
/// Leitet (Menuetext, Benachrichtigungstext) aus Client- und Server-Version
/// ab: eine neue Basis X.Y.Z wird als Version genannt (auch bei Beta), bei
/// gleicher Basis der Beta-Stempel.
fn release_labels(current: &Version, release: &Version) -> (String, String) {
let base = |v: &Version| (v.major, v.minor, v.patch);
let version_changed = base(release) != base(current);
let version = format!("{}.{}.{}", release.major, release.minor, release.patch);
let commit = beta_commit(release).unwrap_or("");
update_labels(version_changed, &version, commit)
}
/// Parst eine vom Nutzer eingegebene Server-Adresse und erlaubt nur http/https
/// -- dieselben Fehlertexte wie bisher inline in `check_server`. `check_server`,
/// `save_server_url` und `open_server` rufen ausschliesslich diesen Helfer;
@@ -174,43 +260,159 @@ fn apply_server(app: &AppHandle, url: Option<&str>) {
let _ = app.state::<TrayItems>().connected.set_text(line);
}
/// Fragt den Update-Endpunkt ueber das Updater-Plugin ab. Der eigene
/// `version_comparator` (`is_update_newer`) ersetzt den Standardvergleich
/// des Plugins. Die 15 s gelten fuer die Pruefung; das Plugin uebernimmt
/// denselben Wert als GESAMT-Timeout des Downloads (updater.rs Z. 698-700),
/// darum setzt `spawn_version_check` vor der Ablage `update.timeout` neu.
async fn check_for_update(
app: &AppHandle,
endpoint: tauri::Url,
) -> tauri_plugin_updater::Result<Option<Update>> {
let current_commit = env!("APP_COMMIT");
app.updater_builder()
.endpoints(vec![endpoint])?
.timeout(Duration::from_secs(15))
.version_comparator(move |current, release| {
is_update_newer(&current, current_commit, &release.version)
})
.build()?
.check()
.await
}
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt
/// den Update-Eintrag zuerst auf den Standardtext zurück -- nach einem
/// Wechsel darf kein Hinweis des alten Servers stehen bleiben.
/// den Update-Eintrag zuerst auf den Standardtext zurück und leert den
/// abgelegten Update-Stand -- nach einem Wechsel darf kein Hinweis des alten
/// Servers stehen bleiben.
fn spawn_version_check(app: AppHandle, server_url: String) {
let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_DEFAULT);
let _ = update_item.set_enabled(false);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = None;
}
// Gespeicherte Adressen sind immer parsebar (parse_server_url) -- reine
// Absicherung.
let Some(endpoint) = update_endpoint(&server_url) else {
return;
};
// Cargo garantiert eine gueltige SemVer-Version; desktop-version.sh
// schreibt immer reines X.Y.Z (D-07).
let current = Version::parse(env!("CARGO_PKG_VERSION"))
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
let app_version = env!("CARGO_PKG_VERSION").to_string();
let app_commit = env!("APP_COMMIT").to_string();
tauri::async_runtime::spawn(async move {
let url = api_url(&server_url, "/desktop/latest");
if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<DesktopLatest>().await {
// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version
// (D-07, desktop-collect.sh) -- ohne den Commit-Vergleich saehe
// ein Beta-Client zwischen zwei Freigabe-Tags nie einen neueren
// Bau (WR-02, Code-Review Phase 18). Fuer den Live-Kanal bleibt
// es beim reinen Versionsvergleich. Die Texte fuer Menue und
// Benachrichtigung liefert `update_labels`.
let version_changed = info.version != app_version;
let is_newer =
version_changed || (info.channel == "beta" && info.commit != app_commit);
if is_newer {
let (menu_text, body) =
update_labels(version_changed, &info.version, &info.commit);
match check_for_update(&app, endpoint).await {
Ok(Some(mut update)) => {
let (menu_text, body) = Version::parse(&update.version)
.map(|release| release_labels(&current, &release))
.unwrap_or_else(|_| update_labels(true, &update.version, ""));
// 15 s reichen fuer die Pruefung, nicht fuer den ~100-MB-Download:
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
// Downloads an (updater.rs Z. 698-700).
update.timeout = Some(Duration::from_secs(600));
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
let _ = update_item.set_text(menu_text);
let _ = update_item.set_text(&menu_text);
let _ = update_item.set_enabled(true);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text));
}
}
Ok(None) => {}
// Der Release-Bau verweigert http-Endpunkte (config.rs
// `validate_endpoints`), der Debug-Bau warnt nur. Keine
// `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der
// Eintrag bleibt gesperrt und erklaert den Grund.
Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => {
let _ = update_item.set_text(UPDATE_ITEM_INSECURE);
}
Err(_) => {}
}
});
}
/// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der
/// bisherige Weg des Tray-Eintrags, jetzt Rueckfall, wenn kein Update
/// abgelegt ist oder die Installation in der App fehlschlaegt. Bewusst ohne
/// `desktop=1` (siehe `with_desktop_marker`).
fn open_download_page(app: &AppHandle) {
if let Some(server) = stored_server_url(app) {
let target = format!("{}/settings/general/desktop", server.trim_end_matches('/'));
let _ = app.opener().open_url(target, None::<&str>);
}
}
/// Laedt das abgelegte Update, prueft die Signatur, installiert es und
/// startet die App neu. Fortschritt und Zustand stehen im Menuetext des
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
/// Rueckfall.
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
let item = app.state::<TrayItems>().update.clone();
let _ = item.set_enabled(false);
let _ = item.set_text("Update wird heruntergeladen…");
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body("Update wird heruntergeladen…")
.show();
tauri::async_runtime::spawn(async move {
let item_progress = item.clone();
let item_finish = item.clone();
let mut received: u64 = 0;
let mut last_percent: Option<u64> = None;
let on_chunk = move |chunk: usize, total: Option<u64>| {
received += chunk as u64;
if let Some(total) = total.filter(|t| *t > 0) {
let percent = (received * 100 / total).min(100);
if last_percent != Some(percent) {
last_percent = Some(percent);
let _ = item_progress.set_text(format!("Lädt … {} %", percent));
}
}
};
let on_finish = move || {
let _ = item_finish.set_text("Wird installiert…");
};
match update.download_and_install(on_chunk, on_finish).await {
Ok(()) => {
// Windows: nie erreicht -- das Plugin startet den NSIS-Installer
// mit `/P /UPDATE /R /ARGS` und beendet den Prozess per
// `std::process::exit(0)`; der Run-Handler wird dabei nicht
// durchlaufen. Linux: die AppImage ist ersetzt; `restart()` aus
// dem Nebenthread laeuft ueber RESTART_EXIT_CODE
// (`code: Some(..)`) am `prevent_exit` des Run-Handlers vorbei
// (tauri app.rs Z. 588-611).
app.restart();
}
Err(e) => {
let _ = item.set_text(&menu_text);
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
*pending = Some((update, menu_text.clone()));
}
let _ = item.set_enabled(true);
let _ = app
.notification()
.builder()
.title("Tessera-Update")
.body(format!(
"Update fehlgeschlagen: {}. Die Download-Seite wird im Browser geöffnet.",
e
))
.show();
open_download_page(&app);
}
}
});
}
@@ -298,6 +500,7 @@ pub fn run() {
None,
))
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.invoke_handler(tauri::generate_handler![
check_server,
save_server_url,
@@ -316,11 +519,13 @@ pub fn run() {
}
// Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
// Server-Adresse ändern... · Update herunterladen · — ·
// Server-Adresse ändern... · Update installieren · — ·
// Autostart-Haken · — · Beenden.
// "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis
// die Versionspruefung eine neuere Version findet; "autostart"
// spiegelt den tatsaechlichen Systemzustand beim Start.
// die Versionspruefung eine neuere Version findet, und heisst dann
// "Auf Version X.Y.Z aktualisieren" bzw. "Auf Beta-Stand ...
// aktualisieren"; "autostart" spiegelt den tatsaechlichen
// Systemzustand beim Start.
let connected =
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
.enabled(false)
@@ -357,6 +562,7 @@ pub fn run() {
connected: connected.clone(),
update: update.clone(),
});
app.manage(PendingUpdate(Mutex::new(None)));
let autostart_for_menu = autostart.clone();
@@ -383,12 +589,20 @@ pub fn run() {
}
}
"update" => {
if let Some(server) = stored_server_url(app) {
let target = format!(
"{}/settings/general/desktop",
server.trim_end_matches('/')
);
let _ = app.opener().open_url(target, None::<&str>);
// `take()` verhindert Doppelklick-Downloads, der
// gesperrte Eintrag ebenso; ohne abgelegten Stand
// bleibt der Browser-Weg (Einstellungen → Desktop-App).
let pending = app
.state::<PendingUpdate>()
.0
.lock()
.ok()
.and_then(|mut guard| guard.take());
match pending {
Some((update, menu_text)) => {
spawn_update_install(app.clone(), update, menu_text)
}
None => open_download_page(app),
}
}
"autostart" => {
@@ -489,23 +703,124 @@ mod tests {
#[test]
fn update_labels_bei_versionswechsel() {
let (menu_text, body) = update_labels(true, "1.2.0", "abc1234");
assert_eq!(menu_text, "Version 1.2.0 herunterladen");
assert_eq!(menu_text, "Auf Version 1.2.0 aktualisieren");
assert_eq!(
body,
"Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich."
"Neue Version 1.2.0 verfügbar – Aktualisieren über das Symbol im Infobereich."
);
}
#[test]
fn update_labels_bei_gleicher_version_beta_commit() {
let (menu_text, body) = update_labels(false, "1.1.0", "abc1234");
assert_eq!(menu_text, "Neuen Beta-Stand herunterladen");
assert_eq!(menu_text, "Auf Beta-Stand abc1234 aktualisieren");
assert_eq!(
body,
"Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich."
"Neuer Beta-Stand abc1234 verfügbar – Aktualisieren über das Symbol im Infobereich."
);
}
fn v(s: &str) -> Version {
Version::parse(s).unwrap()
}
#[test]
fn is_update_newer_hoehere_basis_live() {
assert!(is_update_newer(&v("1.2.0"), "abc1234", &v("1.3.0")));
}
#[test]
fn is_update_newer_hoehere_basis_beta() {
assert!(is_update_newer(
&v("1.2.0"),
"abc1234",
&v("1.3.0-beta.gdef5678")
));
}
#[test]
fn is_update_newer_niedrigere_basis() {
assert!(!is_update_newer(
&v("1.3.0"),
"abc1234",
&v("1.2.0-beta.g9999999")
));
}
#[test]
fn is_update_newer_gleiche_basis_anderer_beta_stempel() {
assert!(is_update_newer(
&v("1.2.0"),
"abc1234",
&v("1.2.0-beta.gdef5678")
));
}
#[test]
fn is_update_newer_gleiche_basis_gleicher_beta_stempel() {
assert!(!is_update_newer(
&v("1.2.0"),
"abc1234",
&v("1.2.0-beta.gabc1234")
));
}
#[test]
fn is_update_newer_gleiche_basis_live_ohne_prerelease() {
assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0")));
}
#[test]
fn is_update_newer_leerer_client_commit_nur_basisvergleich() {
assert!(!is_update_newer(&v("1.2.0"), "", &v("1.2.0-beta.gdef5678")));
}
#[test]
fn is_update_newer_fremdes_prerelease_format() {
assert!(!is_update_newer(&v("1.2.0"), "abc1234", &v("1.2.0-rc.1")));
}
#[test]
fn beta_commit_mit_praefix_g_erlaubt_fuehrende_null() {
assert!(Version::parse("1.2.0-beta.g0123456").is_ok());
assert_eq!(beta_commit(&v("1.2.0-beta.g0123456")), Some("0123456"));
}
#[test]
fn beta_commit_none_ohne_prerelease() {
assert_eq!(beta_commit(&v("1.2.0")), None);
}
#[test]
fn beta_commit_none_bei_fremdem_prerelease() {
assert_eq!(beta_commit(&v("1.2.0-rc.1")), None);
}
#[test]
fn update_endpoint_traegt_platzhalter_und_base() {
assert_eq!(
update_endpoint("https://tessera.ctl.de/").unwrap().as_str(),
"https://tessera.ctl.de/api-proxy/desktop/update?target=%7B%7Btarget%7D%7D&arch=%7B%7Barch%7D%7D&current=%7B%7Bcurrent_version%7D%7D&base=https%3A%2F%2Ftessera.ctl.de"
);
}
#[test]
fn update_endpoint_none_bei_unparsbarer_adresse() {
assert!(update_endpoint("kein url").is_none());
}
#[test]
fn release_labels_neue_basis_nennt_version_auch_bei_beta() {
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.3.0-beta.gdef5678"));
assert_eq!(menu_text, "Auf Version 1.3.0 aktualisieren");
}
#[test]
fn release_labels_gleiche_basis_nennt_beta_stempel() {
let (menu_text, _) = release_labels(&v("1.2.0"), &v("1.2.0-beta.gdef5678"));
assert_eq!(menu_text, "Auf Beta-Stand def5678 aktualisieren");
}
#[test]
fn server_host_mit_pfad_und_ohne_port() {
assert_eq!(
+9
View File
@@ -27,6 +27,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": [
"appimage",
"nsis"
@@ -48,5 +49,13 @@
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIxNDIxMTZDMUFGRkJCRjEKUldUeHUvOGFiQkZDc2V0cnJZcmJVSURraGJHcE5TODVDeUp3UEVpM0xOc3I5TUZrNW5qZUw0d2gK",
"windows": {
"installMode": "passive"
}
}
}
}
+12 -2
View File
@@ -192,7 +192,7 @@ Schließen Sie das Fenster über das X, legt sich Tessera lediglich in den Infob
- **Verbunden mit …** — zeigt grau den Tessera-Server, mit dem die App verbunden ist (nicht anklickbar)
- **Öffnen**
- **Server-Adresse ändern…** — siehe [Server-Adresse ändern](#server-adresse-ändern)
- **Update herunterladen** — wird aktiv, sobald eine neue Version vorliegt (auf dem Beta-Kanal: „Neuen Beta-Stand herunterladen")
- **Update installieren** — wird aktiv, sobald eine neue Version vorliegt, und heißt dann „Auf Version X.Y.Z aktualisieren" (auf dem Beta-Kanal: „Auf Beta-Stand … aktualisieren")
- **Mit Windows starten** (unter Linux: **Beim Anmelden starten**) — mit Häkchen
- **Beenden**
@@ -212,13 +212,23 @@ Setzen oder entfernen Sie das Häkchen bei „Mit Windows starten" (bzw. „Beim
### Neue Version
Ist eine neuere Version verfügbar, meldet sich die App beim Start mit „Neue Version X.Y.Z verfügbar". Der Menüeintrag „Update herunterladen" heißt dann „Version X.Y.Z herunterladen" und öffnet mit einem Klick die Seite Einstellungen → Desktop-App im Browser — dort laden Sie die neue Version herunter und installieren sie wie oben beschrieben. Ein automatisches Aktualisieren gibt es nicht.
Beim Start (und nach einem Wechsel der Server-Adresse) prüft die App, ob Ihr Tessera-Server eine neuere Version hat. Ist das der Fall, meldet sie sich mit „Neue Version X.Y.Z verfügbar", und der Menüeintrag im Infobereich heißt „Auf Version X.Y.Z aktualisieren" (auf dem Beta-Kanal: „Auf Beta-Stand … aktualisieren").
Ein Klick auf diesen Eintrag genügt: Die App lädt das Paket im Hintergrund (der Fortschritt steht im Menü, etwa „Lädt … 42 %"), prüft, dass es unverändert von Ihrem Tessera-Server stammt, und installiert es. Unter Windows erscheint kurz das Installationsfenster mit einem Fortschrittsbalken, danach startet Tessera von selbst neu; die Server-Adresse bleibt erhalten, ebenso die Fensterposition (sie kann nach einem Update einmal auf den Standard zurückfallen). Unter Linux wird die Datei `Tessera-X.Y.Z.AppImage` an ihrem Speicherort ersetzt und die App startet neu — die Datei muss dafür an einem Ort liegen, an dem Sie schreiben dürfen (zum Beispiel in Ihrem Home-Ordner).
Schlägt das Update fehl, meldet die App den Grund und öffnet die Seite Einstellungen → Desktop-App im Browser. Dort laden Sie die neue Version herunter und installieren sie wie oben beschrieben.
Voraussetzung ist eine Server-Adresse, die mit `https` beginnt. Bei einer `http`-Adresse steht im Menü „Update nur über https möglich" — der Weg über den Browser bleibt.
**Einmaliger Wechsel:** Wer die Desktop-App in Version 1.2.0 oder älter installiert hat, lädt die nächste Version ein letztes Mal über den Browser herunter und installiert sie von Hand. Ab dann läuft das Aktualisieren über den Menüeintrag.
### Wenn etwas nicht klappt
- **„Unter dieser Adresse antwortet kein Tessera-Server"** — prüfen Sie die eingegebene Adresse; gemeint ist die Adresse, unter der Sie Tessera im Browser öffnen, nicht eine interne API-Adresse.
- **Der Download-Link fehlt auf der Anmeldeseite** — der Server trägt derzeit keine Desktop-Pakete. Fragen Sie in diesem Fall Ihren Administrator.
- **Windows zeigt die SmartScreen-Warnung** — das ist normal und erwartet, siehe [Installation unter Windows](#installation-unter-windows).
- **„Update fehlgeschlagen"** — das Netz war kurz weg, das Paket kam unvollständig an oder stammt nicht von Ihrem eigenen Server. Nehmen Sie den Weg über den Browser, wie unter [Neue Version](#neue-version) beschrieben.
- **Linux: Das Update meldet fehlende Schreibrechte** — legen Sie die AppImage-Datei in Ihren Home-Ordner und starten Sie sie von dort.
## Einen Fehler melden
+62 -7
View File
@@ -606,7 +606,10 @@ Kanal, Version, Dateinamen, Größen und Prüfsummen.
`publish` kopiert die fertigen Pakete in das API-Abbild nach
`/app/desktop-dist/`, zusammen mit einer `manifest.json` (Version, Kanal,
Dateinamen, Größen, Prüfsummen). Auf dem Beta-Kanal tragen die Dateinamen
Dateinamen, Größen, Prüfsummen; seit der Update-Funktion zusätzlich
`updateVersion` – die Form, die der Client vergleicht, `X.Y.Z` auf Live und
`X.Y.Z-beta.g{commit}` auf Beta – sowie je Plattform die `signature` des
Pakets). Auf dem Beta-Kanal tragen die Dateinamen
zusätzlich den Suffix `-beta.{commit}`, zum Beispiel
`Tessera-Setup-1.1.0-beta.742fb5c.exe` und
`Tessera-1.1.0-beta.742fb5c.AppImage`; auf Live steht dort die reine Form
@@ -617,14 +620,23 @@ Kontrolle auf dem Server:
```bash
docker compose exec api ls -l /app/desktop-dist
curl -s https://{ihre-adresse}/api-proxy/desktop/latest
curl -si "https://{ihre-adresse}/api-proxy/desktop/update?target=windows&arch=x86_64&current=0.0.0&base=https://{ihre-adresse}"
```
`ls -l` zeigt die abgelegten Dateien samt `manifest.json`; die `curl`-Abfrage
liefert dieselben Angaben als JSON (Version, Kanal, je Plattform Dateiname,
Größe, Prüfsumme und relative Download-Adresse) – das ist genau die Antwort,
die auch die Anmeldeseite und die Einstellungsseite auswerten. Antwortet die
Abfrage mit `404`, fehlt entweder das Verzeichnis oder das Manifest; die
Web-Oberfläche blendet den Download-Link dann automatisch aus.
`ls -l` zeigt die abgelegten Dateien samt `manifest.json`; die erste
`curl`-Abfrage liefert dieselben Angaben als JSON (Version, Kanal, je
Plattform Dateiname, Größe, Prüfsumme und relative Download-Adresse) – das
ist genau die Antwort, die auch die Anmeldeseite und die Einstellungsseite
auswerten. Antwortet die Abfrage mit `404`, fehlt entweder das Verzeichnis
oder das Manifest; die Web-Oberfläche blendet den Download-Link dann
automatisch aus.
Die zweite Kontrollzeile stellt die Frage, die der Desktop-Client beim Start
stellt: `200` mit `version`, `url` und `signature` bedeutet, dass sich
installierte Clients von diesem Server aktualisieren können; `204` bedeutet,
dass für diese Plattform kein signiertes Paket vorliegt (zum Beispiel ein Stand
vor September 2026 oder ein Bau ohne Schlüssel). Für Linux `target=linux`
einsetzen.
### Release-Dateien in Gitea
@@ -635,6 +647,47 @@ vorhandene Datei gleichen Namens, statt einen zweiten Anhang anzulegen. Die am
Release hinterlegte Datei ist byteidentisch mit der im Abbild ausgelieferten;
die Prüfsumme (`sha256`) aus `manifest.json` gilt für beide gleichermaßen.
### Updates in der App und der Signierschlüssel
Seit September 2026 aktualisiert sich die Desktop-App per Klick im Menü des
Infobereich-Symbols. Beim Start fragt sie `GET /api-proxy/desktop/update`
und installiert ausschließlich Pakete, deren Signatur zu dem im Client
hinterlegten öffentlichen Schlüssel passt – ein manipuliertes oder fremdes
Paket wird abgelehnt, bevor irgendetwas installiert wird. Das ist die
Vertrauensbasis der Update-Funktion, nicht die Prüfsumme im Manifest.
Der **private Schlüssel** liegt nicht im Repository. Er existiert an zwei
Stellen:
- als Gitea-Secrets `TAURI_SIGNING_PRIVATE_KEY` und
`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` (Repository → Einstellungen → Actions →
Secrets); nur die beiden `tauri build`-Schritte des Jobs `desktop` sehen
sie, die Skripte kennen den Schlüssel nicht;
- als Sicherung auf dem Entwicklungsrechner unter
`~/.tessera/desktop-updater/` (`tessera-updater.key`, `password.txt` und
der öffentliche Teil `tessera-updater.key.pub`).
**Sicherung:** Legen Sie die beiden Dateien zusätzlich an einem zweiten
sicheren Ort ab. Geht der private Schlüssel verloren, können bereits
installierte Clients kein Update mehr annehmen: Es muss ein neues
Schlüsselpaar erzeugt (`pnpm --filter @tessera/desktop exec tauri signer
generate -w <pfad>`), der öffentliche Teil in
`apps/desktop/src-tauri/tauri.conf.json` unter `plugins.updater.pubkey`
eingetragen und jeder Client einmal von Hand neu installiert werden.
Ohne die Secrets bricht der CI-Bau ab („A public key has been found, but no
private key"). `tauri build --no-sign` ist ausschließlich für lokale Proben
gedacht und im CI nicht vorgesehen – ein so gebautes Paket trägt keine
Signatur, und der Update-Endpunkt antwortet dafür mit `204`.
Windows legt bei jedem Update einen Ordner `%TEMP%\Tessera-{Version}-updater-…`
(rund 100 MB) an und räumt ihn nicht auf. Das ist kein Fehler; die Ordner
können jederzeit gelöscht werden.
Der Client erlaubt Updates in der App nur über `https`. Anwender mit einer
`http`-Adresse sehen im Menü den Hinweis „Update nur über https möglich" und
nutzen weiterhin den Weg über den Browser.
### Umgebungsvariablen
Für die Desktop-Auslieferung ist keine neue Pflichtvariable nötig.
@@ -652,3 +705,5 @@ Für die Desktop-Auslieferung ist keine neue Pflichtvariable nötig.
| Client meldet „Unter dieser Adresse antwortet kein Tessera-Server" | Anwender hat die interne API-Adresse statt der Web-Adresse eingetragen, oder `/api-proxy` ist vom Client-Rechner aus nicht erreichbar | Die im Anwenderhandbuch beschriebene Adresse verwenden (dieselbe wie im Browser); Netzwerk-/Firewall-Erreichbarkeit der Web-Adresse prüfen. |
| Windows zeigt die SmartScreen-Warnung | Erwartet – die App ist für den internen Gebrauch nicht signiert (D-09) | Kein Fehler; Anwenderhandbuch, Abschnitt „Installation unter Windows", beschreibt den Ablauf. |
| Beta-Paket nennt einen älteren Commit als das laufende Abbild (Dateiname `-beta.<Commit>`, Einstellungen → Desktop-App) | Erwartet: Desktop-App seit diesem Commit unverändert, Pakete aus dem Zwischenspeicher übernommen (Abschnitt „Wann gebaut wird …") | Kein Fehler. Soll dennoch neu gebaut werden, genügt eine Änderung unter `apps/desktop/` im nächsten Push. |
| Client meldet „Update fehlgeschlagen" | Download über den Proxy abgebrochen (Größengrenze/Zeitlimit, siehe zweite Zeile dieser Tabelle), oder die Signatur passt nicht – die Pakete stammen nicht aus dem CI-Bau mit dem aktuellen Schlüssel | Kontrollzeile `/api-proxy/desktop/update` (Abschnitt „Wo die Pakete im Abbild liegen"), den Pipeline-Lauf und die Proxy-Einstellungen prüfen. Der Anwender kommt über den Browser-Weg weiter. |
| `/api-proxy/desktop/update` antwortet dauerhaft `204`, obwohl Pakete da sind | Manifest ohne `signature`/`updateVersion`: Pakete aus einem Bau vor der Update-Funktion oder mit `--no-sign` | Eine Änderung unter `apps/desktop/` pushen bzw. den Tag neu bauen lassen; im CI prüfen, dass die Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD` gesetzt sind (Abschnitt „Updates in der App und der Signierschlüssel"). |
+19 -3
View File
@@ -140,15 +140,31 @@ Version setzen und Linux-Paket bauen:
```bash
sh .gitea/scripts/desktop-version.sh
pnpm --filter @tessera/desktop exec tauri build --bundles appimage
pnpm --filter @tessera/desktop exec tauri build --bundles appimage --no-sign
```
`desktop-version.sh` schreibt die Version des letzten Freigabe-Tags in
`tauri.conf.json`/`Cargo.toml` — die im Repository eingecheckten Versionsdateien
sind nur eine Basislinie, nicht die tatsächliche Freigabeversion. Das fertige
Paket liegt danach unter
`apps/desktop/src-tauri/target/release/bundle/appimage/`. Um es wie die
API es ausliefern würde einzusammeln:
`apps/desktop/src-tauri/target/release/bundle/appimage/`.
**`--no-sign` ist lokal Pflicht.** Seit der Update-Funktion in der App
verlangt `tauri build` den Signierschlüssel (Umgebungsvariable
`TAURI_SIGNING_PRIVATE_KEY`), weil `bundle.createUpdaterArtifacts` und der
öffentliche Schlüssel `plugins.updater.pubkey` in `tauri.conf.json` gesetzt
sind — ohne Schlüssel bricht der Bau mit „A public key has been found, but no
private key" ab. Mit `--no-sign` entsteht keine `.sig`-Datei;
`desktop-collect.sh` warnt dann nur (Kanal `dev`) und lässt das Feld
`signature` weg, und die API antwortet auf `GET /desktop/update` mit `204`.
Der In-App-Update-Weg lässt sich lokal also nur mit dem echten Schlüssel
durchspielen (Betriebshandbuch, Kapitel 10). Zwei Hinweise für `tauri dev`:
Dort läuft die App ohne AppImage — den Update-Download (`download_and_install`)
nie auslösen, er würde die Binary in `target/` überschreiben; die
Versionsprüfung selbst ist im Debug-Bau auch gegen `http://localhost`
erlaubt (das Plugin warnt nur, der Release-Bau lehnt `http` ab).
Um das Paket so einzusammeln, wie die API es ausliefern würde:
```bash
sh .gitea/scripts/desktop-collect.sh --require linux
+31 -4
View File
@@ -81,17 +81,27 @@ docker ps --filter name=gitea-runner
### Gitea Secrets (fuer die CI-Pipeline)
In Gitea unter **Repository > Settings > Actions > Secrets** werden die Secrets
fuer die Pipeline konfiguriert. Benoetigt wird genau eines:
fuer die Pipeline konfiguriert. Benoetigt werden drei:
| Secret | Beschreibung |
|--------|--------------|
| `REGISTRY_TOKEN` | Gitea-Zugangstoken (Access Token) mit Schreibrecht auf Pakete (`package: write`) und zusaetzlich auf das Repository (`repository: write`, fuer Releases). Wird im Job `publish` fuer `docker login localhost:3002 --password-stdin` verwendet und im Release-Schritt ueber `env` als `GITEA_TOKEN` an `.gitea/scripts/publish-release.sh` gereicht -- nie als Argument. |
| `TAURI_SIGNING_PRIVATE_KEY` | Inhalt der privaten Schluesseldatei des Tauri-Updaters (eine Base64-Zeile, erzeugt mit `tauri signer generate`). Wird ausschliesslich an den zwei `tauri build`-Schritten des Jobs `desktop` als `env` gesetzt; die Tauri-CLI signiert damit das AppImage und den Windows-Installer (`.sig` neben dem Bundle). Der oeffentliche Gegenpart steht in `apps/desktop/src-tauri/tauri.conf.json` (`plugins.updater.pubkey`). |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Passwort zu diesem Schluessel; gleiche Stelle, gleicher Umfang. |
Das Token erscheint nie im Log: es wird per `--password-stdin` uebergeben und
Gitea maskiert Secret-Werte in der Job-Ausgabe. Das Veroeffentlichungs-Skript
`.gitea/scripts/publish-images.sh` kennt das Token nicht; der Login bleibt im
Workflow.
Der Signierschluessel wird ebenfalls nie ausgegeben: die Skripte kennen ihn
nicht (`desktop-collect.sh` prueft nur, OB die Variable gesetzt ist, um die
Signatur zur Pflicht zu machen), nur die beiden Bau-Schritte sehen ihn --
weder `pnpm install`, `apt-get`, `cargo install cargo-xwin` noch die
Cache-Schritte. Die Sicherung des Schluessels ausserhalb der Pipeline
(`~/.tessera/desktop-updater/` auf dem Entwicklungsrechner) beschreibt das
Betriebshandbuch, Kapitel 10.
Der Push geht ueber `localhost:3002` (Gitea laeuft auf demselben Rechner wie der
Runner), weil der Nginx Proxy Manager vor `git.vicolab.de` grosse Image-Blobs
blockt. Das Pullen auf den Servern laeuft ueber `git.vicolab.de`
@@ -180,10 +190,15 @@ sh .gitea/scripts/desktop-stamp.sh stamp`.
(`cargo check`/`cargo clippy`, D-16), Bau des Linux-AppImage
(`tauri build --bundles appimage`), dann des Windows-Installers per
Cross-Bau (`tauri build --runner cargo-xwin --target
x86_64-pc-windows-msvc --bundles nsis`).
x86_64-pc-windows-msvc --bundles nsis`). Beide Bau-Schritte tragen die
Secrets `TAURI_SIGNING_PRIVATE_KEY`/`_PASSWORD` als `env` und signieren
die Bundles (quick-260917-kgc): die Tauri-CLI legt `.sig`-Dateien neben
`-setup.exe` und `.AppImage` ab -- host-unabhaengig, also auch im
Cross-Bau. Kein `--no-sign` im CI.
6. **Pakete einsammeln** (`desktop-collect.sh --require linux,windows`) --
schreibt `manifest.json` und schlaegt fehl, wenn eine der beiden Dateien
fehlt.
schreibt `manifest.json` (mit `updateVersion` und je Plattform der
`signature` aus der `.sig`-Datei) und schlaegt fehl, wenn eine der beiden
Dateien fehlt oder auf `main`/Tags eine `.sig` fehlt.
7. **Uebergabe an `publish`** per `actions/cache/save@v4` mit dem Schluessel
`desktop-dist-${{ gitea.sha }}` (ein neuer Schluessel je Commit, damit
`publish` garantiert die Pakete des gerade gebauten Standes bekommt, nicht
@@ -343,6 +358,18 @@ und einen Branch-Schutz fuer `live` anlegen (T-KU1-04).
Speicherdruck `CARGO_BUILD_JOBS` (z. B. auf `4`) als Umgebungsvariable im
Job setzen, um die parallele Uebersetzung zu drosseln.
### Job `desktop`: "A public key has been found, but no private key"
Die Tauri-CLI bricht den Bau ab, weil `plugins.updater.pubkey` in
`tauri.conf.json` gesetzt ist, aber `TAURI_SIGNING_PRIVATE_KEY` in der
Umgebung fehlt. Ursache sind fast immer fehlende oder umbenannte Secrets: in
Gitea unter **Repository > Settings > Actions > Secrets** pruefen, ob
`TAURI_SIGNING_PRIVATE_KEY` und `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` unter
genau diesen Namen existieren, und ob beide Bau-Schritte in `ci.yml` den
`env`-Block tragen. Niemals `--no-sign` in `ci.yml` eintragen -- damit
entstuenden unsignierte Pakete, die kein Client als Update annimmt
(`desktop-collect.sh` bricht auf `main`/Tags ohne `.sig` ohnehin ab).
### `desktop` baut, obwohl nichts geaendert wurde -- oder uebernimmt trotz Aenderung
**Baut trotzdem:**
+25
View File
@@ -30,6 +30,12 @@ export interface DesktopManifestFile {
name: string;
size: number;
sha256: string;
/**
* Base64-Inhalt der `.sig`-Datei des Tauri-Bundlers (minisign), geschrieben
* von desktop-collect.sh, gelesen nur von `GET /desktop/update`. Fehlt bei
* Bauten mit `--no-sign` -- dann gibt es kein Update in der App.
*/
signature?: string;
}
export interface DesktopManifest {
@@ -37,9 +43,28 @@ export interface DesktopManifest {
channel: string;
commit: string;
buildTime: string;
/**
* SemVer-Form, die der Updater im Client vergleicht: `X.Y.Z` bei live,
* `X.Y.Z-beta.g<sha7>` bei beta (Praefix `g` Pflicht -- ein rein numerischer
* SHA mit fuehrender Null waere kein gueltiger SemVer-Identifier).
*/
updateVersion?: string;
files: Partial<Record<DesktopPlatform, DesktopManifestFile>>;
}
/**
* Antwort von `GET /desktop/update` -- das dynamische Antwortformat von
* `tauri-plugin-updater`. Die Feldnamen sind vom Plugin vorgegeben, darum
* snake_case `pub_date`. `url` muss absolut sein, `signature` ist Pflicht.
*/
export interface DesktopUpdateResponse {
version: string;
pub_date?: string;
url: string;
signature: string;
notes?: string;
}
export interface DesktopLatestFile extends DesktopManifestFile {
url: string;
}