Compare commits

...
12 Commits
Author SHA1 Message Date
haraldandClaude Opus 5.5 5cd45831e2 chore(release): bump version to 0.2.3
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 10m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 71f6a70829 fix(ci): Release-Signatur mit rsign2 statt minisign-CLI
Der Signaturschlüssel wurde mit `rsign generate -W` erzeugt und ist mit
leerem Passwort verschlüsselt; das minisign-CLI aus Ubuntu (0.11) verlangt
dafür ein Terminal und brach in der CI mit "Error while loading the secret
key file" ab.

- Workflow installiert rsign2 0.6.7 (ohne -D warnings) statt minisign
- build-release.sh signiert und verifiziert mit rsign; da rsign bei falscher
  Key-ID mit Exit-Code 0 endet, wird zusätzlich die Erfolgsmeldung geprüft
- README: Signierwerkzeug und Fallstrick dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:04:54 +02:00
haraldandClaude Opus 5.5 b751b71223 chore(release): bump version to 0.2.2
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 9m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:44:51 +02:00
haraldandClaude Opus 5.5 afb057692d fix(ci): rustfmt und clippy in der Release-Pipeline installieren
setup-rust-toolchain installiert mit --profile minimal; ohne explizite
components fehlen rustfmt und clippy, wodurch die Quality Gates bei jedem
Release-Lauf sofort abbrachen (cargo fmt --check nach 2 Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:42:58 +02:00
haraldandClaude Opus 5.5 eeae143dc6 chore(release): bump version to 0.2.1
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 1m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:35:10 +02:00
haraldandClaude Opus 5.5 992c3daad7 fix(ci): Formatierung, Root-fester I/O-Test und robuste Schlüsselsuche
- updater.rs: rustfmt-konforme Formatierung des öffentlichen Schlüssels
  (cargo fmt --check ließ die Release-Pipeline scheitern)
- storage.rs: test_io_error_* simuliert den I/O-Fehler über ein Verzeichnis
  statt chmod 000, damit er auch als root (CI-Runner) greift
- build-release.sh: findet den öffentlichen Schlüssel auch, wenn rustfmt ihn
  in die Folgezeile umbricht

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:30:52 +02:00
haraldandClaude Opus 5.5 9684238a53 chore(release): bump version to 0.2.0
Build & Publish Release / Build Packages & Publish to Gitea (push) Failing after 2m28s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:20:29 +02:00
haraldandClaude Opus 5.5 e8674e730f feat(updater): Minisign-Schlüssel für Release-Signaturen hinterlegen
Öffentlicher Schlüssel (Key-ID 3CFE290C190DA9D9). Der geheime Schlüssel wird
in der CI als Secret MINISIGN_SECRET_KEY erwartet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:10:57 +02:00
haraldandClaude Opus 5.5 7e99d59042 Merge fix/storage-datensicherheit in main
Konfliktlösung: Speicher-Architektur des Branches (Formatversionierung,
Papierkorb, Backups, Instance-Lock, robuster Autosave) übernommen und die
dort fehlenden Korrekturen aus main darauf übertragen:

- Autosave gleicht das Manifest bei jeder Änderung mit dem Notizbuch ab
  (mark_dirty); per Taste N/Bild-ab angelegte Seiten gingen sonst nach
  einem Neustart verloren
- Start öffnet keine beschädigten Notizbücher automatisch und verwendet den
  Ordner eines nicht ladbaren Notizbuchs nicht wieder
- PDF-Export lädt nicht geöffnete Seiten nach (load_all_pages), verweigert
  unvollständige Exporte und meldet das echte Ergebnis an die UI
- Cargo.toml: minisign-verify und fs2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 20:07:22 +02:00
haraldandClaude Opus 5.5 7ed971d2a2 feat(updater): Pflicht-Signaturprüfung der Releases mit Minisign
- SHA256SUMS.txt muss mit dem eingebauten Minisign-Schlüssel
  (UPDATE_SIGNING_PUBLIC_KEY) signiert sein; fehlende oder ungültige
  Signaturen und Versionen ohne Schlüssel lehnen Updates ab
- Prüfsummen und Signatur werden vor dem Paket geladen und geprüft
- build-release.sh signiert mit MINISIGN_SECRET_KEY und prüft, dass der
  Schlüssel zum eingebauten öffentlichen Schlüssel passt; in der CI ist die
  Signatur Pflicht
- publish-gitea-release.sh lädt SHA256SUMS.txt.minisig hoch
- README: Einrichtung des Schlüsselpaars dokumentiert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:49:25 +02:00
haraldandClaude Opus 5.5 5ce2c250bb fix(updater): HTTPS für alle Anfragen erzwingen
Bisher wurde nur die Server-URL geprüft. Download-Links aus der Release-API
und Weiterleitungsziele durften auch http:// sein, womit Paket und
SHA256SUMS.txt unverschlüsselt geladen werden konnten. Jetzt wird jede
Anfrage einzeln geprüft (allow_insecure_http bleibt als Ausnahme).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:45:16 +02:00
haraldandClaude Opus 5.5 d61e1819ed fix(storage): Datenverlust bei Ladefehlern, neuen Seiten und Löschen verhindern
- Beschädigte Seiten werden als page_<id>.json.corrupt-<zeit> gesichert statt
  leer überschrieben; bei I/O-Fehlern wird die Ersatzseite als load_failed
  markiert und nie gespeichert
- Autosave gleicht das Manifest (page_ids, Zähler) mit dem Notizbuch ab, damit
  neu angelegte Seiten nach einem Neustart nicht verloren gehen
- Start: nicht ladbares Notizbuch bleibt unverändert, stattdessen wird ein
  neues angelegt
- Aktuelles Notizbuch löschen: ausstehende Schreibvorgänge vorher abarbeiten,
  damit der Ordner nicht wieder angelegt wird
- PDF-Export lädt nicht geöffnete Seiten nach und verweigert unvollständige
  Exporte; Ergebnis wird per Kanal an die UI gemeldet
- Strg+S speichert alle geladenen Seiten und meldet Schreibfehler; flush()
  liefert Fehler zurück statt immer "Gespeichert"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 19:43:16 +02:00
12 changed files with 539 additions and 201 deletions

No files matched your search

+11 -1
View File
@@ -44,18 +44,28 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with: with:
toolchain: stable toolchain: stable
# Die Action installiert mit --profile minimal; ohne diese Komponenten
# schlagen cargo fmt und cargo clippy in den Quality Gates sofort fehl
components: rustfmt, clippy
cache: true cache: true
- name: Install rsign2 (Release-Signatur)
# RUSTFLAGS leeren: die Toolchain-Action setzt -D warnings, das soll nicht für rsign2 gelten
run: RUSTFLAGS="" cargo install rsign2 --locked --version 0.6.7
- name: Code Quality & Testing (Quality Gates) - name: Code Quality & Testing (Quality Gates)
run: | run: |
cargo fmt --check cargo fmt --check
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
cargo test cargo test
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt) - name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
run: | run: |
chmod +x scripts/*.sh chmod +x scripts/*.sh
./scripts/build-release.sh ./scripts/build-release.sh
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
INK_NOTES_REQUIRE_SIGNATURE: "1"
- name: Publish Release to Gitea - name: Publish Release to Gitea
run: | run: |
Generated
+8 -1
View File
@@ -1048,7 +1048,7 @@ dependencies = [
[[package]] [[package]]
name = "ink-notes" name = "ink-notes"
version = "0.1.1" version = "0.2.3"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"egui", "egui",
@@ -1056,6 +1056,7 @@ dependencies = [
"egui-winit", "egui-winit",
"fs2", "fs2",
"glam", "glam",
"minisign-verify",
"octotablet", "octotablet",
"pollster", "pollster",
"printpdf", "printpdf",
@@ -1304,6 +1305,12 @@ dependencies = [
"paste", "paste",
] ]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.9.1" version = "0.9.1"
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "ink-notes" name = "ink-notes"
version = "0.1.1" version = "0.2.3"
edition = "2021" edition = "2021"
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux" description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
repository = "https://gitea.pansi.eu/harald/ink-notes" repository = "https://gitea.pansi.eu/harald/ink-notes"
@@ -39,6 +39,7 @@ sha2 = "0.10"
ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] } ureq = { version = "2.10", default-features = false, features = ["tls", "gzip", "json"] }
url = "2.5" url = "2.5"
octotablet = "0.1.0" octotablet = "0.1.0"
minisign-verify = "0.3.0"
fs2 = "0.4" fs2 = "0.4"
[profile.dev] [profile.dev]
+25 -8
View File
@@ -71,7 +71,7 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü
- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog). - **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & PNG, AppStream Metainfo, Debian Copyright & Changelog).
- **Portables .AppImage**: Eigenständiges x86_64 Bundle. - **Portables .AppImage**: Eigenständiges x86_64 Bundle.
- **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, SHA-256-Prüfung und atomarer In-Place Austausch. - **In-App Updater (`src/updater.rs`)**: Automatischer Prüflauf gegen Gitea Releases, SemVer-Filterung, Minisign-Signaturprüfung, SHA-256-Prüfung und atomarer In-Place Austausch.
### 1. Release-Pakete bauen ### 1. Release-Pakete bauen
@@ -84,6 +84,23 @@ Ergebnis in `target/`:
- `ink-notes_0.1.0_amd64.deb` - `ink-notes_0.1.0_amd64.deb`
- `ink-notes_0.1.0_x86_64.AppImage` - `ink-notes_0.1.0_x86_64.AppImage`
- `SHA256SUMS.txt` - `SHA256SUMS.txt`
- `SHA256SUMS.txt.minisig` (nur mit gesetztem `MINISIGN_SECRET_KEY`, siehe unten)
### Release-Signatur einrichten (einmalig)
In-App-Updates werden nur mit gültiger Minisign-Signatur installiert. Signiert wird mit [rsign2](https://crates.io/crates/rsign2), einer Minisign-kompatiblen Implementierung (`cargo install rsign2 --locked`). Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
```bash
rsign generate -W -p ink-notes.pub -s ink-notes.key
```
> **Wichtig:** Schlüssel und Signierwerkzeug müssen zusammenpassen. `rsign generate -W` verschlüsselt den Schlüssel mit einem leeren Passwort; das `minisign`-CLI (z. B. Ubuntu-Paket 0.11) kann ihn ohne Terminal nicht laden. `build-release.sh` und die CI signieren deshalb mit `rsign`.
1. Die zweite Zeile aus `ink-notes.pub` (beginnt mit `RW`) in `src/updater.rs` als `UPDATE_SIGNING_PUBLIC_KEY` eintragen und committen.
2. Den kompletten Inhalt von `ink-notes.key` in Gitea unter *Repository → Einstellungen → Actions → Secrets* als `MINISIGN_SECRET_KEY` anlegen.
3. `ink-notes.key` sicher offline aufbewahren und vom Rechner löschen. Geht der Schlüssel verloren, können bestehende Installationen keine Updates mehr prüfen.
Lokal signiert `build-release.sh` nur, wenn `MINISIGN_SECRET_KEY` gesetzt und `rsign` installiert ist. In der CI ist die Signatur Pflicht (`INK_NOTES_REQUIRE_SIGNATURE=1`), und das Skript prüft, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt.
### 2. Installation auf Zorin OS 18 / Ubuntu 24.04 ### 2. Installation auf Zorin OS 18 / Ubuntu 24.04
@@ -123,15 +140,15 @@ export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien: Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
1. **SHA-256-Integritätsprüfung:** 1. **Minisign-Signatur (Pflicht):**
- Vor jeder Ausführung oder Installation wird die offizielle Prüfsummendatei `SHA256SUMS.txt` aus dem Release bezogen. - Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
- Der SHA-256-Hash des heruntergeladenen Pakets wird berechnet (`sha2`). - Der Updater lädt `SHA256SUMS.txt` und die Signatur **vor** dem Paket und prüft sie gegen den in die App eingebauten öffentlichen Schlüssel (`UPDATE_SIGNING_PUBLIC_KEY` in `src/updater.rs`, Crate `minisign-verify`).
- Fehlt die Signatur, ist sie ungültig oder ist kein Schlüssel eingebaut, wird das Update abgelehnt. Ein kompromittierter Release-Server kann so keine manipulierten Pakete ausliefern.
2. **SHA-256-Integritätsprüfung:**
- Der SHA-256-Hash des heruntergeladenen Pakets wird mit dem Eintrag in der signierten `SHA256SUMS.txt` verglichen (`sha2`).
- Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen. - Weicht die Prüfsumme ab oder fehlt ein Eintrag, wird die Datei sofort gelöscht und der Installationsvorgang mit Fehlermeldung abgebrochen.
- *Hinweis zur Authentizität:* Eine Prüfsumme vom selben Release-Server garantiert Schutz gegen Übertragungsfehler und Datei-Beschädigungen, jedoch keine kryptografische Nicht-Abstreitbarkeit gegenüber einem kompromittierten Server.
2. **Signatur-Ausblick:**
- Die Updater-Architektur enthält Schnittstellen für optionale `.sig`/`.minisig`-Signaturen zur zukünftigen End-to-End-Kryptoverifikation über öffentliche Schlüssel.
3. **Redirect-Schutz & HTTPS:** 3. **Redirect-Schutz & HTTPS:**
- Alle API- und Download-Endpunkte erzwingen standardmäßig HTTPS. - Jede Anfrage erzwingt HTTPS – auch Download-Links aus der Release-API und jedes Weiterleitungsziel.
- Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist. - Bei HTTP-Redirects werden sensible Autorisierungs-Header (`Authorization: token ...`) verworfen, sobald die Weiterleitung auf eine andere Origin oder ein externes CDN verweist.
4. **Isolierter Download-Pfad & Path-Traversal-Schutz:** 4. **Isolierter Download-Pfad & Path-Traversal-Schutz:**
- Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`). - Downloads werden in einem isolierten Benutzerverzeichnis abgelegt (`$XDG_CACHE_HOME/ink-notes/updates` bzw. `~/.cache/ink-notes/updates`, Berechtigung `0700`).
+3 -2
View File
@@ -7,8 +7,9 @@ Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18
- **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus - **Touchscreen Multi-Touch**: Stufenloses 2-Finger Pinch-to-Zoom und Panning mit umschaltbarem Finger-Zeichenmodus
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation) - **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold) - **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave - **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave, Papierkorb und fortlaufenden Backups
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases - **Datensicherheit**: Versioniertes Dateiformat mit Migration, Quarantäne beschädigter Seiten, Schutz vor Doppelöffnung und Crash-getestetem Speichern
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases – mit Pflicht-Signaturprüfung (Minisign) und durchgängigem HTTPS
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe - **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
### Installation: ### Installation:
+42 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================== # ==============================================================================
# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux) # Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux)
# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/ # Erstellt .deb, .AppImage, SHA256-Prüfsummen und deren Minisign-Signatur in target/
# ============================================================================== # ==============================================================================
set -euo pipefail set -euo pipefail
@@ -30,10 +30,51 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
cd "${ROOT_DIR}/target" cd "${ROOT_DIR}/target"
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}" sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
# 4. Prüfsummen signieren (Pflicht für In-App-Updates)
# Signiert wird mit rsign2 (minisign-kompatibles Format): Der Schlüssel wurde mit
# `rsign generate -W` erzeugt; das minisign-CLI kann ihn ohne Terminal nicht laden.
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
rm -f "${SIGNATURE_FILE}"
# Schlüssel kann von rustfmt in die Folgezeile umgebrochen sein
EMBEDDED_PUBKEY=$(grep -m1 -A1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | grep -o '"[^"]*"' | head -n1 | tr -d '"')
if [ -n "${MINISIGN_SECRET_KEY:-}" ]; then
if [ -z "${EMBEDDED_PUBKEY}" ]; then
echo "FEHLER: UPDATE_SIGNING_PUBLIC_KEY in src/updater.rs ist leer – diese Version könnte keine Updates prüfen."
exit 1
fi
if ! command -v rsign >/dev/null 2>&1; then
echo "FEHLER: rsign nicht gefunden (Installation: cargo install rsign2 --locked)."
exit 1
fi
echo "=== Signiere ${CHECKSUM_FILE} mit rsign2 ==="
KEY_FILE="$(mktemp)"
trap 'rm -f "${KEY_FILE}"' EXIT
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
rsign sign -W -s "${KEY_FILE}" -t "ink-notes v${VERSION}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}"
rm -f "${KEY_FILE}"
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
# rsign beendet sich bei falscher Key-ID mit Exit-Code 0, daher zusätzlich die Meldung prüfen
if ! VERIFY_OUT=$(rsign verify -P "${EMBEDDED_PUBKEY}" -x "${SIGNATURE_FILE}" "${CHECKSUM_FILE}" 2>&1) \
|| ! grep -q "Signature and comment signature verified" <<<"${VERIFY_OUT}"; then
echo "${VERIFY_OUT}"
echo "FEHLER: Signatur passt nicht zum eingebauten öffentlichen Schlüssel (UPDATE_SIGNING_PUBLIC_KEY)."
rm -f "${SIGNATURE_FILE}"
exit 1
fi
echo "${VERIFY_OUT}"
elif [ "${INK_NOTES_REQUIRE_SIGNATURE:-0}" = "1" ]; then
echo "FEHLER: MINISIGN_SECRET_KEY ist nicht gesetzt, eine Signatur ist aber Pflicht."
exit 1
else
echo "WARNUNG: MINISIGN_SECRET_KEY nicht gesetzt – ${CHECKSUM_FILE} bleibt unsigniert."
echo " Installierte Apps lehnen ein solches Release als Update ab."
fi
echo "======================================================================" echo "======================================================================"
echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:" echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
echo "======================================================================" echo "======================================================================"
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}" ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
echo "" echo ""
echo "Inhalt von target/${CHECKSUM_FILE}:" echo "Inhalt von target/${CHECKSUM_FILE}:"
cat "${CHECKSUM_FILE}" cat "${CHECKSUM_FILE}"
+9 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================== # ==============================================================================
# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu) # Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu)
# Lädt .deb, .AppImage und SHA256SUMS.txt idempotent als Release-Assets hoch. # Lädt .deb, .AppImage, SHA256SUMS.txt und SHA256SUMS.txt.minisig idempotent als Release-Assets hoch.
# ============================================================================== # ==============================================================================
set -euo pipefail set -euo pipefail
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb" DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage" APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt" CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl" TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
if [ ! -f "${TEMPLATE_FILE}" ]; then if [ ! -f "${TEMPLATE_FILE}" ]; then
@@ -46,6 +47,12 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
"${SCRIPT_DIR}/build-release.sh" "${SCRIPT_DIR}/build-release.sh"
fi fi
# Ohne Signatur lehnen installierte Apps das Release als Update ab
if [ ! -f "${SIGNATURE_FILE}" ]; then
echo "FEHLER: '${SIGNATURE_FILE}' fehlt. Release mit gesetztem MINISIGN_SECRET_KEY bauen."
exit 1
fi
echo "======================================================================" echo "======================================================================"
echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}" echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
echo "======================================================================" echo "======================================================================"
@@ -230,6 +237,7 @@ except Exception:
upload_asset "${DEB_FILE}" upload_asset "${DEB_FILE}"
upload_asset "${APPIMAGE_FILE}" upload_asset "${APPIMAGE_FILE}"
upload_asset "${CHECKSUMS_FILE}" upload_asset "${CHECKSUMS_FILE}"
upload_asset "${SIGNATURE_FILE}"
echo "======================================================================" echo "======================================================================"
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!" echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
+8
View File
@@ -330,6 +330,14 @@ impl AutosaveController {
} }
} }
/// Gleicht das Manifest (Seitenliste, aktuelle Seite, ID-Zähler) mit dem Notizbuch ab
/// und markiert die aktuelle Seite als geändert. So landen auch neu angelegte Seiten
/// im gespeicherten Manifest.
pub fn mark_dirty(&mut self, notebook: &Notebook, manifest: &mut NotebookManifest) {
manifest.sync_from_notebook(notebook);
self.mark_page_dirty(notebook.current_page(), manifest);
}
/// Markiert das Manifest als geändert (z. B. bei Seitenwechsel oder Umbenennen). /// Markiert das Manifest als geändert (z. B. bei Seitenwechsel oder Umbenennen).
pub fn mark_manifest_dirty(&mut self, manifest: &NotebookManifest) { pub fn mark_manifest_dirty(&mut self, manifest: &NotebookManifest) {
if self.read_only { if self.read_only {
+10
View File
@@ -139,6 +139,16 @@ pub fn generate_pdf_document(
if notebook.pages.is_empty() { if notebook.pages.is_empty() {
return Err("Notizbuch enthält keine Seiten".into()); return Err("Notizbuch enthält keine Seiten".into());
} }
// Nicht geladene Seiten würden sonst stillschweigend als leere Seiten exportiert
if let Some(page) = notebook.pages.iter().find(|p| {
!p.is_loaded || p.persist == crate::document::PersistState::ReadOnlyAfterLoadError
}) {
return Err(format!(
"Seite {} ist nicht geladen – Export abgebrochen (vorher storage::load_all_pages aufrufen)",
page.id
)
.into());
}
// 1. Erstes Dokument mit Seite 1 erstellen // 1. Erstes Dokument mit Seite 1 erstellen
let title = "ink-notes Notebook"; let title = "ink-notes Notebook";
+122 -111
View File
@@ -1,6 +1,8 @@
//! ink-notes: Latenzoptimierte Linux-Inking-App mit wgpu, winit, egui-Toolbar, //! ink-notes: Latenzoptimierte Linux-Inking-App mit wgpu, winit, egui-Toolbar,
//! Seitenverwaltung, Undo/Redo, Vektor-PDF-Export und asynchroner Notizbuch-Persistenz (Autosave). //! Seitenverwaltung, Undo/Redo, Vektor-PDF-Export und asynchroner Notizbuch-Persistenz (Autosave).
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Sender};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use winit::{ use winit::{
@@ -52,6 +54,44 @@ fn update_window_title(
window.set_title(&title); window.set_title(&title);
} }
/// Exportiert das Notizbuch im Hintergrund als PDF. Noch nicht geladene Seiten werden vorher
/// von der Festplatte nachgeladen; das Ergebnis geht über `result_tx` an den Event-Loop zurück.
fn start_pdf_export(
notebook: &Notebook,
storage_dir: &Path,
title: &str,
result_tx: Sender<Result<PathBuf, String>>,
window: Arc<Window>,
) {
let mut notebook_clone = notebook.clone();
let storage_dir = storage_dir.to_path_buf();
let export_path = export::resolve_pdf_export_path(title);
println!(
"[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...",
export_path.display()
);
std::thread::spawn(move || {
let start = Instant::now();
let result = storage::load_all_pages(&storage_dir, &mut notebook_clone)
.map_err(|err| err.to_string())
.and_then(|()| {
export_notebook_to_pdf(&notebook_clone, &export_path, ExportStrategy::FilledPolygon)
.map_err(|err| err.to_string())
});
match &result {
Ok(()) => println!(
"[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)",
export_path.display(),
start.elapsed(),
notebook_clone.pages.len()
),
Err(err) => eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}"),
}
let _ = result_tx.send(result.map(|()| export_path));
window.request_redraw();
});
}
/// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap) /// Finalisiert einen Strich (mit Spline-Tessellierung, Ecken-Erkennung & RDP oder Auto-Snap)
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein. /// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
fn commit_stroke_to_notebook( fn commit_stroke_to_notebook(
@@ -83,7 +123,7 @@ fn commit_stroke_to_notebook(
page.add_stroke(stroke); page.add_stroke(stroke);
canvas.sync_page(page); canvas.sync_page(page);
manifest.next_stroke_id = notebook.next_stroke_id; manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), manifest); autosave.mark_dirty(notebook, manifest);
update_window_title( update_window_title(
window, window,
notebook, notebook,
@@ -125,23 +165,35 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
let available_notebooks = storage::list_notebooks(&storage_base_dir); let available_notebooks = storage::list_notebooks(&storage_base_dir);
let (initial_id, initial_title) = if let Some(first) = available_notebooks.first() { // Beschädigte Notizbücher nicht automatisch öffnen
let (initial_id, initial_title) = if let Some(first) = available_notebooks
.iter()
.find(|s| s.status == storage::NotebookStatus::Normal)
{
(first.id.clone(), first.title.clone()) (first.id.clone(), first.title.clone())
} else { } else {
("default".to_string(), "Standard-Notizbuch".to_string()) ("default".to_string(), "Standard-Notizbuch".to_string())
}; };
let mut storage_dir = storage_base_dir.join(&initial_id); let mut storage_dir = storage_base_dir.join(&initial_id);
let mut startup_warning: Option<String> = None;
let (mut notebook, mut manifest) = if storage_dir.join("manifest.json").exists() { let (mut notebook, mut manifest) = if storage_dir.join("manifest.json").exists() {
println!("[Storage] Lade Notizbuch aus: {}", storage_dir.display()); println!("[Storage] Lade Notizbuch aus: {}", storage_dir.display());
match storage::load_notebook(&storage_dir) { match storage::load_notebook(&storage_dir) {
Ok((nb, m)) => (nb, m), Ok((nb, m)) => (nb, m),
Err(err) => { Err(err) => {
eprintln!("[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). Erstelle neues Notizbuch."); // Den Ordner des nicht ladbaren Notizbuchs NICHT wiederverwenden, sonst würde
( // der Autosave dessen Manifest überschreiben und alle Seiten verwaist zurücklassen.
Notebook::new(), eprintln!(
NotebookManifest::new(&initial_id, &initial_title), "[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). '{}' bleibt unverändert, erstelle neues Notizbuch.",
) storage_dir.display()
);
startup_warning = Some(format!(
"Notizbuch '{initial_title}' konnte nicht geladen werden – neues Notizbuch angelegt"
));
let (nb, m, dir) = storage::create_new_notebook(&storage_base_dir, &initial_title)?;
storage_dir = dir;
(nb, m)
} }
} }
} else { } else {
@@ -163,6 +215,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
egui_ctx.set_visuals(egui::Visuals::dark()); egui_ctx.set_visuals(egui::Visuals::dark());
// 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen // 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen
// Ergebnisse der PDF-Exporte aus dem Hintergrundthread
let (export_tx, export_rx) = mpsc::channel::<Result<PathBuf, String>>();
let mut updater = UpdaterController::new(); let mut updater = UpdaterController::new();
updater.check_for_updates(); updater.check_for_updates();
@@ -178,6 +233,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
ui_state.current_notebook_id = manifest.id.clone(); ui_state.current_notebook_id = manifest.id.clone();
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = available_notebooks; ui_state.available_notebooks = available_notebooks;
if let Some(warning) = startup_warning {
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
ui_state.set_export_message(warning);
}
let mut selection_state = SelectionState::new(); let mut selection_state = SelectionState::new();
let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) { let mut _notebook_lock = match storage::NotebookLock::acquire(&storage_dir) {
@@ -287,6 +346,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, new_status); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, new_status);
} }
while let Ok(result) = export_rx.try_recv() {
match result {
Ok(path) => ui_state.set_export_message(format!("PDF gespeichert: {}", path.display())),
Err(err) => ui_state.set_export_message(format!("PDF-Export fehlgeschlagen: {err}")),
}
window.request_redraw();
}
let prev_upd = updater.status().clone(); let prev_upd = updater.status().clone();
updater.update(); updater.update();
if *updater.status() != prev_upd { if *updater.status() != prev_upd {
@@ -369,10 +436,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty( autosave.mark_dirty(&notebook, &mut manifest);
notebook.current_page(),
&manifest,
);
update_window_title( update_window_title(
&window, &window,
&notebook, &notebook,
@@ -449,10 +513,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
); );
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty( autosave.mark_dirty(&notebook, &mut manifest);
notebook.current_page(),
&manifest,
);
update_window_title( update_window_title(
&window, &window,
&notebook, &notebook,
@@ -713,7 +774,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().undo() { if notebook.current_page_mut().undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -727,7 +788,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().redo() { if notebook.current_page_mut().redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -757,7 +818,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !new_ids.is_empty() { if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id; manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -769,7 +830,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = selection_state.delete_selected(notebook.current_page_mut()); let removed = selection_state.delete_selected(notebook.current_page_mut());
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -801,38 +862,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
// PDF-Export: Strg + E // PDF-Export: Strg + E
Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => { Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => {
let notebook_clone = notebook.clone(); start_pdf_export(&notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
let export_path = export::resolve_pdf_export_path(&manifest.title); ui_state.set_export_message("PDF-Export läuft…");
let export_path_str = export_path.display().to_string();
let window_ref = window.clone();
println!(
"[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...",
export_path_str
);
ui_state.set_export_message(format!("PDF gespeichert: {}", export_path.display()));
let export_path_thread = export_path.clone();
std::thread::spawn(move || {
let start = std::time::Instant::now();
match export_notebook_to_pdf(
&notebook_clone,
&export_path_thread,
ExportStrategy::FilledPolygon,
) {
Ok(()) => {
let elapsed = start.elapsed();
println!(
"[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)",
export_path_thread.display(),
elapsed,
notebook_clone.pages.len()
);
window_ref.request_redraw();
}
Err(err) => {
eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}");
}
}
});
window.request_redraw(); window.request_redraw();
} }
@@ -868,7 +899,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let color = [0.12, 0.12, 0.14, 1.0]; let color = [0.12, 0.12, 0.14, 1.0];
if selection_state.recolor_selected(notebook.current_page_mut(), color) { if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string()); ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -888,7 +919,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let color = [0.15, 0.35, 0.85, 1.0]; let color = [0.15, 0.35, 0.85, 1.0];
if selection_state.recolor_selected(notebook.current_page_mut(), color) { if selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string()); ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -907,7 +938,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty() { if !selection_state.is_empty() {
if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -931,7 +962,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
PageBackground::Blank => PageBackground::Grid5mm, PageBackground::Blank => PageBackground::Grid5mm,
}; };
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -945,8 +976,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -958,8 +990,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.next_page_id = notebook.next_page_id; manifest.next_page_id = notebook.next_page_id;
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -972,8 +1005,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -986,8 +1020,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1000,8 +1035,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1090,7 +1126,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1188,7 +1224,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width); let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width);
notebook.current_page_mut().replace_stroke(old_id, new_stroke); notebook.current_page_mut().replace_stroke(old_id, new_stroke);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)")); ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)"));
window.request_redraw(); window.request_redraw();
return; return;
@@ -1245,7 +1281,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes { if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes); notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
} }
window.request_redraw(); window.request_redraw();
} }
@@ -1358,7 +1394,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1482,7 +1518,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1623,7 +1659,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius); let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1714,7 +1750,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids); let final_strokes = notebook.current_page().get_strokes(&selection_state.selected_ids);
if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes { if !initial_strokes.is_empty() && !final_strokes.is_empty() && initial_strokes != final_strokes {
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes); notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
} }
window.request_redraw(); window.request_redraw();
} }
@@ -1798,7 +1834,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty() if !selection_state.is_empty()
&& selection_state.recolor_selected(notebook.current_page_mut(), color) { && selection_state.recolor_selected(notebook.current_page_mut(), color) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl umgefärbt".to_string()); ui_state.set_export_message("Auswahl umgefärbt".to_string());
} }
match ui_state.active_tool { match ui_state.active_tool {
@@ -1833,7 +1869,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let removed = selection_state.delete_selected(notebook.current_page_mut()); let removed = selection_state.delete_selected(notebook.current_page_mut());
if !removed.is_empty() { if !removed.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len())); ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -1860,7 +1896,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !new_ids.is_empty() { if !new_ids.is_empty() {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.next_stroke_id = notebook.next_stroke_id; manifest.next_stroke_id = notebook.next_stroke_id;
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len())); ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
window.request_redraw(); window.request_redraw();
} }
@@ -1869,7 +1905,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if !selection_state.is_empty() if !selection_state.is_empty()
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) { && selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
ui_state.set_export_message("Auswahl um 90° gedreht".to_string()); ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
window.request_redraw(); window.request_redraw();
} }
@@ -1897,7 +1933,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().undo() { if notebook.current_page_mut().undo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1906,7 +1942,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if notebook.current_page_mut().redo() { if notebook.current_page_mut().redo() {
selection_state.clear(); selection_state.clear();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1918,8 +1954,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = notebook.current_page_index; manifest.current_page_index = notebook.current_page_index;
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1939,8 +1976,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
background: Some(p.background), background: Some(p.background),
}) })
.collect(); .collect();
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1959,8 +1997,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}) })
.collect(); .collect();
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1971,8 +2010,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx); let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
canvas.sync_page(notebook.current_page_mut()); canvas.sync_page(notebook.current_page_mut());
manifest.current_page_index = idx; manifest.current_page_index = idx;
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
@@ -1981,43 +2021,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let page = notebook.current_page_mut(); let page = notebook.current_page_mut();
page.background = bg; page.background = bg;
canvas.sync_page(page); canvas.sync_page(page);
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
window.request_redraw(); window.request_redraw();
} }
UiAction::ExportPdf => { UiAction::ExportPdf => {
let notebook_clone = notebook.clone(); start_pdf_export(&notebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
let export_path = export::resolve_pdf_export_path(&manifest.title); ui_state.set_export_message("PDF-Export läuft…");
let export_path_str = export_path.display().to_string();
let window_ref = window.clone();
println!(
"[Export] Starte PDF-Vektorexport nach '{}' (im Hintergrundthread)...",
export_path_str
);
ui_state.set_export_message(format!("PDF gespeichert: {}", export_path.display()));
let export_path_thread = export_path.clone();
std::thread::spawn(move || {
let start = std::time::Instant::now();
match export_notebook_to_pdf(
&notebook_clone,
&export_path_thread,
ExportStrategy::FilledPolygon,
) {
Ok(()) => {
let elapsed = start.elapsed();
println!(
"[Export] PDF erfolgreich exportiert: '{}' ({:.1?} für {} Seiten)",
export_path_thread.display(),
elapsed,
notebook_clone.pages.len()
);
window_ref.request_redraw();
}
Err(err) => {
eprintln!("[Export-Fehler] Konnte PDF nicht exportieren: {err}");
}
}
});
window.request_redraw(); window.request_redraw();
} }
@@ -2107,6 +2117,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) => { Ok(()) => {
ui_state.document_title = manifest.title.clone(); ui_state.document_title = manifest.title.clone();
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir); ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
manifest.sync_from_notebook(&notebook);
autosave.mark_manifest_dirty(&manifest); autosave.mark_manifest_dirty(&manifest);
update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status()); update_window_title(&window, &notebook, &manifest, ui_state.active_tool, autosave.status());
ui_state.set_export_message(format!("Umbenannt in '{}'", new_title)); ui_state.set_export_message(format!("Umbenannt in '{}'", new_title));
@@ -2265,7 +2276,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
if let Some(page) = notebook.pages.iter_mut().find(|p| p.id == page_id) { if let Some(page) = notebook.pages.iter_mut().find(|p| p.id == page_id) {
page.unlock_after_load_error(); page.unlock_after_load_error();
} }
autosave.mark_page_dirty(notebook.current_page(), &manifest); autosave.mark_dirty(&notebook, &mut manifest);
window.request_redraw(); window.request_redraw();
} }
UiAction::RetryShutdown => { UiAction::RetryShutdown => {
+104 -16
View File
@@ -1206,6 +1206,28 @@ pub fn cleanup_trash(base_dir: &Path, max_age: std::time::Duration) -> io::Resul
Ok(removed) Ok(removed)
} }
/// Lädt alle noch nicht geladenen Seiten ohne Ersatzseiten (z. B. für den PDF-Export).
/// Schlägt fehl, sobald eine Seite nicht vollständig verfügbar ist.
pub fn load_all_pages(notebook_dir: &Path, notebook: &mut Notebook) -> Result<(), StorageError> {
for page in &mut notebook.pages {
if page.persist == crate::document::PersistState::ReadOnlyAfterLoadError {
return Err(StorageError::CorruptedPage(
page.id,
"Seite konnte nicht geladen werden".to_string(),
));
}
if !page.is_loaded {
*page = match load_page(notebook_dir, page.id) {
Ok(data) => data.to_page(),
// Wie beim Lazy Loading: nie gespeicherte Seite = leere Seite
Err(StorageError::PageNotFound(_)) => Page::new(page.id, page.background),
Err(e) => return Err(e),
};
}
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1500,12 +1522,6 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
fn test_io_error_does_not_quarantine_and_does_not_overwrite() { fn test_io_error_does_not_quarantine_and_does_not_overwrite() {
use crate::document::PersistState; use crate::document::PersistState;
use std::os::unix::fs::PermissionsExt;
// Skip test if running as root
if std::env::var("USER").unwrap_or_default() == "root" {
return;
}
let temp_dir = tempfile::tempdir().expect("tempdir"); let temp_dir = tempfile::tempdir().expect("tempdir");
let path = temp_dir.path(); let path = temp_dir.path();
@@ -1513,19 +1529,17 @@ mod tests {
let manifest = NotebookManifest::new("nb_io", "Test IO"); let manifest = NotebookManifest::new("nb_io", "Test IO");
save_manifest(path, &manifest).unwrap(); save_manifest(path, &manifest).unwrap();
// I/O-Fehler simulieren, der auch als root auftritt (CI-Runner laufen oft als root,
// dort greift ein chmod 000 nicht): page_1.json ist ein Verzeichnis -> EISDIR beim Lesen
let original_content = br#"{"id": 1, "background": "Grid5mm", "strokes": []}"#; let original_content = br#"{"id": 1, "background": "Grid5mm", "strokes": []}"#;
let page_file = path.join("page_1.json"); let page_path = path.join("page_1.json");
fs::write(&page_file, original_content).unwrap(); fs::create_dir(&page_path).unwrap();
let marker = page_path.join("original");
// Make file unreadable fs::write(&marker, original_content).unwrap();
fs::set_permissions(&page_file, fs::Permissions::from_mode(0o000)).unwrap();
let (nb, mut m) = let (nb, mut m) =
load_notebook(path).expect("load_notebook should succeed with fallback page"); load_notebook(path).expect("load_notebook should succeed with fallback page");
// Restore permissions for inspection
fs::set_permissions(&page_file, fs::Permissions::from_mode(0o600)).unwrap();
let dir_entries: Vec<String> = fs::read_dir(path) let dir_entries: Vec<String> = fs::read_dir(path)
.unwrap() .unwrap()
.flatten() .flatten()
@@ -1544,10 +1558,15 @@ mod tests {
); );
save_notebook(path, &nb, &mut m).unwrap(); save_notebook(path, &nb, &mut m).unwrap();
assert!(
page_path.is_dir(),
"Original page path must not be replaced"
);
assert_eq!( assert_eq!(
fs::read(&page_file).unwrap(), fs::read(&marker).unwrap(),
original_content, original_content,
"Original file content must not be overwritten" "Original content must not be overwritten"
); );
} }
@@ -2079,4 +2098,73 @@ mod tests {
"Kompaktes JSON spart mind. 50% Whitespace" "Kompaktes JSON spart mind. 50% Whitespace"
); );
} }
/// Hilfsfunktion: Seite mit einem einzelnen Strich.
fn page_with_one_stroke(page_id: u64, background: PageBackground) -> Page {
let mut page = Page::new(page_id, background);
let samples = vec![
StylusSample {
position: glam::Vec2::new(100.0, 100.0),
pressure: 0.5,
timestamp: 0.0,
},
StylusSample {
position: glam::Vec2::new(200.0, 150.0),
pressure: 0.5,
timestamp: 0.05,
},
];
let vertices = vec![
InkVertex::new(100.0, 100.0, [0.0, 0.0, 0.0, 1.0]),
InkVertex::new(200.0, 150.0, [0.0, 0.0, 0.0, 1.0]),
];
page.add_stroke(Stroke::new(1, samples, vertices, [0.0, 0.0, 0.0, 1.0]));
page
}
#[test]
fn test_keyboard_new_page_is_recorded_in_manifest() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Neue Seiten").unwrap();
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
// Wie Taste N in main.rs: neue Seite anhängen, Strich darauf, speichern
notebook.next_page();
let idx = notebook.current_page_index;
notebook.pages[idx] = page_with_one_stroke(notebook.pages[idx].id, PageBackground::Lined);
autosave.mark_dirty(&notebook, &mut manifest);
autosave.flush().unwrap();
autosave.shutdown().unwrap();
let (mut reloaded, reloaded_manifest) = load_notebook(&dir).unwrap();
assert_eq!(reloaded_manifest.page_ids(), vec![1, 2]);
assert_eq!(
reloaded.pages.len(),
2,
"Neue Seite muss einen Neustart überleben"
);
ensure_page_loaded(&dir, &mut reloaded, 1).unwrap();
assert_eq!(reloaded.pages[1].strokes.len(), 1);
}
#[test]
fn test_load_all_pages_for_export() {
let temp_dir = tempfile::tempdir().unwrap();
let (mut notebook, mut manifest, dir) =
create_new_notebook(temp_dir.path(), "Export").unwrap();
notebook.add_page(PageBackground::Lined);
notebook.pages[1] = page_with_one_stroke(notebook.pages[1].id, PageBackground::Lined);
save_notebook(&dir, &notebook, &mut manifest).unwrap();
let (mut loaded, _) = load_notebook(&dir).unwrap();
assert!(!loaded.pages[1].is_loaded);
// Ohne Nachladen verweigert der Export, statt leere Seiten zu erzeugen
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_err());
load_all_pages(&dir, &mut loaded).unwrap();
assert_eq!(loaded.pages[1].strokes.len(), 1);
assert_eq!(loaded.pages[1].background, PageBackground::Lined);
assert!(crate::export::generate_pdf_document(&loaded, Default::default()).is_ok());
}
} }
+195 -59
View File
@@ -7,7 +7,7 @@
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz) //! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
//! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten) //! - Isolierter Benutzer-Cache-Pfad (`$XDG_CACHE_HOME/ink-notes/updates` mit 0700 Rechten)
//! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien //! - Integritätsprüfung via SHA-256 (`SHA256SUMS.txt`) mit automatischem Löschen ungültiger Dateien
//! - Erweiterbare Schnittstelle für optionale Signaturprüfung (.sig / .minisig) //! - Pflicht-Signaturprüfung der `SHA256SUMS.txt` via Minisign (eingebauter öffentlicher Schlüssel)
//! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename` //! - Atomares AppImage-Self-Update via `.new`-Zwischendatei, Backup-Pflicht und atomarem `fs::rename`
//! - Echtes Fortschritts-Streaming (~10 Updates/s) //! - Echtes Fortschritts-Streaming (~10 Updates/s)
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv) //! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
@@ -313,22 +313,78 @@ pub fn verify_checksum(
Ok(()) Ok(())
} }
/// Status einer Signaturverifikation (.sig / .minisig). /// Öffentlicher Minisign-Schlüssel (Base64), mit dem die `SHA256SUMS.txt` jedes Releases
#[derive(Debug, Clone, PartialEq, Eq)] /// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
pub enum SignatureVerificationStatus { /// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
NotPresent, /// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
Verified { signer: String }, pub const UPDATE_SIGNING_PUBLIC_KEY: &str =
Failed(String), "RWTZqQ0ZDCn+PHTpqgI8NwjtgtkJk5QyJ0FYHWZlCZtABsXBcdPDv9WI";
/// Name des Prüfsummen-Assets eines Releases.
pub const CHECKSUMS_ASSET_NAME: &str = "SHA256SUMS.txt";
/// Name der Minisign-Signatur zu [`CHECKSUMS_ASSET_NAME`].
pub const CHECKSUMS_SIGNATURE_ASSET_NAME: &str = "SHA256SUMS.txt.minisig";
/// Prüft die Minisign-Signatur der Prüfsummendatei. Nur aktuelle (prehashed) Signaturen
/// werden akzeptiert; ohne hinterlegten Schlüssel schlägt die Prüfung immer fehl.
pub fn verify_checksums_signature(
checksums: &[u8],
signature_text: &str,
public_key_b64: &str,
) -> Result<(), String> {
let key = public_key_b64.trim();
if key.is_empty() {
return Err("In dieser Version ist kein Signaturschlüssel für Updates hinterlegt. Bitte das Update manuell installieren.".to_string());
}
let public_key = minisign_verify::PublicKey::from_base64(key)
.map_err(|e| format!("Ungültiger Update-Signaturschlüssel: {e}."))?;
let signature = minisign_verify::Signature::decode(signature_text)
.map_err(|e| format!("{CHECKSUMS_SIGNATURE_ASSET_NAME} ist ungültig: {e}."))?;
public_key
.verify(checksums, &signature, false)
.map_err(|e| format!("Signaturprüfung von {CHECKSUMS_ASSET_NAME} fehlgeschlagen: {e}."))
} }
/// Erweiterbare Schnittstelle für kryptografische Signaturprüfung. /// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
pub fn verify_signature_if_present( fn fetch_release_text(
_asset_file: &Path, agent: &ureq::Agent,
_sig_bytes: Option<&[u8]>, release: &GiteaRelease,
) -> Result<SignatureVerificationStatus, String> { name: &str,
// Erweiterungspunkt: Sobald ein Signaturschlüssel / Minisign-Public-Key config: &UpdaterConfig,
// im Projekt konfiguriert ist, kann hier die kryptografische Verifikation erfolgen. ) -> Result<String, String> {
Ok(SignatureVerificationStatus::NotPresent) let asset = release
.assets
.iter()
.find(|a| a.name == name)
.ok_or_else(|| format!("Release enthält keine {name}."))?;
http_get_with_auth_guard(
agent,
&asset.browser_download_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
)
.map_err(|e| format!("Konnte {name} nicht laden: {e}."))?
.into_string()
.map_err(|e| format!("Konnte {name} nicht dekodieren: {e}."))
}
/// Lädt `SHA256SUMS.txt` samt Minisign-Signatur und gibt den Inhalt nur bei gültiger
/// Signatur zurück.
fn fetch_verified_checksums(
agent: &ureq::Agent,
release: &GiteaRelease,
config: &UpdaterConfig,
public_key_b64: &str,
) -> Result<String, String> {
// Ohne Schlüssel gar nicht erst Netzwerkanfragen stellen
if public_key_b64.trim().is_empty() {
return verify_checksums_signature(b"", "", public_key_b64).map(|()| String::new());
}
let checksums = fetch_release_text(agent, release, CHECKSUMS_ASSET_NAME, config)?;
let signature = fetch_release_text(agent, release, CHECKSUMS_SIGNATURE_ASSET_NAME, config)?;
verify_checksums_signature(checksums.as_bytes(), &signature, public_key_b64)?;
Ok(checksums)
} }
/// Event-Typen für den asynchronen Worker. /// Event-Typen für den asynchronen Worker.
@@ -368,6 +424,17 @@ pub enum UpdaterStatus {
Error(String), Error(String),
} }
/// Lehnt jede URL ohne HTTPS ab, sofern unsicheres HTTP nicht ausdrücklich erlaubt ist.
fn ensure_secure_scheme(url: &url::Url, allow_insecure_http: bool) -> Result<(), String> {
match url.scheme() {
"https" => Ok(()),
"http" if allow_insecure_http => Ok(()),
scheme => Err(format!(
"Unsicheres Protokoll '{scheme}' für '{url}' abgewiesen. Es wird zwingend HTTPS verlangt."
)),
}
}
/// Erstellt einen ureq Agent mit sauberen Timeouts. /// Erstellt einen ureq Agent mit sauberen Timeouts.
fn create_http_agent() -> ureq::Agent { fn create_http_agent() -> ureq::Agent {
ureq::AgentBuilder::new() ureq::AgentBuilder::new()
@@ -384,12 +451,15 @@ fn http_get_with_auth_guard(
initial_url_str: &str, initial_url_str: &str,
token: Option<&str>, token: Option<&str>,
max_redirects: usize, max_redirects: usize,
allow_insecure_http: bool,
) -> Result<ureq::Response, String> { ) -> Result<ureq::Response, String> {
let mut current_url = url::Url::parse(initial_url_str) let mut current_url = url::Url::parse(initial_url_str)
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?; .map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
let initial_origin = current_url.origin().ascii_serialization(); let initial_origin = current_url.origin().ascii_serialization();
for _ in 0..=max_redirects { for _ in 0..=max_redirects {
// Jede Anfrage prüfen – auch Download-Links aus der API und Weiterleitungsziele
ensure_secure_scheme(&current_url, allow_insecure_http)?;
let mut req = agent.get(current_url.as_str()); let mut req = agent.get(current_url.as_str());
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist // Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
@@ -500,7 +570,13 @@ impl UpdaterController {
); );
let agent = create_http_agent(); let agent = create_http_agent();
match http_get_with_auth_guard(&agent, &api_url, config.auth_token.as_deref(), 5) { match http_get_with_auth_guard(
&agent,
&api_url,
config.auth_token.as_deref(),
5,
config.allow_insecure_http,
) {
Ok(resp) => match resp.into_json::<GiteaRelease>() { Ok(resp) => match resp.into_json::<GiteaRelease>() {
Ok(release) => { Ok(release) => {
if release.prerelease && !config.include_prereleases { if release.prerelease && !config.include_prereleases {
@@ -563,12 +639,37 @@ impl UpdaterController {
let target_file = cache_dir.join(&safe_asset_name); let target_file = cache_dir.join(&safe_asset_name);
let agent = create_http_agent(); let agent = create_http_agent();
// 1. Download des Release-Assets mit Fortschritts-Streaming // 1. Signierte Prüfsummen laden und verifizieren, BEVOR das Paket geladen wird
let checksums_content = match active_release.as_ref() {
Some(release) => match fetch_verified_checksums(
&agent,
release,
&config,
UPDATE_SIGNING_PUBLIC_KEY,
) {
Ok(text) => text,
Err(e) => {
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"{e} Download abgelehnt."
)));
return;
}
},
None => {
let _ = tx.send(UpdaterEvent::DownloadError(
"Kein Release ausgewählt. Download abgelehnt.".to_string(),
));
return;
}
};
// 2. Download des Release-Assets mit Fortschritts-Streaming
let resp = match http_get_with_auth_guard( let resp = match http_get_with_auth_guard(
&agent, &agent,
&asset.browser_download_url, &asset.browser_download_url,
config.auth_token.as_deref(), config.auth_token.as_deref(),
5, 5,
config.allow_insecure_http,
) { ) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
@@ -646,53 +747,13 @@ impl UpdaterController {
drop(file); drop(file);
// 2. Integritätsprüfung gegen SHA256SUMS.txt // 3. Integritätsprüfung des Pakets gegen die signierte SHA256SUMS.txt
let checksum_asset = active_release
.as_ref()
.and_then(|r| r.assets.iter().find(|a| a.name == "SHA256SUMS.txt"));
let checksums_content = match checksum_asset {
Some(ca) => {
match http_get_with_auth_guard(
&agent,
&ca.browser_download_url,
config.auth_token.as_deref(),
5,
) {
Ok(c_resp) => match c_resp.into_string() {
Ok(text) => text,
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Konnte SHA256SUMS.txt nicht dekodieren: {e}. Datei gelöscht."
)));
return;
}
},
Err(e) => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(format!(
"Konnte SHA256SUMS.txt nicht laden: {e}. Datei aus Sicherheitsgründen gelöscht."
)));
return;
}
}
}
None => {
let _ = std::fs::remove_file(&target_file);
let _ = tx.send(UpdaterEvent::DownloadError(
"Release enthält keine SHA256SUMS.txt-Prüfsummen. Download abgelehnt und gelöscht.".to_string(),
));
return;
}
};
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) { if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
let _ = tx.send(UpdaterEvent::DownloadError(err)); let _ = tx.send(UpdaterEvent::DownloadError(err));
return; return;
} }
// 3. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen // 4. Wenn es ein AppImage ist, Executable-Bit auf 0755 setzen
if safe_asset_name.ends_with(".AppImage") { if safe_asset_name.ends_with(".AppImage") {
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -996,6 +1057,81 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
assert!(allowed_insecure.validate_url().is_ok()); assert!(allowed_insecure.validate_url().is_ok());
} }
/// Nur für Tests erzeugtes Minisign-Schlüsselpaar (der geheime Schlüssel wurde verworfen).
const TEST_PUBLIC_KEY: &str = "RWQr5S8CVgsjS4UA3uY0xqTDua4uRSSzKWpFfJKLCypjgP3NGt9Tmdbh";
const TEST_CHECKSUMS: &str = "abc123 ink-notes_9.9.9_amd64.deb\n";
const TEST_SIGNATURE: &str = "untrusted comment: signature from rsign secret key
RUQr5S8CVgsjS5BYMUx1ICe1E356aP7wesQXw+oKF6Rd8WsvdF1dmCb1ffonXgJ2GhWE5iAWPXQaT8Y/Y4zLIpyS1PA8JCe8eAM=
trusted comment: ink-notes test
iw0NOedyAQg3emO36fHjyfn+AbnI7Idyl+NwFTDJX1YceLM1jSfjIfeIc0D49IQXyGTpM/QnkZJkRO/eI8ZKBQ==
";
#[test]
fn test_checksums_signature_verification() {
// Gültige Signatur
assert!(verify_checksums_signature(
TEST_CHECKSUMS.as_bytes(),
TEST_SIGNATURE,
TEST_PUBLIC_KEY
)
.is_ok());
// Manipulierte Prüfsummen (z. B. ausgetauschter Hash)
let tampered = TEST_CHECKSUMS.replace("abc123", "abc124");
assert!(
verify_checksums_signature(tampered.as_bytes(), TEST_SIGNATURE, TEST_PUBLIC_KEY)
.is_err()
);
// Fremder Schlüssel
let other_key = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3";
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, other_key)
.is_err()
);
// Kein Schlüssel hinterlegt -> strikt ablehnen
let err =
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), TEST_SIGNATURE, "").unwrap_err();
assert!(err.contains("kein Signaturschlüssel"), "{err}");
// Fehlende / kaputte Signatur
assert!(
verify_checksums_signature(TEST_CHECKSUMS.as_bytes(), "", TEST_PUBLIC_KEY).is_err()
);
// Altes (nicht prehashed) Signaturformat wird nicht akzeptiert
let legacy_sig = "untrusted comment: signature from minisign secret key
RWQf6LRCGA9i59SLOFxz6NxvASXDJeRtuZykwQepbDEGt87ig1BNpWaVWuNrm73YiIiJbq71Wi+dP9eKL8OC351vwIasSSbXxwA=
trusted comment: timestamp:1555779966\tfile:test
QtKMXWyYcwdpZAlPF7tE2ENJkRd1ujvKjlj1m9RtHTBnZPa5WKU5uWRs5GoP5M/VqE81QFuMKI5k/SfNQUaOAA==";
assert!(verify_checksums_signature(b"test", legacy_sig, other_key).is_err());
}
#[test]
fn test_https_enforced_for_every_request() {
let https = url::Url::parse("https://gitea.pansi.eu/a.deb").unwrap();
let http = url::Url::parse("http://cdn.example.com/a.deb").unwrap();
let ftp = url::Url::parse("ftp://example.com/a.deb").unwrap();
assert!(ensure_secure_scheme(&https, false).is_ok());
assert!(ensure_secure_scheme(&http, false).is_err());
assert!(ensure_secure_scheme(&http, true).is_ok());
assert!(ensure_secure_scheme(&ftp, true).is_err());
// Ein http-Download-Link aus der Release-API wird abgewiesen, bevor eine
// Verbindung aufgebaut wird
let agent = create_http_agent();
let err = http_get_with_auth_guard(
&agent,
"http://127.0.0.1:9/ink-notes.AppImage",
Some("geheim"),
5,
false,
)
.unwrap_err();
assert!(err.contains("HTTPS"), "{err}");
}
#[test] #[test]
fn test_appimage_atomic_self_update() { fn test_appimage_atomic_self_update() {
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();