Compare commits
22
Commits
main
..
f04d63dd7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f04d63dd7b | ||
|
|
b58cb75cfe | ||
|
|
b7ce114677 | ||
|
|
6fbea4fe6f | ||
|
|
2b695d6892 | ||
|
|
124a90dc07 | ||
|
|
d6947c2576 | ||
|
|
0646fbeebe | ||
|
|
6fd046417a | ||
|
|
f7b78fd5db | ||
|
|
9ee717bbc7 | ||
|
|
ac18ff7a16 | ||
|
|
73da8f0140 | ||
|
|
a2a371b7d9 | ||
|
|
786a892658 | ||
|
|
ec8fe187fe | ||
|
|
31880e1005 | ||
|
|
bce4c7862f | ||
|
|
465a88e4e5 | ||
|
|
c4ca218d97 | ||
|
|
9d009bfcba | ||
|
|
3c6f4dd67c |
@@ -1,273 +0,0 @@
|
|||||||
name: Release Desktop Client
|
|
||||||
|
|
||||||
# Baut den Tauri-Client aus crates/tauri-app fuer Windows und Linux, laedt die
|
|
||||||
# Bundles als Job-Artefakte hoch und haengt sie an ein Gitea-Release zum Tag.
|
|
||||||
#
|
|
||||||
# ACHTUNG — Verzeichnis-Falle (hat im Nachbarprojekt lserver einen Tag CI
|
|
||||||
# gekostet): Sobald `.gitea/workflows/` existiert, ignoriert Gitea
|
|
||||||
# `.github/workflows/` KOMPLETT. In diesem Repo gibt es kein `.github/`, also
|
|
||||||
# ist heute nichts betroffen — wer aber spaeter einen Workflow unter
|
|
||||||
# `.github/workflows/` anlegt, bekommt keinen Lauf und auch keinen roten
|
|
||||||
# Fehler, sondern schlicht Stille. Alle Workflows gehoeren hierher.
|
|
||||||
#
|
|
||||||
# VOR DEM TAG VERSION BUMPEN (analog zu lserver, wo package.json gebumpt wird):
|
|
||||||
# Die Version im Release kommt aus dem Tag, die Version IM Artefaktnamen aus
|
|
||||||
# der Config. Beide muessen zusammenpassen, sonst heisst die Datei zu einem
|
|
||||||
# Tag v0.2.0 weiterhin `maarcadetweet_0.1.0_x64-setup.exe`:
|
|
||||||
# * crates/tauri-app/src-tauri/tauri.conf.json -> "version"
|
|
||||||
# * crates/tauri-app/src-tauri/Cargo.toml -> [package] version
|
|
||||||
# * crates/tauri-app/package.json -> "version" (Konsistenz)
|
|
||||||
# Details: docs/tauri-release.md, Abschnitt "Release-Checkliste".
|
|
||||||
#
|
|
||||||
# macOS: DAFUER GIBT ES KEINEN RUNNER. Weder Gitea-Instanz noch Infrastruktur
|
|
||||||
# haben einen macOS-Host; .dmg/.app werden lokal gebaut und von Hand an das
|
|
||||||
# hier erzeugte Release gehaengt:
|
|
||||||
# cd crates/tauri-app && npm ci && npm run tauri -- build --ci
|
|
||||||
# # Artefakte: src-tauri/target/release/bundle/dmg/*.dmg und macos/*.app
|
|
||||||
# # Universal-Build: npm run tauri -- build --ci --target universal-apple-darwin
|
|
||||||
# Danach im Gitea-Release "Edit release" -> Dateien anhaengen. Ohne
|
|
||||||
# Notarisierung meldet Gatekeeper die App als nicht verifiziert (siehe
|
|
||||||
# docs/tauri-release.md, Abschnitt 9 "Offene Punkte").
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
windows:
|
|
||||||
name: Windows (MSI + NSIS)
|
|
||||||
# Label `windows` = act_runner auf der Build-VM winbuild (10.10.2.131, Server-Netz),
|
|
||||||
# Win11, cargo 1.98.1, Node 24, Tauri-CLI 2.11.4. Laut Infrastruktur-Doku
|
|
||||||
# on-demand — laeuft die VM nicht, wird der Job nie geplant (Gitea zeigt
|
|
||||||
# dann gar keinen Lauf an, keinen fehlgeschlagenen).
|
|
||||||
runs-on: windows
|
|
||||||
timeout-minutes: 60
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: powershell
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# package-lock.json liegt unter crates/tauri-app/ -> npm ci (reproduzierbar).
|
|
||||||
# Kein `npm install`: das wuerde den Lock im Build veraendern.
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: crates/tauri-app
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
# package.json definiert `"tauri": "tauri"` — der npm-Umweg nutzt die
|
|
||||||
# @tauri-apps/cli-devDependency aus dem Lock (2.x) statt einer global
|
|
||||||
# installierten `cargo tauri`-Version, ist also an das Repo gebunden.
|
|
||||||
# `cargo tauri build --ci` waere gleichwertig, haengt aber an dem, was
|
|
||||||
# gerade auf dem Runner installiert ist.
|
|
||||||
# bundle.targets in tauri.conf.json steht auf "all" -> unter Windows
|
|
||||||
# heisst das msi + nsis.
|
|
||||||
- name: Build Tauri bundles
|
|
||||||
working-directory: crates/tauri-app
|
|
||||||
run: npm run tauri -- build --ci
|
|
||||||
|
|
||||||
- name: Bundles auflisten
|
|
||||||
working-directory: crates/tauri-app
|
|
||||||
run: |
|
|
||||||
$bundle = "src-tauri\target\release\bundle"
|
|
||||||
Get-ChildItem -Path $bundle -Recurse -Include *.msi, *.exe |
|
|
||||||
ForEach-Object { Write-Host "$($_.FullName) ($([math]::Round($_.Length / 1MB)) MB)" }
|
|
||||||
|
|
||||||
- name: Upload bundles
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: maarcadetweet-windows
|
|
||||||
path: |
|
|
||||||
crates/tauri-app/src-tauri/target/release/bundle/msi/*.msi
|
|
||||||
crates/tauri-app/src-tauri/target/release/bundle/nsis/*.exe
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Gitea-Release anlegen und Bundles anhaengen
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
$api = "$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY"
|
|
||||||
$tag = $env:GITHUB_REF_NAME
|
|
||||||
$auth = "Authorization: token $env:GITEA_TOKEN"
|
|
||||||
|
|
||||||
# Release-Notes aus dem passenden CHANGELOG-Abschnitt ziehen.
|
|
||||||
# CHANGELOG.md existiert in diesem Repo noch nicht — sobald es
|
|
||||||
# angelegt wird (Format `## [0.2.0] - ...` wie bei lserver), landet
|
|
||||||
# der Abschnitt automatisch im Release.
|
|
||||||
$notes = "Automatisch gebaut aus $env:GITHUB_SHA."
|
|
||||||
$version = $tag.TrimStart("v")
|
|
||||||
if (Test-Path CHANGELOG.md) {
|
|
||||||
# Ausdruecklich als UTF-8 lesen: Windows PowerShell 5.1 nimmt sonst
|
|
||||||
# die ANSI-Codepage und macht aus "Aenderungen" Buchstabensalat.
|
|
||||||
$lines = [IO.File]::ReadAllText((Resolve-Path CHANGELOG.md), [Text.UTF8Encoding]::new($false)) -split "`r?`n"
|
|
||||||
$start = ($lines | Select-String -Pattern "^## \[$([regex]::Escape($version))\]" | Select-Object -First 1)
|
|
||||||
if ($start) {
|
|
||||||
$from = $start.LineNumber
|
|
||||||
$rest = $lines[$from..($lines.Count - 1)]
|
|
||||||
$next = ($rest | Select-String -Pattern "^## \[" | Select-Object -First 1)
|
|
||||||
$take = if ($next) { $next.LineNumber - 2 } else { $rest.Count - 1 }
|
|
||||||
if ($take -ge 0) { $notes = ($rest[0..$take] -join "`n").Trim() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$body = @{ tag_name = $tag; name = "maarcadetweet $tag"; body = $notes } | ConvertTo-Json -Depth 3
|
|
||||||
$bodyFile = Join-Path $env:RUNNER_TEMP "release.json"
|
|
||||||
[IO.File]::WriteAllText($bodyFile, $body, [Text.UTF8Encoding]::new($false))
|
|
||||||
|
|
||||||
# Beide Jobs (windows + linux) haengen an DASSELBE Release und laufen
|
|
||||||
# parallel. Deshalb: anlegen versuchen, und wenn das scheitert (der
|
|
||||||
# andere Job war schneller, oder es ist ein Re-Run), das vorhandene
|
|
||||||
# Release per Tag holen.
|
|
||||||
$created = curl.exe -s -X POST -H $auth -H "Content-Type: application/json" --data-binary "@$bodyFile" "$api/releases" | ConvertFrom-Json
|
|
||||||
if (-not $created.id) {
|
|
||||||
$created = curl.exe -s -H $auth "$api/releases/tags/$tag" | ConvertFrom-Json
|
|
||||||
}
|
|
||||||
if (-not $created.id) { throw "Konnte kein Release fuer $tag anlegen oder finden." }
|
|
||||||
|
|
||||||
$bundle = "crates\tauri-app\src-tauri\target\release\bundle"
|
|
||||||
$files = Get-ChildItem -Path $bundle -Recurse -Include *.msi, *.exe
|
|
||||||
if (-not $files) { throw "Keine Windows-Bundles unter $bundle gefunden." }
|
|
||||||
foreach ($f in $files) {
|
|
||||||
$name = [Uri]::EscapeDataString($f.Name)
|
|
||||||
curl.exe -s -o NUL -w "Asset-Upload $($f.Name): HTTP %{http_code}`n" -X POST -H $auth -F "attachment=@$($f.FullName)" "$api/releases/$($created.id)/assets?name=$name"
|
|
||||||
}
|
|
||||||
Write-Host "Release: $env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY/releases/tag/$tag"
|
|
||||||
|
|
||||||
linux:
|
|
||||||
name: Linux (deb + rpm + AppImage)
|
|
||||||
# NICHT `ubuntu-latest` — auf diesem Runner (VM ci-runner, 192.168.1.72)
|
|
||||||
# ist dieses Label auf `docker://node:22-bookworm` gemappt, also einen
|
|
||||||
# Container mit Node, aber ohne Rust und ohne GTK. Der Job braeche dort
|
|
||||||
# bei `cargo` ab. Nachgesehen in /var/lib/gitea-runner/.runner:
|
|
||||||
# labels: ['ubuntu-latest:docker://node:22-bookworm', 'linux-amd64:host']
|
|
||||||
# `linux-amd64` ist das Host-Label, und auf dem Host liegen Rust
|
|
||||||
# (/root/.cargo/bin, auch ohne Login-Shell im PATH), Node 22 und die
|
|
||||||
# Tauri-GTK-Deps. Der Runner-Dienst laeuft als root.
|
|
||||||
runs-on: linux-amd64
|
|
||||||
timeout-minutes: 60
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
# linuxdeploy/appimagetool werden als AppImage aus ~/.cache/tauri
|
|
||||||
# gestartet und brauchen sonst FUSE, was in der VM nicht zuverlaessig
|
|
||||||
# funktioniert (siehe MAARCADE-INFRASTRUKTUR.md: "AppImage scheitert an
|
|
||||||
# linuxdeploy/FUSE"). Mit dieser Variable entpacken sie sich selbst.
|
|
||||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
|
||||||
# Das Release-Profil in src-tauri/Cargo.toml strippt bereits selbst;
|
|
||||||
# linuxdeploys eigener strip-Lauf ist dann nur eine weitere Fehlerquelle.
|
|
||||||
NO_STRIP: "true"
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Toolchain melden
|
|
||||||
run: |
|
|
||||||
node --version
|
|
||||||
npm --version
|
|
||||||
cargo --version || echo "cargo fehlt im PATH — ggf. ~/.cargo/env sourcen"
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
working-directory: crates/tauri-app
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
# bundle.targets in tauri.conf.json ist "all" -> unter Linux sind das
|
|
||||||
# deb, rpm und appimage. Bewusst in zwei Aufrufe getrennt: der
|
|
||||||
# AppImage-Schritt ist der fragile (Downloads von linuxdeploy +
|
|
||||||
# appimagetool beim ersten Lauf, FUSE), und wenn er faellt, sollen deb
|
|
||||||
# und rpm trotzdem im Release landen. Der zweite Aufruf ist billig — der
|
|
||||||
# Cargo-Release-Build ist dann schon im target/-Cache.
|
|
||||||
- name: Build Tauri bundles (deb + rpm)
|
|
||||||
working-directory: crates/tauri-app
|
|
||||||
run: npm run tauri -- build --ci --bundles deb,rpm
|
|
||||||
|
|
||||||
- name: Build Tauri bundle (AppImage)
|
|
||||||
working-directory: crates/tauri-app
|
|
||||||
continue-on-error: true
|
|
||||||
run: npm run tauri -- build --ci --bundles appimage
|
|
||||||
|
|
||||||
- name: Bundles auflisten
|
|
||||||
run: |
|
|
||||||
find crates/tauri-app/src-tauri/target/release/bundle \
|
|
||||||
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
|
|
||||||
-printf '%p (%kK)\n' || true
|
|
||||||
|
|
||||||
- name: Upload bundles
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: maarcadetweet-linux
|
|
||||||
path: |
|
|
||||||
crates/tauri-app/src-tauri/target/release/bundle/deb/*.deb
|
|
||||||
crates/tauri-app/src-tauri/target/release/bundle/rpm/*.rpm
|
|
||||||
crates/tauri-app/src-tauri/target/release/bundle/appimage/*.AppImage
|
|
||||||
# AppImage darf fehlen (siehe continue-on-error oben), deb/rpm nicht —
|
|
||||||
# ein komplett leerer Upload soll auffallen.
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
- name: Gitea-Release anlegen und Bundles anhaengen
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
|
||||||
tag="${GITHUB_REF_NAME}"
|
|
||||||
version="${tag#v}"
|
|
||||||
|
|
||||||
# Gleiche CHANGELOG-Logik wie im Windows-Job. Die Datei existiert
|
|
||||||
# heute nicht; ohne sie bleibt es beim Fallback-Text.
|
|
||||||
notes="Automatisch gebaut aus ${GITHUB_SHA}."
|
|
||||||
if [ -f CHANGELOG.md ]; then
|
|
||||||
section="$(awk -v v="$version" '
|
|
||||||
/^## \[/ { if (found) exit; if ($0 ~ "^## \\[" v "\\]") { found = 1; next } }
|
|
||||||
found { print }
|
|
||||||
' CHANGELOG.md)"
|
|
||||||
if [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ]; then
|
|
||||||
notes="$section"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# jq ist auf dem Runner nicht garantiert, Node 22 schon.
|
|
||||||
payload="${RUNNER_TEMP}/release.json"
|
|
||||||
NOTES="$notes" TAG="$tag" node -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
fs.writeFileSync(process.argv[1], JSON.stringify({
|
|
||||||
tag_name: process.env.TAG,
|
|
||||||
name: `maarcadetweet ${process.env.TAG}`,
|
|
||||||
body: process.env.NOTES,
|
|
||||||
}));
|
|
||||||
' "$payload"
|
|
||||||
|
|
||||||
# Anlegen oder — falls der Windows-Job schneller war bzw. das Release
|
|
||||||
# vom Re-Run schon existiert — das vorhandene holen.
|
|
||||||
id="$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" --data-binary "@${payload}" \
|
|
||||||
"${api}/releases" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).id||""))}catch{}})')"
|
|
||||||
if [ -z "$id" ]; then
|
|
||||||
id="$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
"${api}/releases/tags/${tag}" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).id||""))}catch{}})')"
|
|
||||||
fi
|
|
||||||
if [ -z "$id" ]; then
|
|
||||||
echo "Konnte kein Release fuer ${tag} anlegen oder finden." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
bundle="crates/tauri-app/src-tauri/target/release/bundle"
|
|
||||||
found=0
|
|
||||||
while IFS= read -r f; do
|
|
||||||
found=1
|
|
||||||
name="$(basename "$f")"
|
|
||||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-F "attachment=@${f}" \
|
|
||||||
"${api}/releases/${id}/assets?name=${name}")"
|
|
||||||
echo "Asset-Upload ${name}: HTTP ${code}"
|
|
||||||
done < <(find "$bundle" \( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) | sort)
|
|
||||||
[ "$found" -eq 1 ] || { echo "Keine Linux-Bundles unter ${bundle} gefunden." >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "Release: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${tag}"
|
|
||||||
@@ -34,32 +34,15 @@ async fn pds_describe(state: tauri::State<'_, AppState>) -> Result<serde_json::V
|
|||||||
state.pds.describe_server().await.map_err(|e| e.to_string())
|
state.pds.describe_server().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `auth_register(handle, password, inviteCode?)` — create an account
|
|
||||||
/// on the configured PDS and store the resulting session.
|
|
||||||
///
|
|
||||||
/// `invite_code` arrives from the frontend as `inviteCode` (Tauri maps
|
|
||||||
/// camelCase JS argument keys onto snake_case Rust parameters, the same
|
|
||||||
/// way `mark_notifications_seen` receives `seenAt`). It is `Option`
|
|
||||||
/// because the invite gate is a *server* setting
|
|
||||||
/// (`PDS_INVITE_REQUIRED`): the public instance at
|
|
||||||
/// `https://tweet.maarcade.com` demands a code, a locally run dev PDS
|
|
||||||
/// usually does not, and the client has no business deciding which.
|
|
||||||
/// When no code is given the field is dropped from the request body
|
|
||||||
/// rather than sent empty — see [`pds_client::CreateAccountReq`].
|
|
||||||
///
|
|
||||||
/// A refused code surfaces as the PDS's `400
|
|
||||||
/// {"error":"InvalidInviteCode", …}` inside the stringified error, and
|
|
||||||
/// `errorMessage()` in `client.ts` turns that into German copy.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn auth_register(
|
async fn auth_register(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
handle: String,
|
handle: String,
|
||||||
password: String,
|
password: String,
|
||||||
invite_code: Option<String>,
|
|
||||||
) -> Result<AccountSession, String> {
|
) -> Result<AccountSession, String> {
|
||||||
let sess = state
|
let sess = state
|
||||||
.pds
|
.pds
|
||||||
.create_account(&handle, &password, invite_code.as_deref())
|
.create_account(&handle, &password)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let s = AccountSession {
|
let s = AccountSession {
|
||||||
@@ -785,74 +768,16 @@ async fn show_notification(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default PDS base URL — the public instance.
|
|
||||||
///
|
|
||||||
/// This is what a *shipped* build talks to. It used to be
|
|
||||||
/// `http://127.0.0.1:2583`, which meant a packaged `.app` handed to
|
|
||||||
/// anyone but the developer pointed at a server that does not exist on
|
|
||||||
/// their machine: every call failed with a connection error and the
|
|
||||||
/// login screen could not even render `describeServer`. A default is
|
|
||||||
/// the configuration of the people who never set one, so it has to be
|
|
||||||
/// the production deployment.
|
|
||||||
///
|
|
||||||
/// No trailing slash: [`PdsHttpClient`] builds its endpoints as
|
|
||||||
/// `{base}/xrpc/com.atproto.…`, so the base must end at the host.
|
|
||||||
const DEFAULT_PDS_URL: &str = "https://tweet.maarcade.com";
|
|
||||||
|
|
||||||
/// Default AppView base URL. Same host as the PDS — the reverse proxy
|
|
||||||
/// in front of `tweet.maarcade.com` routes by path prefix: `/xrpc/…`
|
|
||||||
/// to the PDS, `/api/…` to the AppView. [`AppViewClient`] appends
|
|
||||||
/// `/api/…` to this base (see the `format!("{}/api/…", self.base_url)`
|
|
||||||
/// calls in `appview_client.rs`), so the two clients can and must
|
|
||||||
/// share the one origin.
|
|
||||||
const DEFAULT_APPVIEW_URL: &str = "https://tweet.maarcade.com";
|
|
||||||
|
|
||||||
/// Resolve one base URL from its environment variable, falling back to
|
|
||||||
/// the compiled-in default.
|
|
||||||
///
|
|
||||||
/// **The environment always wins.** Development runs against a local
|
|
||||||
/// stack — `MAARCADETWEET_PDS_URL=http://127.0.0.1:2583` and
|
|
||||||
/// `MAARCADETWEET_APPVIEW_URL=http://127.0.0.1:2584`, which is what
|
|
||||||
/// `scripts/` and the dev docker-compose set up — and pointing the
|
|
||||||
/// desktop client at it must stay a matter of exporting two variables,
|
|
||||||
/// never of rebuilding. Only an *unset* variable takes the production
|
|
||||||
/// default.
|
|
||||||
///
|
|
||||||
/// A variable set to whitespace (or the empty string) counts as unset:
|
|
||||||
/// an empty base URL would silently produce request URLs like
|
|
||||||
/// `/xrpc/…` with no host, and `reqwest` would reject them as a
|
|
||||||
/// relative-URL error far away from the actual mistake. Trailing
|
|
||||||
/// slashes are trimmed because both clients append an absolute path to
|
|
||||||
/// this string, and `https://host//api/x` is not the same route to
|
|
||||||
/// every proxy.
|
|
||||||
///
|
|
||||||
/// Takes the already-performed lookup rather than the variable name so
|
|
||||||
/// it stays a pure function — testable without mutating the process
|
|
||||||
/// environment, and without a Tauri runtime.
|
|
||||||
fn base_url_or_default(from_env: Result<String, std::env::VarError>, default: &str) -> String {
|
|
||||||
let configured = from_env.ok();
|
|
||||||
let trimmed = configured
|
|
||||||
.as_deref()
|
|
||||||
.map(|v| v.trim().trim_end_matches('/'))
|
|
||||||
.filter(|v| !v.is_empty());
|
|
||||||
trimmed.unwrap_or(default).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let pds_url = base_url_or_default(
|
let pds_url = std::env::var("MAARCADETWEET_PDS_URL")
|
||||||
std::env::var("MAARCADETWEET_PDS_URL"),
|
.unwrap_or_else(|_| "http://127.0.0.1:2583".to_string());
|
||||||
DEFAULT_PDS_URL,
|
let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL")
|
||||||
);
|
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||||
let appview_url = base_url_or_default(
|
|
||||||
std::env::var("MAARCADETWEET_APPVIEW_URL"),
|
|
||||||
DEFAULT_APPVIEW_URL,
|
|
||||||
);
|
|
||||||
tracing::info!(%pds_url, %appview_url, "resolved backend base URLs");
|
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
pds: PdsHttpClient::new(pds_url.clone()),
|
pds: PdsHttpClient::new(pds_url.clone()),
|
||||||
@@ -1131,75 +1056,3 @@ async fn profile_set(
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::env::VarError;
|
|
||||||
|
|
||||||
/// The whole point of the change: a build with nothing configured
|
|
||||||
/// must talk to the public instance, not to a loopback port that
|
|
||||||
/// only exists on a developer's laptop. Pinned as a literal so a
|
|
||||||
/// well-meant "let's default back to localhost for dev" has to
|
|
||||||
/// argue with a red test first.
|
|
||||||
#[test]
|
|
||||||
fn unset_env_falls_back_to_the_public_instance() {
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Err(VarError::NotPresent), DEFAULT_PDS_URL),
|
|
||||||
"https://tweet.maarcade.com"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Err(VarError::NotPresent), DEFAULT_APPVIEW_URL),
|
|
||||||
"https://tweet.maarcade.com"
|
|
||||||
);
|
|
||||||
// Both services live behind the same origin — the proxy splits
|
|
||||||
// them by path prefix (`/xrpc/` vs `/api/`), which the clients
|
|
||||||
// append themselves.
|
|
||||||
assert_eq!(DEFAULT_PDS_URL, DEFAULT_APPVIEW_URL);
|
|
||||||
assert!(!DEFAULT_PDS_URL.ends_with('/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Development against a local stack has to keep working by
|
|
||||||
/// exporting a variable, so a set value always beats the default.
|
|
||||||
#[test]
|
|
||||||
fn env_var_overrides_the_default() {
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Ok("http://127.0.0.1:2583".into()), DEFAULT_PDS_URL),
|
|
||||||
"http://127.0.0.1:2583"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Ok("http://127.0.0.1:2584".into()), DEFAULT_APPVIEW_URL),
|
|
||||||
"http://127.0.0.1:2584"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An empty or whitespace-only variable is a misconfiguration, not
|
|
||||||
/// a request for an empty base URL: `reqwest` would answer the
|
|
||||||
/// resulting host-less URL with a relative-URL error nowhere near
|
|
||||||
/// the cause. Trailing slashes go because both clients append an
|
|
||||||
/// absolute path (`{base}/xrpc/…`, `{base}/api/…`).
|
|
||||||
#[test]
|
|
||||||
fn blank_env_is_ignored_and_trailing_slashes_are_trimmed() {
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Ok("".into()), DEFAULT_PDS_URL),
|
|
||||||
DEFAULT_PDS_URL
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Ok(" ".into()), DEFAULT_PDS_URL),
|
|
||||||
DEFAULT_PDS_URL
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Ok("http://127.0.0.1:2584/".into()), DEFAULT_APPVIEW_URL),
|
|
||||||
"http://127.0.0.1:2584"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Ok(" https://tweet.maarcade.com// ".into()), DEFAULT_PDS_URL),
|
|
||||||
"https://tweet.maarcade.com"
|
|
||||||
);
|
|
||||||
// A non-UTF-8 variable is as unusable as an unset one.
|
|
||||||
assert_eq!(
|
|
||||||
base_url_or_default(Err(VarError::NotUnicode("\u{fffd}".into())), DEFAULT_PDS_URL),
|
|
||||||
DEFAULT_PDS_URL
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,27 +26,6 @@ pub struct CreateAccountReq {
|
|||||||
pub handle: String,
|
pub handle: String,
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
/// Invite code, required by the PDS whenever it runs with
|
|
||||||
/// `PDS_INVITE_REQUIRED=true` (the public instance at
|
|
||||||
/// `https://tweet.maarcade.com` does). Rejected codes come back as
|
|
||||||
/// `400 {"error":"InvalidInviteCode", …}`.
|
|
||||||
///
|
|
||||||
/// **Wire name.** The server's `CreateAccountReq`
|
|
||||||
/// (`crates/pds-server/src/routes/types.rs`) is snake_case with an
|
|
||||||
/// `#[serde(alias = "inviteCode")]` for off-the-shelf atproto
|
|
||||||
/// clients. We are not one of those: every body this client sends
|
|
||||||
/// is snake_case (`refresh_jwt` in `refresh_session`, `handle` /
|
|
||||||
/// `password` right here), so `invite_code` is the field name that
|
|
||||||
/// matches the rest of the file. The alias exists for other people.
|
|
||||||
///
|
|
||||||
/// **Skipped when `None`.** Same reasoning as `validate` below —
|
|
||||||
/// a PDS with the invite gate *off* must keep accepting our
|
|
||||||
/// registrations, and sending `"invite_code": null` (let alone
|
|
||||||
/// `""`) would be claiming the user supplied something. Omitting
|
|
||||||
/// the key leaves the server's `Option` at `None`, which is exactly
|
|
||||||
/// "the user gave no code".
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub invite_code: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -151,31 +130,15 @@ impl PdsHttpClient {
|
|||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `com.atproto.server.createAccount`.
|
|
||||||
///
|
|
||||||
/// `invite_code` is `None` when the user left the field empty; the
|
|
||||||
/// key is then left out of the body entirely (see
|
|
||||||
/// [`CreateAccountReq::invite_code`]) so a PDS running without the
|
|
||||||
/// invite gate still registers the account. A code that only
|
|
||||||
/// contains whitespace is treated as absent for the same reason —
|
|
||||||
/// the server's `invite::normalize` trims before looking it up, so
|
|
||||||
/// `" "` could never match a real code anyway, and passing it on
|
|
||||||
/// would only turn "you forgot the field" into "your code is
|
|
||||||
/// wrong".
|
|
||||||
pub async fn create_account(
|
pub async fn create_account(
|
||||||
&self,
|
&self,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
invite_code: Option<&str>,
|
|
||||||
) -> Result<AccountSession> {
|
) -> Result<AccountSession> {
|
||||||
let body = CreateAccountReq {
|
let body = CreateAccountReq {
|
||||||
handle: handle.to_string(),
|
handle: handle.to_string(),
|
||||||
email: None,
|
email: None,
|
||||||
password: password.to_string(),
|
password: password.to_string(),
|
||||||
invite_code: invite_code
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|c| !c.is_empty())
|
|
||||||
.map(str::to_string),
|
|
||||||
};
|
};
|
||||||
let r = self
|
let r = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -545,23 +545,17 @@
|
|||||||
// Used in the Settings view to show which backends the client is
|
// Used in the Settings view to show which backends the client is
|
||||||
// talking to. Kept as plain helpers so they can be swapped for a
|
// talking to. Kept as plain helpers so they can be swapped for a
|
||||||
// `pds_describe`/`appview_describe` Tauri command later.
|
// `pds_describe`/`appview_describe` Tauri command later.
|
||||||
// The fallbacks mirror `DEFAULT_PDS_URL` / `DEFAULT_APPVIEW_URL` in
|
|
||||||
// `src-tauri/src/lib.rs`: both services sit behind the one public
|
|
||||||
// origin, split by path prefix (`/xrpc/` → PDS, `/api/` → AppView).
|
|
||||||
// If those constants ever move, move these with them — a Settings
|
|
||||||
// pane that names the wrong backend is worse than one that names
|
|
||||||
// none.
|
|
||||||
function pdsBase(): string {
|
function pdsBase(): string {
|
||||||
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
|
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
|
||||||
return (import.meta as any).env.VITE_PDS_URL as string;
|
return (import.meta as any).env.VITE_PDS_URL as string;
|
||||||
}
|
}
|
||||||
return "https://tweet.maarcade.com";
|
return "http://127.0.0.1:2583";
|
||||||
}
|
}
|
||||||
function appviewBase(): string {
|
function appviewBase(): string {
|
||||||
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
|
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
|
||||||
return (import.meta as any).env.VITE_APPVIEW_URL as string;
|
return (import.meta as any).env.VITE_APPVIEW_URL as string;
|
||||||
}
|
}
|
||||||
return "https://tweet.maarcade.com";
|
return "http://127.0.0.1:2584";
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -142,21 +142,6 @@ export function isAuthFailure(e: unknown): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the PDS refused a registration because of the invite
|
|
||||||
/// code. The public instance runs with `PDS_INVITE_REQUIRED=true` and
|
|
||||||
/// answers `400 {"error":"InvalidInviteCode","message":…}` — the same
|
|
||||||
/// code for a missing, misspelled, disabled and already-spent code, on
|
|
||||||
/// purpose: the server does not tell an unauthenticated caller which
|
|
||||||
/// of those it was, since that would make invite codes enumerable.
|
|
||||||
///
|
|
||||||
/// Matched on the string for the same reason as [`isTokenInvalid`]:
|
|
||||||
/// `pds_client.rs` bails with `createAccount failed: {status} {body}`
|
|
||||||
/// and `lib.rs` stringifies that into the command's `Err(String)`, so
|
|
||||||
/// the code travels verbatim across the IPC boundary.
|
|
||||||
function isInvalidInviteCode(e: unknown): boolean {
|
|
||||||
return errorText(e).includes("InvalidInviteCode");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User-facing copy for a failed call, in the app's German UI voice.
|
/// User-facing copy for a failed call, in the app's German UI voice.
|
||||||
///
|
///
|
||||||
/// An auth failure gets a sentence naming the actual remedy. The raw
|
/// An auth failure gets a sentence naming the actual remedy. The raw
|
||||||
@@ -170,18 +155,6 @@ export function errorMessage(e: unknown): string {
|
|||||||
if (isAuthFailure(e)) {
|
if (isAuthFailure(e)) {
|
||||||
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
|
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
|
||||||
}
|
}
|
||||||
if (isInvalidInviteCode(e)) {
|
|
||||||
// Deliberately covers "no code given" too: the raw body a user
|
|
||||||
// would otherwise read is `createAccount failed: 400 Bad Request
|
|
||||||
// {"error":"InvalidInviteCode","message":"a valid invite code is
|
|
||||||
// required to create an account on this server"}`. Since the
|
|
||||||
// server refuses to say *which* way the code was wrong, the copy
|
|
||||||
// names both plausible fixes rather than guessing one.
|
|
||||||
return (
|
|
||||||
"Einladungscode ungültig oder bereits verbraucht — " +
|
|
||||||
"bitte prüfen oder einen neuen Code anfordern."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return String(e);
|
return String(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,38 +208,11 @@ function createSessionStore() {
|
|||||||
set(s);
|
set(s);
|
||||||
return s;
|
return s;
|
||||||
},
|
},
|
||||||
/// Create an account on the configured PDS.
|
async register(handle: string, password: string) {
|
||||||
///
|
|
||||||
/// `inviteCode` is optional because the invite gate lives on the
|
|
||||||
/// *server* (`PDS_INVITE_REQUIRED`): the public instance at
|
|
||||||
/// `https://tweet.maarcade.com` requires a code, a local dev PDS
|
|
||||||
/// normally does not. The client therefore never refuses a
|
|
||||||
/// registration for a missing code on its own — it would break
|
|
||||||
/// development against localhost — it just forwards what the user
|
|
||||||
/// typed and lets the PDS decide.
|
|
||||||
///
|
|
||||||
/// An empty (or whitespace-only) field is *omitted*, not sent as
|
|
||||||
/// `""`. Those are two different statements: "I gave no code" vs.
|
|
||||||
/// "my code is the empty string". The first is legitimate against
|
|
||||||
/// an open server; the second is never true and would only turn
|
|
||||||
/// into a confusing `InvalidInviteCode` on a server that has the
|
|
||||||
/// gate switched off. Dropping the key leaves the Rust
|
|
||||||
/// `Option<String>` at `None`, and `create_account` then leaves
|
|
||||||
/// the field out of the JSON body entirely.
|
|
||||||
async register(handle: string, password: string, inviteCode?: string) {
|
|
||||||
if (!isTauri()) {
|
if (!isTauri()) {
|
||||||
throw new Error("register requires the Tauri desktop runtime");
|
throw new Error("register requires the Tauri desktop runtime");
|
||||||
}
|
}
|
||||||
const code = inviteCode?.trim();
|
const s = await safeInvoke<Session>("auth_register", { handle, password });
|
||||||
const s = await safeInvoke<Session>("auth_register", {
|
|
||||||
handle,
|
|
||||||
password,
|
|
||||||
// Explicit `null` rather than a dropped key, matching how
|
|
||||||
// `createPost` passes its optional `embed` / `reply`: it
|
|
||||||
// deserialises into the Rust `Option<String>` as `None`
|
|
||||||
// without depending on how `invoke` treats `undefined`.
|
|
||||||
inviteCode: code ? code : null,
|
|
||||||
});
|
|
||||||
set(s);
|
set(s);
|
||||||
return s;
|
return s;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
// Unit tests for the invite-code half of the registration path.
|
|
||||||
//
|
|
||||||
// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is
|
|
||||||
// mocked so no Tauri shell is needed, and every assertion is about the
|
|
||||||
// exact command name + argument bag we hand the Rust IPC layer. That
|
|
||||||
// argument bag is the contract — `invoke` maps camelCase JS keys onto
|
|
||||||
// the snake_case Rust command parameters (`inviteCode` → `invite_code`
|
|
||||||
// on `auth_register`), so a typo here surfaces at runtime as a null
|
|
||||||
// argument, not at compile time.
|
|
||||||
//
|
|
||||||
// Covered:
|
|
||||||
// * `session.register` — the code is forwarded as `inviteCode`,
|
|
||||||
// alongside the unchanged `handle` / `password`;
|
|
||||||
// * the empty / whitespace-only field — must reach the shell as
|
|
||||||
// `null` ("no code given"), never as `""` ("my code is the empty
|
|
||||||
// string"), because a PDS without the invite gate has to keep
|
|
||||||
// accepting registrations;
|
|
||||||
// * `errorMessage` — the PDS's `InvalidInviteCode` body becomes
|
|
||||||
// German copy instead of the raw wire string.
|
|
||||||
//
|
|
||||||
// Run with:
|
|
||||||
// npx vitest run src/lib/api/invite.test.ts
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const invokeMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/core", () => ({
|
|
||||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
|
||||||
isTauri: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
/// What the Rust `auth_register` command answers with on success.
|
|
||||||
const SESSION = {
|
|
||||||
did: "did:plc:alice",
|
|
||||||
handle: "alice.tweet.maarcade.com",
|
|
||||||
access_jwt: "acc",
|
|
||||||
refresh_jwt: "ref",
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
invokeMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("session.register", () => {
|
|
||||||
it("forwards the invite code as `inviteCode`", async () => {
|
|
||||||
const { session } = await import("./client");
|
|
||||||
invokeMock.mockResolvedValueOnce(SESSION);
|
|
||||||
|
|
||||||
const s = await session.register(
|
|
||||||
"alice.tweet.maarcade.com",
|
|
||||||
"hunter2hunter2",
|
|
||||||
"mt-7k3qw-z9d2m",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(invokeMock).toHaveBeenCalledWith("auth_register", {
|
|
||||||
handle: "alice.tweet.maarcade.com",
|
|
||||||
password: "hunter2hunter2",
|
|
||||||
inviteCode: "mt-7k3qw-z9d2m",
|
|
||||||
});
|
|
||||||
expect(s.did).toBe("did:plc:alice");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims the surrounding whitespace off a pasted code", async () => {
|
|
||||||
const { session } = await import("./client");
|
|
||||||
invokeMock.mockResolvedValueOnce(SESSION);
|
|
||||||
|
|
||||||
// Copying a code out of a chat message routinely drags a space or
|
|
||||||
// a newline along. The server trims too (`invite::normalize`), but
|
|
||||||
// sending the untrimmed string would mean the *client* and the
|
|
||||||
// server disagree about whether the field is empty.
|
|
||||||
await session.register("alice.test", "pw", " mt-7k3qw-z9d2m\n");
|
|
||||||
|
|
||||||
expect(invokeMock.mock.calls[0][1]).toMatchObject({
|
|
||||||
inviteCode: "mt-7k3qw-z9d2m",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends null — never an empty string — when the field is blank", async () => {
|
|
||||||
const { session } = await import("./client");
|
|
||||||
|
|
||||||
// Three ways the UI can hand us "nothing": the argument omitted
|
|
||||||
// entirely (login-shaped call), an untouched input, and an input
|
|
||||||
// holding only whitespace. All three mean "the user gave no code"
|
|
||||||
// and must arrive at the Rust `Option<String>` as `None`, so that
|
|
||||||
// a PDS running without `PDS_INVITE_REQUIRED` still registers the
|
|
||||||
// account instead of rejecting a blank code.
|
|
||||||
for (const blank of [undefined, "", " "]) {
|
|
||||||
invokeMock.mockReset();
|
|
||||||
invokeMock.mockResolvedValueOnce(SESSION);
|
|
||||||
|
|
||||||
await session.register("alice.test", "pw", blank);
|
|
||||||
|
|
||||||
const args = invokeMock.mock.calls[0][1] as Record<string, unknown>;
|
|
||||||
expect(args.inviteCode).toBeNull();
|
|
||||||
expect(args.inviteCode).not.toBe("");
|
|
||||||
// The rest of the bag is unaffected.
|
|
||||||
expect(args.handle).toBe("alice.test");
|
|
||||||
expect(args.password).toBe("pw");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("errorMessage for InvalidInviteCode", () => {
|
|
||||||
/// Exactly what crosses the IPC boundary when the PDS refuses the
|
|
||||||
/// code: `pds_client.rs` bails with `createAccount failed: {status}
|
|
||||||
/// {body}` and `lib.rs` stringifies that into `Err(String)`, which
|
|
||||||
/// `invoke` rejects with as a bare JS string.
|
|
||||||
const RAW =
|
|
||||||
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
|
|
||||||
'"message":"a valid invite code is required to create an account on this server"}';
|
|
||||||
|
|
||||||
it("replaces the raw 400 body with copy the user can act on", async () => {
|
|
||||||
const { errorMessage } = await import("./client");
|
|
||||||
const msg = errorMessage(RAW);
|
|
||||||
|
|
||||||
expect(msg).toContain("Einladungscode");
|
|
||||||
// None of the wire noise survives into the UI.
|
|
||||||
expect(msg).not.toContain("InvalidInviteCode");
|
|
||||||
expect(msg).not.toContain("400");
|
|
||||||
// Both shapes a rejected `invoke` can produce — a bare string and
|
|
||||||
// an Error — go through the same `errorText` normalisation.
|
|
||||||
expect(errorMessage(new Error(RAW))).toBe(msg);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves unrelated registration failures verbatim", async () => {
|
|
||||||
const { errorMessage } = await import("./client");
|
|
||||||
// A taken handle is a different 400 and has its own message worth
|
|
||||||
// showing; the invite branch must not swallow it.
|
|
||||||
expect(
|
|
||||||
errorMessage(
|
|
||||||
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
|
|
||||||
),
|
|
||||||
).toContain("HandleNotAvailable");
|
|
||||||
expect(errorMessage("createAccount failed: 500 db down")).toContain("500");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import {
|
import { session, describeServer, type Session } from "../api/client";
|
||||||
session,
|
|
||||||
describeServer,
|
|
||||||
errorMessage,
|
|
||||||
type Session,
|
|
||||||
} from "../api/client";
|
|
||||||
|
|
||||||
let { onLogin }: { onLogin: (s: Session) => void } = $props();
|
let { onLogin }: { onLogin: (s: Session) => void } = $props();
|
||||||
|
|
||||||
@@ -15,9 +10,6 @@
|
|||||||
let mode: "login" | "register" = $state("login");
|
let mode: "login" | "register" = $state("login");
|
||||||
let handle: string = $state("");
|
let handle: string = $state("");
|
||||||
let password: string = $state("");
|
let password: string = $state("");
|
||||||
// Only meaningful in "register" mode — the field below is rendered
|
|
||||||
// solely there, and `submit()` only forwards it on that branch.
|
|
||||||
let inviteCode: string = $state("");
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let serverInfo: any = $state(null);
|
let serverInfo: any = $state(null);
|
||||||
@@ -35,40 +27,16 @@
|
|||||||
busy = true;
|
busy = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
// The invite code is deliberately *not* part of the guard above.
|
|
||||||
// Whether one is required is a server setting
|
|
||||||
// (`PDS_INVITE_REQUIRED`): the public instance demands a code, a
|
|
||||||
// dev PDS on localhost usually does not. Refusing to submit
|
|
||||||
// without one would make the client unusable against the second
|
|
||||||
// kind of server for a rule it cannot see. So we forward what
|
|
||||||
// the user typed — `session.register` drops an empty string
|
|
||||||
// instead of sending a blank code — and let the PDS answer.
|
|
||||||
const s = mode === "register"
|
const s = mode === "register"
|
||||||
? await session.register(handle, password, inviteCode)
|
? await session.register(handle, password)
|
||||||
: await session.login(handle, password);
|
: await session.login(handle, password);
|
||||||
onLogin(s);
|
onLogin(s);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// `errorMessage` translates the failures worth naming — an
|
error = String(e);
|
||||||
// expired session, and a rejected `InvalidInviteCode` — into
|
|
||||||
// German copy, and passes everything else through verbatim.
|
|
||||||
error = errorMessage(e);
|
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the form's mode-specific state when switching sides.
|
|
||||||
///
|
|
||||||
/// Without this, a code typed while registering would linger in the
|
|
||||||
/// hidden field: switch to login, switch back, and the stale value
|
|
||||||
/// is silently submitted again. The error goes too — the message
|
|
||||||
/// from a failed registration says nothing about the login the user
|
|
||||||
/// is now attempting.
|
|
||||||
function toggleMode() {
|
|
||||||
mode = mode === "register" ? "login" : "register";
|
|
||||||
inviteCode = "";
|
|
||||||
error = null;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="login">
|
<div class="login">
|
||||||
@@ -107,30 +75,6 @@
|
|||||||
autocomplete={mode === "register" ? "new-password" : "current-password"}
|
autocomplete={mode === "register" ? "new-password" : "current-password"}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{#if mode === "register"}
|
|
||||||
<!--
|
|
||||||
Registration only. Logging in never carries a code, and a
|
|
||||||
field that is present but meaningless invites people to
|
|
||||||
fill it in. `{#if}` removes it from the DOM rather than
|
|
||||||
hiding it, so it also drops out of the tab order.
|
|
||||||
-->
|
|
||||||
<label class="field">
|
|
||||||
<span class="key">einladungscode</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={inviteCode}
|
|
||||||
placeholder="mt-xxxxx-xxxxx"
|
|
||||||
disabled={busy}
|
|
||||||
onkeydown={(e) => e.key === "Enter" && submit()}
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="none"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<span class="hint">
|
|
||||||
// von dieser Instanz verlangt — ohne Code keine Registrierung
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</form>
|
</form>
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="err">err: {error}</div>
|
<div class="err">err: {error}</div>
|
||||||
@@ -139,7 +83,7 @@
|
|||||||
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
<button class="btn btn--primary" onclick={submit} disabled={busy || !handle || !password}>
|
||||||
{busy ? "..." : mode === "register" ? "create account" : "log in"}
|
{busy ? "..." : mode === "register" ? "create account" : "log in"}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn--ghost" onclick={toggleMode} disabled={busy}>
|
<button class="btn btn--ghost" onclick={() => (mode = mode === "register" ? "login" : "register")} disabled={busy}>
|
||||||
{mode === "register" ? "have an account? log in" : "no account? register"}
|
{mode === "register" ? "have an account? log in" : "no account? register"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,15 +172,6 @@
|
|||||||
font-size: var(--fs-50);
|
font-size: var(--fs-50);
|
||||||
letter-spacing: var(--tracking-label);
|
letter-spacing: var(--tracking-label);
|
||||||
}
|
}
|
||||||
/* Sub-label under the invite field. Same dim mono voice as the
|
|
||||||
`// pds: …` server meta line above the form, so it reads as a
|
|
||||||
comment on the field rather than as a second input label. */
|
|
||||||
.hint {
|
|
||||||
color: var(--text-dim);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--fs-50);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
.form input {
|
.form input {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--line-2);
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
// Regression guard for the invite-code field on the login screen.
|
|
||||||
//
|
|
||||||
// The public instance runs the PDS with `PDS_INVITE_REQUIRED=true`, so
|
|
||||||
// `createAccount` without a code is refused with `400
|
|
||||||
// {"error":"InvalidInviteCode", …}`. Three things have to hold for the
|
|
||||||
// screen to be usable against it:
|
|
||||||
//
|
|
||||||
// 1. the field exists in "register" mode and *not* in "login" mode —
|
|
||||||
// a code is meaningless when signing in, and an input that is
|
|
||||||
// present but ignored invites people to fill it in;
|
|
||||||
// 2. what the user typed reaches `session.register` as its third
|
|
||||||
// argument, and an untouched field does not become a blank code;
|
|
||||||
// 3. a rejected code renders as German copy, not as the raw wire
|
|
||||||
// body, which is where the user would otherwise read
|
|
||||||
// `createAccount failed: 400 Bad Request {"error":…}`.
|
|
||||||
//
|
|
||||||
// Setup follows `NotificationsView.test.ts`: the component is mounted
|
|
||||||
// against jsdom with `../api/client` partially mocked — the real
|
|
||||||
// `errorMessage` is kept, since the error copy is part of what we are
|
|
||||||
// asserting on, and only the calls that would need a Tauri runtime are
|
|
||||||
// stubbed.
|
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { mount, unmount, tick } from "svelte";
|
|
||||||
|
|
||||||
const registerMock = vi.fn();
|
|
||||||
const loginMock = vi.fn();
|
|
||||||
const describeServerMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../api/client", async () => {
|
|
||||||
const actual =
|
|
||||||
await vi.importActual<typeof import("../api/client")>("../api/client");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
// Keep the real `errorMessage` from `actual` — the German copy for
|
|
||||||
// `InvalidInviteCode` is exactly what test 3 checks.
|
|
||||||
describeServer: (...args: unknown[]) => describeServerMock(...args),
|
|
||||||
session: {
|
|
||||||
...actual.session,
|
|
||||||
register: (...args: unknown[]) => registerMock(...args),
|
|
||||||
login: (...args: unknown[]) => loginMock(...args),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import LoginScreen from "./LoginScreen.svelte";
|
|
||||||
|
|
||||||
let target: HTMLDivElement;
|
|
||||||
let app: ReturnType<typeof mount> | null = null;
|
|
||||||
|
|
||||||
const SESSION = {
|
|
||||||
did: "did:plc:alice",
|
|
||||||
handle: "alice.tweet.maarcade.com",
|
|
||||||
access_jwt: "acc",
|
|
||||||
refresh_jwt: "ref",
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
target = document.createElement("div");
|
|
||||||
document.body.appendChild(target);
|
|
||||||
registerMock.mockReset();
|
|
||||||
loginMock.mockReset();
|
|
||||||
describeServerMock.mockReset();
|
|
||||||
// `onMount` calls this; a resolved stub keeps the meta line quiet.
|
|
||||||
describeServerMock.mockResolvedValue({ did: "did:web:tweet.maarcade.com" });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
if (app) unmount(app);
|
|
||||||
app = null;
|
|
||||||
target.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Let `onMount`, the mocked promises and Svelte's flush settle.
|
|
||||||
async function flush(turns = 6) {
|
|
||||||
for (let i = 0; i < turns; i++) {
|
|
||||||
await Promise.resolve();
|
|
||||||
await tick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The screen has no test ids; the inputs are addressed the way a user
|
|
||||||
/// would, by the label text beside them.
|
|
||||||
function fieldByLabel(label: string): HTMLInputElement | null {
|
|
||||||
for (const el of target.querySelectorAll("label.field")) {
|
|
||||||
if (el.querySelector(".key")?.textContent?.trim() === label) {
|
|
||||||
return el.querySelector("input");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeInto(input: HTMLInputElement, value: string) {
|
|
||||||
input.value = value;
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flip login ⇄ register via the ghost button under the form.
|
|
||||||
async function toggleMode() {
|
|
||||||
const buttons = [...target.querySelectorAll("button.btn--ghost")];
|
|
||||||
buttons[buttons.length - 1].dispatchEvent(
|
|
||||||
new MouseEvent("click", { bubbles: true }),
|
|
||||||
);
|
|
||||||
await tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitForm() {
|
|
||||||
target
|
|
||||||
.querySelector("button.btn--primary")!
|
|
||||||
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
||||||
await flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mountScreen() {
|
|
||||||
app = mount(LoginScreen, { target, props: { onLogin: vi.fn() } });
|
|
||||||
await flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("LoginScreen invite field", () => {
|
|
||||||
it("shows the code field only while registering", async () => {
|
|
||||||
await mountScreen();
|
|
||||||
|
|
||||||
// "login" is the default mode — no invite field, and nothing in
|
|
||||||
// the tab order either, since `{#if}` removes it from the DOM.
|
|
||||||
expect(fieldByLabel("einladungscode")).toBeNull();
|
|
||||||
|
|
||||||
await toggleMode();
|
|
||||||
expect(fieldByLabel("einladungscode")).not.toBeNull();
|
|
||||||
|
|
||||||
await toggleMode();
|
|
||||||
expect(fieldByLabel("einladungscode")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes the typed code to session.register as the third argument", async () => {
|
|
||||||
registerMock.mockResolvedValue(SESSION);
|
|
||||||
await mountScreen();
|
|
||||||
await toggleMode();
|
|
||||||
|
|
||||||
typeInto(fieldByLabel("handle")!, "alice.tweet.maarcade.com");
|
|
||||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
|
||||||
typeInto(fieldByLabel("einladungscode")!, "mt-7k3qw-z9d2m");
|
|
||||||
await submitForm();
|
|
||||||
|
|
||||||
expect(loginMock).not.toHaveBeenCalled();
|
|
||||||
expect(registerMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(registerMock).toHaveBeenCalledWith(
|
|
||||||
"alice.tweet.maarcade.com",
|
|
||||||
"hunter2hunter2",
|
|
||||||
"mt-7k3qw-z9d2m",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not turn an untouched field into a blank code", async () => {
|
|
||||||
registerMock.mockResolvedValue(SESSION);
|
|
||||||
await mountScreen();
|
|
||||||
await toggleMode();
|
|
||||||
|
|
||||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
|
||||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
|
||||||
// Invite field deliberately left alone. The submit must still go
|
|
||||||
// through: whether a code is required is the *server's* call
|
|
||||||
// (`PDS_INVITE_REQUIRED`), and a dev PDS on localhost runs without
|
|
||||||
// the gate. What must not happen is a blank code travelling on as
|
|
||||||
// if the user had entered one.
|
|
||||||
await submitForm();
|
|
||||||
|
|
||||||
expect(registerMock).toHaveBeenCalledTimes(1);
|
|
||||||
const code = registerMock.mock.calls[0][2];
|
|
||||||
expect(code === "" || code === undefined).toBe(true);
|
|
||||||
expect(code?.trim?.() ?? "").toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears a typed code when switching back to login", async () => {
|
|
||||||
loginMock.mockResolvedValue(SESSION);
|
|
||||||
await mountScreen();
|
|
||||||
await toggleMode();
|
|
||||||
|
|
||||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
|
||||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
|
||||||
typeInto(fieldByLabel("einladungscode")!, "mt-stale-code0");
|
|
||||||
|
|
||||||
// Back to login, then to register again: a stale code lingering in
|
|
||||||
// the hidden field would be submitted silently on the next try.
|
|
||||||
await toggleMode();
|
|
||||||
await toggleMode();
|
|
||||||
expect(fieldByLabel("einladungscode")!.value).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders German copy when the PDS rejects the code", async () => {
|
|
||||||
// Verbatim what reaches the component: `pds_client.rs` bails with
|
|
||||||
// `createAccount failed: {status} {body}`, `lib.rs` stringifies it
|
|
||||||
// into the command's `Err(String)`, and `invoke` rejects with that
|
|
||||||
// bare string.
|
|
||||||
registerMock.mockRejectedValue(
|
|
||||||
'createAccount failed: 400 Bad Request {"error":"InvalidInviteCode",' +
|
|
||||||
'"message":"a valid invite code is required to create an account on this server"}',
|
|
||||||
);
|
|
||||||
await mountScreen();
|
|
||||||
await toggleMode();
|
|
||||||
|
|
||||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
|
||||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
|
||||||
typeInto(fieldByLabel("einladungscode")!, "mt-wrong-code0");
|
|
||||||
await submitForm();
|
|
||||||
|
|
||||||
const err = target.querySelector(".err");
|
|
||||||
expect(err).not.toBeNull();
|
|
||||||
expect(err!.textContent).toContain("Einladungscode");
|
|
||||||
// The wire noise the user would otherwise be shown is gone.
|
|
||||||
expect(err!.textContent).not.toContain("InvalidInviteCode");
|
|
||||||
expect(err!.textContent).not.toContain("400 Bad Request");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still shows an unrelated failure verbatim", async () => {
|
|
||||||
// The invite branch must not swallow every registration error —
|
|
||||||
// a taken handle has its own message worth reading.
|
|
||||||
registerMock.mockRejectedValue(
|
|
||||||
'createAccount failed: 400 Bad Request {"error":"HandleNotAvailable"}',
|
|
||||||
);
|
|
||||||
await mountScreen();
|
|
||||||
await toggleMode();
|
|
||||||
|
|
||||||
typeInto(fieldByLabel("handle")!, "alice.test");
|
|
||||||
typeInto(fieldByLabel("password")!, "hunter2hunter2");
|
|
||||||
await submitForm();
|
|
||||||
|
|
||||||
expect(target.querySelector(".err")!.textContent).toContain(
|
|
||||||
"HandleNotAvailable",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+3
-47
@@ -296,57 +296,13 @@ beiden.
|
|||||||
5. Mit einer älteren installierten Version gegenprüfen, dass `check()` das
|
5. Mit einer älteren installierten Version gegenprüfen, dass `check()` das
|
||||||
Update findet und die Signaturprüfung durchgeht.
|
Update findet und die Signaturprüfung durchgeht.
|
||||||
|
|
||||||
## 8. CI: Tag-Release über Gitea Actions
|
## 8. Offene Punkte
|
||||||
|
|
||||||
`.gitea/workflows/release.yml` baut Schritt 2 der Checkliste für **Windows und
|
|
||||||
Linux** automatisch. Trigger ist ein Tag `v*.*.*` (zusätzlich manuell per
|
|
||||||
`workflow_dispatch`, dann ohne Release-Anlage).
|
|
||||||
|
|
||||||
| Job | `runs-on` | Runner | Bundles |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `windows` | `windows` | winbuild, 10.10.2.131 (on-demand) | `msi/*.msi`, `nsis/*-setup.exe` |
|
|
||||||
| `linux` | `ubuntu-latest` | ci-runner, 192.168.1.72 | `deb/*.deb`, `rpm/*.rpm`, `appimage/*.AppImage` |
|
|
||||||
|
|
||||||
Beide Jobs laufen `npm ci` (es gibt eine `package-lock.json`) und danach den
|
|
||||||
npm-Skript-Umweg `npm run tauri -- build --ci` aus `crates/tauri-app/`, damit
|
|
||||||
die im Lock gepinnte `@tauri-apps/cli` benutzt wird und nicht die zufällig auf
|
|
||||||
dem Runner installierte. Die Pfade sind die aus Abschnitt 5. Beide laden ihre
|
|
||||||
Bundles als Job-Artefakt hoch **und** hängen sie an dasselbe Gitea-Release zum
|
|
||||||
Tag (anlegen, und falls der andere Job schneller war, das vorhandene per Tag
|
|
||||||
holen). Die Release-Beschreibung kommt aus dem passenden
|
|
||||||
`## [<version>]`-Abschnitt einer `CHANGELOG.md`, sobald es eine gibt — bis
|
|
||||||
dahin steht dort der Commit-SHA.
|
|
||||||
|
|
||||||
Zwei Dinge, die der Workflow *nicht* tut:
|
|
||||||
|
|
||||||
* **macOS.** Es gibt keinen macOS-Runner. `.dmg`/`.app` werden lokal nach
|
|
||||||
Abschnitt 4 gebaut und im Gitea-Release von Hand angehängt.
|
|
||||||
* **Signierte Updater-Artefakte.** Der Workflow baut ohne Release-Overlay und
|
|
||||||
ohne `TAURI_SIGNING_PRIVATE_KEY*`; es entstehen also keine `.sig`-Dateien
|
|
||||||
(Abschnitt 2 und 5). Für ein echtes Auto-Update müssen Overlay-Datei und
|
|
||||||
Secrets ergänzt und der Build-Aufruf um
|
|
||||||
`--config src-tauri/tauri.release.conf.json` erweitert werden.
|
|
||||||
|
|
||||||
**Vor dem Tag zu bumpen** (Schritt 1 der Checkliste): Die Release-Version kommt
|
|
||||||
aus dem Tag, die Version im *Dateinamen* aus `src-tauri/tauri.conf.json`. Ohne
|
|
||||||
Bump heißt das Artefakt zu `v0.2.0` weiterhin
|
|
||||||
`maarcadetweet_0.1.0_x64-setup.exe`. `src-tauri/Cargo.toml` und `package.json`
|
|
||||||
mitziehen — alle drei stehen aktuell auf `0.1.0`.
|
|
||||||
|
|
||||||
> **Falle:** Sobald `.gitea/workflows/` existiert, ignoriert Gitea
|
|
||||||
> `.github/workflows/` vollständig — kommentarlos, ohne roten Lauf. Im
|
|
||||||
> Nachbarprojekt `lserver` waren Tests dadurch einen Tag lang still
|
|
||||||
> abgeschaltet. Dieses Repo hat kein `.github/`, und das soll so bleiben: neue
|
|
||||||
> Workflows gehören nach `.gitea/workflows/`.
|
|
||||||
|
|
||||||
## 9. Offene Punkte
|
|
||||||
|
|
||||||
* Kein Release-Overlay im Repo — die Datei aus Abschnitt 2 muss angelegt
|
* Kein Release-Overlay im Repo — die Datei aus Abschnitt 2 muss angelegt
|
||||||
werden. Der Endpoint `https://releases.maarcadetweet.local/…` in der aktuellen
|
werden. Der Endpoint `https://releases.maarcadetweet.local/…` in der aktuellen
|
||||||
Config ist ein Platzhalter und existiert nicht.
|
Config ist ein Platzhalter und existiert nicht.
|
||||||
* Kein Update-Server und kein Skript, das `latest.json` erzeugt (`scripts/` ist
|
* Kein Update-Server, kein CI-Workflow, kein Skript, das `latest.json` erzeugt
|
||||||
leer). Der CI-Workflow aus Abschnitt 8 baut und veröffentlicht Installer,
|
(`scripts/` ist leer).
|
||||||
aber keine Updater-Artefakte.
|
|
||||||
* Keine Code-Signierung/Notarisierung für macOS und keine Authenticode-Signatur
|
* Keine Code-Signierung/Notarisierung für macOS und keine Authenticode-Signatur
|
||||||
für Windows konfiguriert (`bundle` enthält weder `macOS.signingIdentity` noch
|
für Windows konfiguriert (`bundle` enthält weder `macOS.signingIdentity` noch
|
||||||
`windows.certificateThumbprint`). Der Tauri-Updater-Schlüssel ersetzt das
|
`windows.certificateThumbprint`). Der Tauri-Updater-Schlüssel ersetzt das
|
||||||
|
|||||||
Reference in New Issue
Block a user