diff --git a/.gitea/scripts/desktop-collect.sh b/.gitea/scripts/desktop-collect.sh new file mode 100755 index 0000000..d8f5d4e --- /dev/null +++ b/.gitea/scripts/desktop-collect.sh @@ -0,0 +1,156 @@ +#!/bin/sh +# desktop-collect.sh -- Desktop-Pakete aus dem Tauri-Bau einsammeln, unter +# kanonischem Namen ablegen und manifest.json schreiben (Phase 18, D-08). +# +# Kanalmodell (identisch zu publish-images.sh, an GITHUB_REF entschieden, +# damit lokale Proben ohne Runner pruefbar sind): +# refs/tags/v* -> Kanal live, kein Namenssuffix +# refs/heads/main -> Kanal beta, Suffix -beta.{7-stelliger SHA} am Dateinamen +# alles andere -> Kanal dev, kein Suffix (lokale Proben tragen den +# Freigabe-Namen, damit desktop-version.sh/desktop-collect.sh +# ohne Pipeline durchgespielt werden koennen) +# +# Aufruf: sh .gitea/scripts/desktop-collect.sh --require linux[,windows] +# +# Umgebung: +# GITHUB_REF Kanalentscheidung (siehe oben) +# DESKTOP_DIST Zielordner fuer die Pakete (Vorgabe: desktop-dist) +# TAURI_DIR Tauri-Projektordner (Vorgabe: apps/desktop/src-tauri) +# +# 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. +set -eu + +REQUIRE="" +while [ $# -gt 0 ]; do + case "$1" in + --require) + shift + REQUIRE="${1:-}" + ;; + *) + echo "Unbekanntes Argument: $1" >&2 + exit 1 + ;; + esac + shift +done + +if [ -z "$REQUIRE" ]; then + echo "Aufruf: $0 --require linux[,windows]" >&2 + exit 1 +fi + +DESKTOP_DIST="${DESKTOP_DIST:-desktop-dist}" +TAURI_DIR="${TAURI_DIR:-apps/desktop/src-tauri}" +REF="${GITHUB_REF:-}" + +case "$REF" in + refs/tags/v*) + CHANNEL=live + SUFFIX="" + ;; + refs/heads/main) + CHANNEL=beta + SHA_SHORT="$(git rev-parse --short=7 HEAD)" + SUFFIX="-beta.$SHA_SHORT" + ;; + *) + CHANNEL=dev + SUFFIX="" + ;; +esac + +VERSION="$(jq -r .version "$TAURI_DIR/tauri.conf.json")" +case "$VERSION" in + [0-9]*.[0-9]*.[0-9]*) + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Version '$VERSION' aus $TAURI_DIR/tauri.conf.json ist nicht rein numerisch (X.Y.Z)." >&2 + exit 1 + fi + ;; + *) + echo "Version '$VERSION' aus $TAURI_DIR/tauri.conf.json ist nicht rein numerisch (X.Y.Z)." >&2 + exit 1 + ;; +esac + +COMMIT="$(git rev-parse --short=7 HEAD)" +BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +mkdir -p "$DESKTOP_DIST" +# Alte Pakete/Manifest entfernen, Platzhalter (.gitkeep) bleibt erhalten. +rm -f "$DESKTOP_DIST"/*.AppImage "$DESKTOP_DIST"/*.exe "$DESKTOP_DIST/manifest.json" + +MANIFEST_ARGS="" +TMP_MANIFEST="$(mktemp)" +trap 'rm -f "$TMP_MANIFEST"' EXIT INT TERM + +LINUX_NAME="" +LINUX_SIZE="" +LINUX_SHA="" +WINDOWS_NAME="" +WINDOWS_SIZE="" +WINDOWS_SHA="" + +case ",$REQUIRE," in + *,linux,*) + APPIMAGE_DIR="$TAURI_DIR/target/release/bundle/appimage" + APPIMAGE_COUNT="$(find "$APPIMAGE_DIR" -maxdepth 1 -name '*.AppImage' 2>/dev/null | wc -l | tr -d ' ')" + if [ "$APPIMAGE_COUNT" -ne 1 ]; then + echo "Erwartet genau eine .AppImage-Datei in $APPIMAGE_DIR, gefunden: $APPIMAGE_COUNT" >&2 + exit 1 + fi + APPIMAGE_SRC="$(find "$APPIMAGE_DIR" -maxdepth 1 -name '*.AppImage')" + LINUX_NAME="Tessera-${VERSION}${SUFFIX}.AppImage" + 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)" + ;; +esac + +case ",$REQUIRE," in + *,windows,*) + NSIS_DIR="$TAURI_DIR/target/x86_64-pc-windows-msvc/release/bundle/nsis" + NSIS_COUNT="$(find "$NSIS_DIR" -maxdepth 1 -name '*.exe' 2>/dev/null | wc -l | tr -d ' ')" + if [ "$NSIS_COUNT" -ne 1 ]; then + echo "Erwartet genau eine .exe-Datei in $NSIS_DIR, gefunden: $NSIS_COUNT" >&2 + exit 1 + fi + NSIS_SRC="$(find "$NSIS_DIR" -maxdepth 1 -name '*.exe')" + WINDOWS_NAME="Tessera-Setup-${VERSION}${SUFFIX}.exe" + 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)" + ;; +esac + +# manifest.json ausschliesslich ueber jq -n mit --arg/--argjson bauen (kein +# manuelles String-Zusammenbauen von JSON). +jq -n \ + --arg version "$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 windowsName "$WINDOWS_NAME" \ + --argjson windowsSize "${WINDOWS_SIZE:-null}" \ + --arg windowsSha "$WINDOWS_SHA" \ + '{ + version: $version, + 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) + ) + }' > "$DESKTOP_DIST/manifest.json" + +echo "Manifest geschrieben: $DESKTOP_DIST/manifest.json (Version $VERSION, Kanal $CHANNEL)" diff --git a/.gitignore b/.gitignore index e2e09ea..d7f9d0d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ user-files/ # GSD runtime scratch (Dispatch-Sentinel, pro Sitzung neu geschrieben) .gsd/ + +# Desktop-Pakete aus dem Bau (Phase 18) +desktop-dist/* +!desktop-dist/.gitkeep diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 592f314..8c830d8 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -47,6 +47,10 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_ COPY --from=builder /app/apps/api/prisma ./apps/api/prisma COPY --from=builder /app/packages/shared/src ./packages/shared/src COPY apps/api/scripts ./apps/api/scripts +# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete + +# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur +# lesend zur Laufzeit -- kein chown noetig. +COPY desktop-dist ./desktop-dist USER nestjs EXPOSE 3001 CMD ["sh", "apps/api/scripts/migrate-and-start.sh"] diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index fbf3756..e41c428 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { BugReportsModule } from './bug-reports/bug-reports.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; import { ForcePasswordChangeInterceptor } from './auth/interceptors/force-password-change.interceptor'; +import { DesktopModule } from './desktop/desktop.module'; import { HealthModule } from './health/health.module'; import { LdapModule } from './ldap/ldap.module'; import { MailModule } from './mail/mail.module'; @@ -36,6 +37,7 @@ import { UserModule } from './user/user.module'; UserModule, TenantModule, HealthModule, + DesktopModule, MailModule, LdapModule, ModuleRegistryModule, diff --git a/apps/api/src/desktop/desktop.controller.ts b/apps/api/src/desktop/desktop.controller.ts new file mode 100644 index 0000000..22ccae5 --- /dev/null +++ b/apps/api/src/desktop/desktop.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Inject, Param, StreamableFile } from '@nestjs/common'; +import type { DesktopLatestResponse } from '@tessera/shared'; +import { Public } from '../auth/decorators/public.decorator'; +import { DesktopService } from './desktop.service'; + +@Controller('desktop') +export class DesktopController { + // `@Inject()` explizit (nicht nur der Konstruktor-Typ): Vitest transpiliert + // ueber esbuild, das `emitDecoratorMetadata` nicht respektiert -- ohne den + // expliziten Token findet Nests DI in diesem einen HTTP-Durchstich-Test + // (desktop.service.spec.ts) keinen Provider und `desktopService` bleibt + // `undefined`. Im echten Build (tsc via `nest build`) waere das auch ohne + // `@Inject()` korrekt aufgeloest worden. + constructor(@Inject(DesktopService) private readonly desktopService: DesktopService) {} + + // Bewusst oeffentlich (D-10, gleicher Grund wie HealthController.getVersion, + // T-KU1-03): die Anmeldeseite zeigt den Download-Link, bevor eine Anmeldung + // existiert. + @Public() + @Get('latest') + getLatest(): DesktopLatestResponse { + return this.desktopService.getLatest(); + } + + // Bewusst oeffentlich (D-10): der Download selbst braucht keine Anmeldung, + // gleicher Grund wie getLatest oben. + @Public() + @Get('download/:platform') + download(@Param('platform') platform: string): StreamableFile { + const { stream, entry } = this.desktopService.getPackage(platform); + return new StreamableFile(stream, { + type: 'application/octet-stream', + disposition: `attachment; filename="${entry.name}"`, + length: entry.size, + }); + } +} diff --git a/apps/api/src/desktop/desktop.module.ts b/apps/api/src/desktop/desktop.module.ts new file mode 100644 index 0000000..9841555 --- /dev/null +++ b/apps/api/src/desktop/desktop.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DesktopController } from './desktop.controller'; +import { DesktopService } from './desktop.service'; + +@Module({ + controllers: [DesktopController], + providers: [DesktopService], +}) +export class DesktopModule {} diff --git a/apps/api/src/desktop/desktop.service.spec.ts b/apps/api/src/desktop/desktop.service.spec.ts new file mode 100644 index 0000000..26a3a2e --- /dev/null +++ b/apps/api/src/desktop/desktop.service.spec.ts @@ -0,0 +1,168 @@ +import 'reflect-metadata'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { NestFactory } from '@nestjs/core'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +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'; + +/** + * DesktopService/DesktopController.spec — HTTP-Durchstich ueber + * NestFactory (Phase 18, Task 1). Kein `fs`-Mock: ein echtes + * Temp-Verzeichnis mit einer kleinen Zufallsdatei und einem von Hand + * geschriebenen manifest.json, dessen sha256 unabhaengig ueber + * crypto.createHash berechnet wird -- der Pruefling erzeugt den + * Erwartungswert nicht selbst. + * + * `DesktopService.getManifest()` liest manifest.json bei JEDEM Aufruf neu + * (kein Cache) -- writeManifest() darf die Datei deshalb zwischen Tests + * ueberschreiben, ohne den laufenden HTTP-Server neu zu starten. + */ + +const ORIGINAL_ENV = process.env.DESKTOP_DIST_DIR; + +let tempDir: string; +let app: Awaited>; +let baseUrl: string; + +const PACKAGE_NAME = 'test-package.bin'; +let packageSize: number; +let packageSha256: string; + +function writeManifest(files: Record) { + fs.writeFileSync( + path.join(tempDir, 'manifest.json'), + JSON.stringify({ + version: '1.1.0', + channel: 'dev', + commit: 'abc1234', + buildTime: '2026-09-16T00:00:00Z', + files, + }), + ); +} + +beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-')); + const packageBytes = crypto.randomBytes(64 * 1024); + fs.writeFileSync(path.join(tempDir, PACKAGE_NAME), packageBytes); + packageSize = packageBytes.length; + packageSha256 = crypto.createHash('sha256').update(packageBytes).digest('hex'); + + writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } }); + + process.env.DESKTOP_DIST_DIR = tempDir; + app = await NestFactory.create(DesktopModule, { logger: false }); + await app.listen(0); + const address = app.getHttpServer().address(); + const port = typeof address === 'object' && address ? address.port : 0; + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(async () => { + await app.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + if (ORIGINAL_ENV === undefined) { + delete process.env.DESKTOP_DIST_DIR; + } else { + process.env.DESKTOP_DIST_DIR = ORIGINAL_ENV; + } +}); + +afterEach(() => { + // Default-Manifest fuer den naechsten Test wiederherstellen (Tests 6/7 + // ueberschreiben es bewusst mit einer anderen Form). + writeManifest({ linux: { name: PACKAGE_NAME, size: packageSize, sha256: packageSha256 } }); +}); + +describe('DesktopService/DesktopController — HTTP-Durchstich (Phase 18)', () => { + it('Test 1 (latest, Manifest vorhanden): 200 mit Kopf-Feldern und relativer Download-URL', async () => { + const res = await fetch(`${baseUrl}/desktop/latest`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + version: '1.1.0', + channel: 'dev', + commit: 'abc1234', + buildTime: '2026-09-16T00:00:00Z', + files: { + linux: { + name: PACKAGE_NAME, + size: packageSize, + sha256: packageSha256, + url: '/desktop/download/linux', + }, + }, + }); + }); + + it('Test 2 (getLatest ohne Manifest): eigene Instanz mit leerem Temp-Verzeichnis wirft NotFoundException', () => { + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-desktop-empty-')); + const previous = process.env.DESKTOP_DIST_DIR; + process.env.DESKTOP_DIST_DIR = emptyDir; + try { + const service = new DesktopService(); + expect(() => service.getLatest()).toThrow(NotFoundException); + } finally { + process.env.DESKTOP_DIST_DIR = previous; + fs.rmSync(emptyDir, { recursive: true, force: true }); + } + }); + + it('Test 3 (download/linux): 200, attachment-Header und Body-Hash stimmen mit dem Manifest ueberein', async () => { + const res = await fetch(`${baseUrl}/desktop/download/linux`); + expect(res.status).toBe(200); + expect(res.headers.get('content-disposition')).toBe(`attachment; filename="${PACKAGE_NAME}"`); + expect(res.headers.get('content-type')).toBe('application/octet-stream'); + expect(res.headers.get('content-length')).toBe(String(packageSize)); + const buffer = Buffer.from(await res.arrayBuffer()); + const hash = crypto.createHash('sha256').update(buffer).digest('hex'); + expect(hash).toBe(packageSha256); + }); + + it('Test 4 (Plattform-Whitelist + Traversal ueber HTTP): mac und ..%2F..%2Fetc%2Fpasswd enden mit 400', async () => { + const resMac = await fetch(`${baseUrl}/desktop/download/mac`); + expect(resMac.status).toBe(400); + + const resTraversal = await fetch(`${baseUrl}/desktop/download/..%2F..%2Fetc%2Fpasswd`); + expect(resTraversal.status).toBe(400); + }); + + it('Test 5 (Whitelist vor Dateisystem): nicht existierendes Verzeichnis + mac wirft BadRequestException, nicht NotFoundException', () => { + const missingDir = path.join(os.tmpdir(), 'tessera-desktop-does-not-exist-' + Date.now()); + const previous = process.env.DESKTOP_DIST_DIR; + process.env.DESKTOP_DIST_DIR = missingDir; + try { + const service = new DesktopService(); + expect(() => service.getPackage('mac')).toThrow(BadRequestException); + } finally { + process.env.DESKTOP_DIST_DIR = previous; + } + }); + + it('Test 6 (Manifest nur mit windows): download/linux endet mit 404', async () => { + writeManifest({ + windows: { name: 'Tessera-Setup-1.1.0.exe', size: 123, sha256: 'a'.repeat(64) }, + }); + const res = await fetch(`${baseUrl}/desktop/download/linux`); + expect(res.status).toBe(404); + }); + + it('Test 7 (manipulierter Name im Manifest): "../x.AppImage" endet mit 404', async () => { + writeManifest({ + linux: { name: '../x.AppImage', size: 123, sha256: 'a'.repeat(64) }, + }); + const res = await fetch(`${baseUrl}/desktop/download/linux`); + expect(res.status).toBe(404); + }); + + it('Test 8 (bewusst oeffentlich): getLatest und download tragen @Public()', () => { + expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.getLatest)).toBe(true); + expect(Reflect.getMetadata(IS_PUBLIC_KEY, DesktopController.prototype.download)).toBe(true); + }); +}); diff --git a/apps/api/src/desktop/desktop.service.ts b/apps/api/src/desktop/desktop.service.ts new file mode 100644 index 0000000..3c8072d --- /dev/null +++ b/apps/api/src/desktop/desktop.service.ts @@ -0,0 +1,125 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { + DesktopLatestResponse, + DesktopManifest, + DesktopManifestFile, + DesktopPlatform, +} from '@tessera/shared'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Wertevorrat der Plattformen (Phase 18, D-10). Geschlossen -- eine dritte + * Plattform waere eine bewusste Erweiterung hier UND am Typ `DesktopPlatform` + * in packages/shared/src/index.ts. + */ +const PLATFORMS = ['windows', 'linux'] as const; + +@Injectable() +export class DesktopService { + private readonly logger = new Logger(DesktopService.name); + + /** Resolved path to desktop-dist/ (monorepo root, or /app/desktop-dist im Abbild). */ + private readonly desktopDistDir: string; + + constructor() { + const envDir = process.env.DESKTOP_DIST_DIR?.trim(); + this.desktopDistDir = + envDir && envDir.length > 0 + ? envDir + : // __dirname at runtime = apps/api/dist/desktop/ -- go up 4 levels to monorepo root + path.resolve(__dirname, '..', '..', '..', '..', 'desktop-dist'); + } + + /** + * Liest manifest.json. Gibt `null` zurueck (nie werfen) wenn die Datei + * fehlt, nicht parsebar ist, oder die Grundform nicht stimmt (version kein + * String, files kein Objekt) -- D-10: "fehlt das Verzeichnis/Manifest: 404 + * mit klarer Meldung". + */ + getManifest(): DesktopManifest | null { + const manifestPath = path.join(this.desktopDistDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + return null; + } + try { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + const parsed = JSON.parse(raw) as DesktopManifest; + if (typeof parsed.version !== 'string' || typeof parsed.files !== 'object' || parsed.files === null) { + this.logger.warn(`manifest.json unter ${manifestPath} hat unerwartete Form`); + return null; + } + return parsed; + } catch (error) { + this.logger.warn(`manifest.json unter ${manifestPath} konnte nicht gelesen werden: ${error}`); + return null; + } + } + + /** + * `GET /desktop/latest` (D-10): Kopf-Felder aus dem Manifest, je + * vorhandener Plattform eine relative Download-URL ergaenzt (Client stellt + * die API-Basis davor, siehe Objective-Abschnitt "Vom Client aus ..."). + */ + getLatest(): DesktopLatestResponse { + const manifest = this.getManifest(); + if (!manifest) { + throw new NotFoundException('Desktop packages are not available on this server'); + } + const files: DesktopLatestResponse['files'] = {}; + for (const platform of PLATFORMS) { + const entry = manifest.files[platform]; + if (entry) { + files[platform] = { ...entry, url: `/desktop/download/${platform}` }; + } + } + return { + version: manifest.version, + channel: manifest.channel, + commit: manifest.commit, + buildTime: manifest.buildTime, + files, + }; + } + + /** + * `GET /desktop/download/:platform` (D-10, T-18-01): Reihenfolge ist die + * Sicherheitseigenschaft -- Whitelist VOR jedem Dateisystemzugriff, der + * Dateiname kommt ausschliesslich aus manifest.json, nie aus der Anfrage. + */ + getPackage(platform: string): { stream: fs.ReadStream; entry: DesktopManifestFile } { + // (1) Whitelist -- vor jedem Dateisystemzugriff. + if (!PLATFORMS.includes(platform as DesktopPlatform)) { + throw new BadRequestException('Unknown platform'); + } + const knownPlatform = platform as DesktopPlatform; + + // (2) Manifest holen. + const manifest = this.getManifest(); + if (!manifest) { + throw new NotFoundException('Desktop packages are not available on this server'); + } + + // (3) Eintrag fuer diese Plattform muss existieren. + const entry = manifest.files[knownPlatform]; + if (!entry) { + throw new NotFoundException(`No package for platform: ${knownPlatform}`); + } + + // (4) Verteidigung in der Tiefe (T-18-02): auch ein manipuliertes + // Manifest darf nicht aus dem Ordner hinausfuehren. + if (!/^[A-Za-z0-9._-]+$/.test(entry.name)) { + throw new NotFoundException(`No package for platform: ${knownPlatform}`); + } + + // (5) Datei muss existieren. + const filePath = path.join(this.desktopDistDir, entry.name); + if (!fs.existsSync(filePath)) { + throw new NotFoundException(`Package file missing: ${entry.name}`); + } + + // (6) Stream zurueckgeben -- kein Puffern der ganzen Datei (Installer + // sind deutlich groesser als DKV-Exporte). + return { stream: fs.createReadStream(filePath), entry }; + } +} diff --git a/desktop-dist/.gitkeep b/desktop-dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 30444cc..941d8d8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -17,3 +17,37 @@ export interface VersionResponse { commit: string; buildTime: string; } + +/** + * Desktop-Pakete (Phase 18, D-08): Quelle ist ausschliesslich `manifest.json`, + * geschrieben nur von `.gitea/scripts/desktop-collect.sh` im CI. `platform` ist + * ein geschlossener Wertevorrat -- eine dritte Plattform waere eine bewusste + * Erweiterung hier und an der Konstante `PLATFORMS` im API-Dienst. + */ +export type DesktopPlatform = 'windows' | 'linux'; + +export interface DesktopManifestFile { + name: string; + size: number; + sha256: string; +} + +export interface DesktopManifest { + version: string; + channel: string; + commit: string; + buildTime: string; + files: Partial>; +} + +export interface DesktopLatestFile extends DesktopManifestFile { + url: string; +} + +export interface DesktopLatestResponse { + version: string; + channel: string; + commit: string; + buildTime: string; + files: Partial>; +}