Compare commits
4
Commits
v0.1.0
...
7ed971d2a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed971d2a2 | ||
|
|
5ce2c250bb | ||
|
|
d61e1819ed | ||
|
|
f9b39c6fe3 |
No files matched your search
@@ -38,6 +38,7 @@ jobs:
|
||||
squashfs-tools \
|
||||
dpkg-dev \
|
||||
curl \
|
||||
minisign \
|
||||
python3
|
||||
|
||||
- name: Setup Rust Toolchain
|
||||
@@ -52,10 +53,13 @@ jobs:
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test
|
||||
|
||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt)
|
||||
- name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt + .minisig)
|
||||
run: |
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/build-release.sh
|
||||
env:
|
||||
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
|
||||
INK_NOTES_REQUIRE_SIGNATURE: "1"
|
||||
|
||||
- name: Publish Release to Gitea
|
||||
run: |
|
||||
|
||||
Generated
+8
-1
@@ -1038,13 +1038,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ink-notes"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"egui-winit",
|
||||
"glam",
|
||||
"minisign-verify",
|
||||
"octotablet",
|
||||
"pollster",
|
||||
"printpdf",
|
||||
@@ -1293,6 +1294,12 @@ dependencies = [
|
||||
"paste",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ink-notes"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
description = "Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux"
|
||||
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"] }
|
||||
url = "2.5"
|
||||
octotablet = "0.1.0"
|
||||
minisign-verify = "0.3.0"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus
|
||||
|
||||
@@ -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).
|
||||
- **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
|
||||
|
||||
@@ -84,6 +84,23 @@ Ergebnis in `target/`:
|
||||
- `ink-notes_0.1.0_amd64.deb`
|
||||
- `ink-notes_0.1.0_x86_64.AppImage`
|
||||
- `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. Einmalig ein Schlüsselpaar erzeugen (ohne Passwort, da es nur als CI-Secret verwendet wird):
|
||||
|
||||
```bash
|
||||
minisign -G -W -p ink-notes.pub -s ink-notes.key
|
||||
# oder ohne minisign-Paket, mit dem kompatiblen rsign2 (cargo install rsign2):
|
||||
rsign generate -W -p ink-notes.pub -s ink-notes.key
|
||||
```
|
||||
|
||||
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 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
|
||||
|
||||
@@ -123,15 +140,15 @@ export GITEA_TOKEN="<DEIN_GITEA_TOKEN>"
|
||||
|
||||
Der In-App-Updater (`src/updater.rs`) folgt strengen Sicherheitsrichtlinien:
|
||||
|
||||
1. **SHA-256-Integritätsprüfung:**
|
||||
- Vor jeder Ausführung oder Installation wird die offizielle Prüfsummendatei `SHA256SUMS.txt` aus dem Release bezogen.
|
||||
- Der SHA-256-Hash des heruntergeladenen Pakets wird berechnet (`sha2`).
|
||||
1. **Minisign-Signatur (Pflicht):**
|
||||
- Jedes Release enthält `SHA256SUMS.txt.minisig`, eine Minisign-Signatur der Prüfsummendatei.
|
||||
- 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.
|
||||
- *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:**
|
||||
- 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.
|
||||
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`).
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
||||
- **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)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
||||
- **Viewport**: Stufenloser Zoom (25% - 500%) und Panning
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
|
||||
- **In-App Updater**: Automatische Versionsprüfung und Aktualisierung via Gitea Releases
|
||||
- **In-App Updater**: Automatische Versionsprüfung und sichere Atomar-Aktualisierung via Gitea Releases
|
||||
- **Vektor-PDF-Export**: Verlustfreie Ausgabe für Druck und Weitergabe
|
||||
|
||||
### Installation:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# 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
|
||||
|
||||
@@ -30,10 +30,36 @@ echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ==="
|
||||
cd "${ROOT_DIR}/target"
|
||||
sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}"
|
||||
|
||||
# 4. Prüfsummen mit Minisign signieren (Pflicht für In-App-Updates)
|
||||
SIGNATURE_FILE="${CHECKSUM_FILE}.minisig"
|
||||
rm -f "${SIGNATURE_FILE}"
|
||||
EMBEDDED_PUBKEY=$(grep -m1 'pub const UPDATE_SIGNING_PUBLIC_KEY' "${ROOT_DIR}/src/updater.rs" | cut -d '"' -f2)
|
||||
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
|
||||
echo "=== Signiere ${CHECKSUM_FILE} mit Minisign ==="
|
||||
KEY_FILE="$(mktemp)"
|
||||
trap 'rm -f "${KEY_FILE}"' EXIT
|
||||
printf '%s\n' "${MINISIGN_SECRET_KEY}" > "${KEY_FILE}"
|
||||
minisign -S -s "${KEY_FILE}" -m "${CHECKSUM_FILE}" -t "ink-notes v${VERSION}"
|
||||
rm -f "${KEY_FILE}"
|
||||
# Stellt sicher, dass der geheime Schlüssel zum eingebauten öffentlichen Schlüssel passt
|
||||
minisign -V -P "${EMBEDDED_PUBKEY}" -m "${CHECKSUM_FILE}"
|
||||
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 "Release-Artefakte für v${VERSION} erfolgreich erstellt:"
|
||||
echo "======================================================================"
|
||||
ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}"
|
||||
if [ -f "${SIGNATURE_FILE}" ]; then ls -lh "${SIGNATURE_FILE}"; fi
|
||||
echo ""
|
||||
echo "Inhalt von target/${CHECKSUM_FILE}:"
|
||||
cat "${CHECKSUM_FILE}"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# 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
|
||||
|
||||
@@ -33,6 +33,7 @@ RELEASE_TITLE="ink-notes ${TAG_NAME}"
|
||||
DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb"
|
||||
APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage"
|
||||
CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt"
|
||||
SIGNATURE_FILE="${CHECKSUMS_FILE}.minisig"
|
||||
TEMPLATE_FILE="${ROOT_DIR}/packaging/release-notes.md.tmpl"
|
||||
|
||||
if [ ! -f "${TEMPLATE_FILE}" ]; then
|
||||
@@ -46,6 +47,12 @@ if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_
|
||||
"${SCRIPT_DIR}/build-release.sh"
|
||||
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 "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}"
|
||||
echo "======================================================================"
|
||||
@@ -230,6 +237,7 @@ except Exception:
|
||||
upload_asset "${DEB_FILE}"
|
||||
upload_asset "${APPIMAGE_FILE}"
|
||||
upload_asset "${CHECKSUMS_FILE}"
|
||||
upload_asset "${SIGNATURE_FILE}"
|
||||
|
||||
echo "======================================================================"
|
||||
echo "Release ${TAG_NAME} erfolgreich veröffentlicht!"
|
||||
|
||||
+85
-14
@@ -11,7 +11,7 @@ use std::sync::Arc;
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::document::Page;
|
||||
use crate::document::{Notebook, Page};
|
||||
use crate::storage::{self, NotebookManifest, PageData};
|
||||
|
||||
/// Aktueller Status des Autosave-Controllers für OSD und UI.
|
||||
@@ -138,14 +138,53 @@ impl AutosaveController {
|
||||
}
|
||||
|
||||
/// Markiert eine Seite als geändert und startet/erneuert das Debounce-Fenster.
|
||||
///
|
||||
/// Nicht geladene oder fehlgeschlagene Ersatzseiten werden nie gespeichert, damit ihre
|
||||
/// Originaldatei auf der Festplatte nicht überschrieben wird.
|
||||
pub fn mark_page_dirty(&mut self, page: &Page, manifest: &NotebookManifest) {
|
||||
let page_data = PageData::from(page);
|
||||
self.pending_pages.insert(page.id, page_data);
|
||||
if page.is_loaded && !page.load_failed {
|
||||
self.pending_pages.insert(page.id, PageData::from(page));
|
||||
}
|
||||
self.pending_manifest = Some(manifest.clone());
|
||||
self.last_change_time = Some(Instant::now());
|
||||
self.status = AutosaveStatus::Pending;
|
||||
}
|
||||
|
||||
/// Markiert die aktuelle Seite als geändert und gleicht das Manifest (Seitenliste,
|
||||
/// aktuelle Seite, ID-Zähler) mit dem Notizbuch ab.
|
||||
pub fn mark_dirty(&mut self, notebook: &Notebook, manifest: &NotebookManifest) {
|
||||
let mut synced = manifest.clone();
|
||||
synced.sync_from_notebook(notebook);
|
||||
self.mark_page_dirty(notebook.current_page(), &synced);
|
||||
}
|
||||
|
||||
/// Sichert sofort alle geladenen Seiten und das Manifest (Strg+S) und meldet,
|
||||
/// ob wirklich alles geschrieben wurde.
|
||||
pub fn save_now(
|
||||
&mut self,
|
||||
notebook: &Notebook,
|
||||
manifest: &NotebookManifest,
|
||||
) -> Result<(), String> {
|
||||
for page in ¬ebook.pages {
|
||||
if page.is_loaded && !page.load_failed {
|
||||
self.pending_pages.insert(page.id, PageData::from(page));
|
||||
}
|
||||
}
|
||||
let mut synced = manifest.clone();
|
||||
synced.sync_from_notebook(notebook);
|
||||
self.pending_manifest = Some(synced);
|
||||
|
||||
let errors = self.flush_collect_errors();
|
||||
if errors.is_empty() {
|
||||
// Der komplette Zustand liegt jetzt auf der Festplatte – frühere Fehler sind überholt
|
||||
self.status = AutosaveStatus::Saved;
|
||||
Ok(())
|
||||
} else {
|
||||
self.status = AutosaveStatus::Error;
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Prüft den Worker-Kanal und stößt fällige Schreibvorgänge nach Ablauf des Debounce-Timers an.
|
||||
///
|
||||
/// Sollte regelmäßig im Event-Loop aufgerufen werden.
|
||||
@@ -220,40 +259,72 @@ impl AutosaveController {
|
||||
}
|
||||
|
||||
/// Führt einen synchronen Flush aller noch ausstehenden Änderungen durch (z. B. vor Beenden).
|
||||
pub fn flush(&mut self) {
|
||||
pub fn flush(&mut self) -> Result<(), String> {
|
||||
let errors = self.flush_collect_errors();
|
||||
if errors.is_empty() {
|
||||
if self.status != AutosaveStatus::Error {
|
||||
self.status = AutosaveStatus::Saved;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
self.status = AutosaveStatus::Error;
|
||||
Err(errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Schickt alle anstehenden Änderungen los, wartet, bis der Worker sie abgearbeitet hat,
|
||||
/// und liefert alle dabei aufgetretenen Schreibfehler.
|
||||
fn flush_collect_errors(&mut self) -> Vec<String> {
|
||||
// Falls noch Änderungen im Debounce-Puffer liegen, sofort losschicken
|
||||
if !self.pending_pages.is_empty() || self.pending_manifest.is_some() {
|
||||
self.dispatch_pending();
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let (ack_tx, ack_rx) = mpsc::channel();
|
||||
if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_ok() {
|
||||
let _ = ack_rx.recv();
|
||||
if self.cmd_tx.send(AutosaveCommand::Flush(ack_tx)).is_err() || ack_rx.recv().is_err() {
|
||||
errors.push("Autosave-Worker ist nicht erreichbar".to_string());
|
||||
}
|
||||
|
||||
// Restliche Antworten leeren
|
||||
// Restliche Antworten leeren (der Worker hat alle vor dem Flush-Befehl abgearbeitet)
|
||||
while let Ok(response) = self.resp_rx.try_recv() {
|
||||
if self.in_flight_count > 0 {
|
||||
self.in_flight_count -= 1;
|
||||
}
|
||||
if let AutosaveResponse::PageSaved { result: Err(e), .. } = response {
|
||||
eprintln!("[Autosave-Flush] Fehler: {e}");
|
||||
match response {
|
||||
AutosaveResponse::PageSaved {
|
||||
page_id,
|
||||
result: Err(e),
|
||||
} => {
|
||||
eprintln!("[Autosave-Flush] Seite {page_id}: {e}");
|
||||
errors.push(format!("Seite {page_id}: {e}"));
|
||||
}
|
||||
AutosaveResponse::ManifestSaved { result: Err(e) } => {
|
||||
eprintln!("[Autosave-Flush] Manifest: {e}");
|
||||
errors.push(format!("Manifest: {e}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
self.status = AutosaveStatus::Saved;
|
||||
errors
|
||||
}
|
||||
|
||||
/// Wechselt das Zielverzeichnis für zukünftige Speicheroperationen (z. B. beim Dokumentenwechsel),
|
||||
/// nachdem alle noch anstehenden Schreibvorgänge des vorherigen Notizbuchs sicher geflusht wurden.
|
||||
pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) {
|
||||
self.flush();
|
||||
/// Liefert die Schreibfehler des vorherigen Notizbuchs zurück; der Status gilt danach
|
||||
/// für das neue Notizbuch.
|
||||
pub fn switch_notebook_dir(&mut self, new_notebook_dir: PathBuf) -> Result<(), String> {
|
||||
let result = self.flush();
|
||||
self.notebook_dir = new_notebook_dir;
|
||||
self.status = AutosaveStatus::Saved;
|
||||
result
|
||||
}
|
||||
|
||||
/// Beendet den Hintergrund-Worker ordnungsgemäß nach einem Flush.
|
||||
pub fn shutdown(&mut self) {
|
||||
self.flush();
|
||||
if let Err(e) = self.flush() {
|
||||
eprintln!("[Shutdown] Nicht alle Änderungen konnten gespeichert werden: {e}");
|
||||
}
|
||||
let _ = self.cmd_tx.send(AutosaveCommand::Shutdown);
|
||||
if let Some(handle) = self.worker_handle.take() {
|
||||
let _ = handle.join();
|
||||
|
||||
@@ -314,6 +314,9 @@ pub struct Page {
|
||||
pub stroke_ranges: Vec<Range<u32>>,
|
||||
pub is_dirty: bool,
|
||||
pub is_loaded: bool,
|
||||
/// Laden von der Festplatte ist fehlgeschlagen; die Seite ist ein leerer Ersatz und
|
||||
/// darf niemals gespeichert werden, damit die Originaldatei nicht überschrieben wird.
|
||||
pub load_failed: bool,
|
||||
pub history: HistoryStack,
|
||||
}
|
||||
|
||||
@@ -327,6 +330,7 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: true,
|
||||
load_failed: false,
|
||||
history: HistoryStack::new(),
|
||||
}
|
||||
}
|
||||
@@ -341,6 +345,7 @@ impl Page {
|
||||
stroke_ranges: Vec::new(),
|
||||
is_dirty: false,
|
||||
is_loaded: false,
|
||||
load_failed: false,
|
||||
history: HistoryStack::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,18 @@ pub fn generate_pdf_document(
|
||||
if notebook.pages.is_empty() {
|
||||
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.load_failed)
|
||||
{
|
||||
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
|
||||
let title = "ink-notes Notebook";
|
||||
|
||||
+154
-142
@@ -1,6 +1,8 @@
|
||||
//! ink-notes: Latenzoptimierte Linux-Inking-App mit wgpu, winit, egui-Toolbar,
|
||||
//! 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::time::{Duration, Instant};
|
||||
use winit::{
|
||||
@@ -52,6 +54,44 @@ fn update_window_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(¬ebook_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)
|
||||
/// und fügt ihn in die aktuelle Seite des Notizbuchs ein.
|
||||
fn commit_stroke_to_notebook(
|
||||
@@ -83,7 +123,7 @@ fn commit_stroke_to_notebook(
|
||||
page.add_stroke(stroke);
|
||||
canvas.sync_page(page);
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_page_dirty(notebook.current_page(), manifest);
|
||||
autosave.mark_dirty(notebook, manifest);
|
||||
update_window_title(
|
||||
window,
|
||||
notebook,
|
||||
@@ -128,16 +168,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
};
|
||||
|
||||
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() {
|
||||
println!("[Storage] Lade Notizbuch aus: {}", storage_dir.display());
|
||||
match storage::load_notebook(&storage_dir) {
|
||||
Ok((nb, m)) => (nb, m),
|
||||
Err(err) => {
|
||||
eprintln!("[Storage-Fehler] Konnte Notizbuch nicht laden ({err}). Erstelle neues Notizbuch.");
|
||||
(
|
||||
Notebook::new(),
|
||||
NotebookManifest::new(&initial_id, &initial_title),
|
||||
)
|
||||
// Den Ordner des nicht ladbaren Notizbuchs NICHT wiederverwenden, sonst würde
|
||||
// der Autosave dessen Manifest überschreiben und alle Seiten verwaist zurücklassen.
|
||||
eprintln!(
|
||||
"[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 {
|
||||
@@ -159,6 +207,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
egui_ctx.set_visuals(egui::Visuals::dark());
|
||||
|
||||
// 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();
|
||||
updater.check_for_updates();
|
||||
|
||||
@@ -174,6 +225,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
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();
|
||||
|
||||
// Initiale Seite und Werkzeugeinstellungen mit Canvas synchronisieren
|
||||
@@ -265,6 +320,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
update_window_title(&window, ¬ebook, &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();
|
||||
updater.update();
|
||||
if *updater.status() != prev_upd {
|
||||
@@ -347,10 +410,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(
|
||||
notebook.current_page(),
|
||||
&manifest,
|
||||
);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
@@ -427,10 +487,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(
|
||||
notebook.current_page(),
|
||||
&manifest,
|
||||
);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
@@ -680,7 +737,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().undo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -694,7 +751,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().redo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -724,7 +781,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -736,7 +793,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = selection_state.delete_selected(notebook.current_page_mut());
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -745,8 +802,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Jetzt speichern (Disk Flush): Strg + S
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("s") && ctrl => {
|
||||
autosave.flush();
|
||||
ui_state.set_export_message("Gespeichert");
|
||||
match autosave.save_now(¬ebook, &manifest) {
|
||||
Ok(()) => ui_state.set_export_message("Gespeichert"),
|
||||
Err(err) => ui_state.set_export_message(format!("Speichern fehlgeschlagen: {err}")),
|
||||
}
|
||||
update_window_title(
|
||||
&window,
|
||||
¬ebook,
|
||||
@@ -759,38 +818,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// PDF-Export: Strg + E
|
||||
Key::Character(c) if c.eq_ignore_ascii_case("e") && ctrl => {
|
||||
let notebook_clone = notebook.clone();
|
||||
let export_path = export::resolve_pdf_export_path(&manifest.title);
|
||||
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(
|
||||
¬ebook_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}");
|
||||
}
|
||||
}
|
||||
});
|
||||
start_pdf_export(¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
|
||||
ui_state.set_export_message("PDF-Export läuft…");
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
@@ -826,7 +855,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let color = [0.12, 0.12, 0.14, 1.0];
|
||||
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message("Auswahl auf Schwarz umgefärbt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -846,7 +875,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let color = [0.15, 0.35, 0.85, 1.0];
|
||||
if selection_state.recolor_selected(notebook.current_page_mut(), color) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message("Auswahl auf Blau umgefärbt".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -865,7 +894,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !selection_state.is_empty() {
|
||||
if selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -889,7 +918,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
PageBackground::Blank => PageBackground::Grid5mm,
|
||||
};
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -903,7 +932,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -915,7 +944,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -928,7 +957,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -941,7 +970,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -954,7 +983,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1035,7 +1064,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1133,7 +1162,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let new_stroke = geometry::shape_to_stroke(shape, old_id, stroke_color, stroke_width);
|
||||
notebook.current_page_mut().replace_stroke(old_id, new_stroke);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message(format!("{shape_name} ausgerichtet! (Undo mit Strg+Z)"));
|
||||
window.request_redraw();
|
||||
return;
|
||||
@@ -1190,7 +1219,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 {
|
||||
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1303,7 +1332,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1427,7 +1456,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1568,7 +1597,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = page.erase_at(page_pt, eraser_radius, stroke_max_radius);
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1659,7 +1688,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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 {
|
||||
notebook.current_page_mut().commit_modified_strokes(initial_strokes, final_strokes);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
}
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1743,7 +1772,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !selection_state.is_empty()
|
||||
&& selection_state.recolor_selected(notebook.current_page_mut(), color) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message("Auswahl umgefärbt".to_string());
|
||||
}
|
||||
match ui_state.active_tool {
|
||||
@@ -1778,7 +1807,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let removed = selection_state.delete_selected(notebook.current_page_mut());
|
||||
if !removed.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) gelöscht", removed.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1805,7 +1834,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !new_ids.is_empty() {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message(format!("{} Objekt(e) eingefügt", new_ids.len()));
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1814,7 +1843,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !selection_state.is_empty()
|
||||
&& selection_state.rotate_selected_90_deg(notebook.current_page_mut()) {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
ui_state.set_export_message("Auswahl um 90° gedreht".to_string());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1842,7 +1871,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().undo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1851,7 +1880,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if notebook.current_page_mut().redo() {
|
||||
selection_state.clear();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1863,7 +1892,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, curr);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1875,7 +1904,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1886,7 +1915,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1897,7 +1926,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let _ = storage::ensure_page_loaded(&storage_dir, &mut notebook, idx);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
manifest.current_page_index = idx;
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1906,43 +1935,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let page = notebook.current_page_mut();
|
||||
page.background = bg;
|
||||
canvas.sync_page(page);
|
||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
window.request_redraw();
|
||||
}
|
||||
UiAction::ExportPdf => {
|
||||
let notebook_clone = notebook.clone();
|
||||
let export_path = export::resolve_pdf_export_path(&manifest.title);
|
||||
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(
|
||||
¬ebook_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}");
|
||||
}
|
||||
}
|
||||
});
|
||||
start_pdf_export(¬ebook, &storage_dir, &manifest.title, export_tx.clone(), window.clone());
|
||||
ui_state.set_export_message("PDF-Export läuft…");
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
@@ -1958,7 +1957,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let new_notebook_dir = storage_base_dir.join(&id);
|
||||
match storage::load_notebook(&new_notebook_dir) {
|
||||
Ok((loaded_nb, loaded_manifest)) => {
|
||||
autosave.switch_notebook_dir(new_notebook_dir.clone());
|
||||
if let Err(err) = autosave.switch_notebook_dir(new_notebook_dir.clone()) {
|
||||
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
|
||||
}
|
||||
notebook = loaded_nb;
|
||||
manifest = loaded_manifest;
|
||||
storage_dir = new_notebook_dir;
|
||||
@@ -1980,10 +1981,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
UiAction::CreateNotebook(title) => {
|
||||
autosave.flush();
|
||||
match storage::create_new_notebook(&storage_base_dir, &title) {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) {
|
||||
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
|
||||
}
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
@@ -2034,41 +2036,51 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
UiAction::DeleteNotebook(id) => {
|
||||
let target_dir = storage_base_dir.join(&id);
|
||||
let is_current = id == manifest.id;
|
||||
if is_current {
|
||||
// Ausstehende Autosave-Schreibvorgänge VOR dem Löschen abarbeiten,
|
||||
// sonst legen sie den gelöschten Ordner danach wieder an.
|
||||
let _ = autosave.flush();
|
||||
}
|
||||
match storage::delete_notebook(&target_dir) {
|
||||
Ok(()) => {
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
if is_current {
|
||||
if let Some(first) = ui_state.available_notebooks.first().cloned() {
|
||||
let new_notebook_dir = storage_base_dir.join(&first.id);
|
||||
match storage::load_notebook(&new_notebook_dir) {
|
||||
Ok((loaded_nb, loaded_manifest)) => {
|
||||
autosave.switch_notebook_dir(new_notebook_dir.clone());
|
||||
notebook = loaded_nb;
|
||||
manifest = loaded_manifest;
|
||||
storage_dir = new_notebook_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
|
||||
// Erstes ladbares Notizbuch öffnen, sonst ein neues anlegen
|
||||
let replacement = ui_state
|
||||
.available_notebooks
|
||||
.iter()
|
||||
.find_map(|summary| {
|
||||
let dir = storage_base_dir.join(&summary.id);
|
||||
match storage::load_notebook(&dir) {
|
||||
Ok((nb, m)) => Some((nb, m, dir)),
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", summary.id);
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte Dokument '{}' nicht laden: {err}", first.id);
|
||||
})
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch"));
|
||||
match replacement {
|
||||
Ok((new_nb, new_manifest, new_dir)) => {
|
||||
if let Err(err) = autosave.switch_notebook_dir(new_dir.clone()) {
|
||||
eprintln!("[Autosave-Fehler] Vorheriges Notizbuch nicht vollständig gespeichert: {err}");
|
||||
}
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message(format!("Gewechselt zu '{}'", manifest.title));
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("[Dokument-Fehler] Konnte kein Ersatz-Notizbuch anlegen: {err}");
|
||||
ui_state.set_export_message(format!("Dokument gelöscht, aber kein Ersatz-Notizbuch: {err}"));
|
||||
}
|
||||
} else if let Ok((new_nb, new_manifest, new_dir)) = storage::create_new_notebook(&storage_base_dir, "Standard-Notizbuch") {
|
||||
autosave.switch_notebook_dir(new_dir.clone());
|
||||
notebook = new_nb;
|
||||
manifest = new_manifest;
|
||||
storage_dir = new_dir;
|
||||
selection_state.clear();
|
||||
ui_state.current_notebook_id = manifest.id.clone();
|
||||
ui_state.document_title = manifest.title.clone();
|
||||
ui_state.available_notebooks = storage::list_notebooks(&storage_base_dir);
|
||||
canvas.sync_page(notebook.current_page_mut());
|
||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||
ui_state.set_export_message("Standard-Notizbuch erstellt".to_string());
|
||||
}
|
||||
} else {
|
||||
ui_state.set_export_message("Dokument gelöscht".to_string());
|
||||
|
||||
+271
-16
@@ -149,6 +149,19 @@ impl NotebookManifest {
|
||||
next_page_id: 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Übernimmt Seitenliste, aktuelle Seite und ID-Zähler aus dem Notizbuch,
|
||||
/// damit neu angelegte Seiten im Manifest referenziert werden.
|
||||
pub fn sync_from_notebook(&mut self, notebook: &Notebook) {
|
||||
self.page_ids = notebook.pages.iter().map(|p| p.id).collect();
|
||||
self.current_page_index = notebook.current_page_index;
|
||||
self.next_stroke_id = notebook.next_stroke_id;
|
||||
self.next_page_id = notebook.next_page_id;
|
||||
self.modified_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
}
|
||||
}
|
||||
|
||||
/// Mögliche Fehler bei Speicher- und Ladeoperationen.
|
||||
@@ -347,18 +360,11 @@ pub fn save_notebook(
|
||||
) -> Result<(), StorageError> {
|
||||
fs::create_dir_all(notebook_dir)?;
|
||||
|
||||
manifest.page_ids = notebook.pages.iter().map(|p| p.id).collect();
|
||||
manifest.current_page_index = notebook.current_page_index;
|
||||
manifest.next_stroke_id = notebook.next_stroke_id;
|
||||
manifest.next_page_id = notebook.next_page_id;
|
||||
manifest.modified_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
manifest.sync_from_notebook(notebook);
|
||||
|
||||
// Alle bereits geladenen Seiten speichern
|
||||
// Alle bereits geladenen Seiten speichern (fehlgeschlagene Ersatzseiten niemals)
|
||||
for page in ¬ebook.pages {
|
||||
if page.is_loaded {
|
||||
if page.is_loaded && !page.load_failed {
|
||||
let page_data = PageData::from(page);
|
||||
save_page(notebook_dir, &page_data)?;
|
||||
}
|
||||
@@ -383,8 +389,8 @@ pub fn load_notebook(notebook_dir: &Path) -> Result<(Notebook, NotebookManifest)
|
||||
let first_page = match load_page(notebook_dir, first_id) {
|
||||
Ok(data) => data.to_page(),
|
||||
Err(e) => {
|
||||
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}. Erstelle leere Ersatzseite.");
|
||||
Page::new(first_id, PageBackground::Grid5mm)
|
||||
eprintln!("[Storage] Konnte erste Seite {first_id} nicht laden: {e}.");
|
||||
replacement_page_after_load_error(notebook_dir, first_id, &e)
|
||||
}
|
||||
};
|
||||
pages.push(first_page);
|
||||
@@ -426,14 +432,77 @@ pub fn ensure_page_loaded(
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[Storage] Fehler beim Lazy Loading von Seite {page_id}: {e}");
|
||||
// Ersatzseite als geladen markieren, damit die App nicht hängt
|
||||
notebook.pages[page_idx].is_loaded = true;
|
||||
notebook.pages[page_idx].rebuild_cache_if_dirty();
|
||||
// Ersatzseite einsetzen, damit die App nicht hängt
|
||||
let background = notebook.pages[page_idx].background;
|
||||
let mut page = replacement_page_after_load_error(notebook_dir, page_id, &e);
|
||||
page.background = background;
|
||||
page.rebuild_cache_if_dirty();
|
||||
notebook.pages[page_idx] = page;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt eine leere Ersatzseite für eine Seite, deren Datei nicht geladen werden konnte.
|
||||
///
|
||||
/// - Fehlende Datei: normale leere Seite (z. B. neu angelegt, aber noch nie gespeichert).
|
||||
/// - Beschädigtes JSON: Die Datei wird als `page_<id>.json.corrupt-<zeit>` beiseite gelegt,
|
||||
/// danach ist die leere Seite normal beschreibbar.
|
||||
/// - Sonstige Fehler (I/O) oder fehlgeschlagenes Beiseitelegen: Die Ersatzseite wird als
|
||||
/// `load_failed` markiert und nie gespeichert, damit die Originaldatei erhalten bleibt.
|
||||
fn replacement_page_after_load_error(
|
||||
notebook_dir: &Path,
|
||||
page_id: u64,
|
||||
err: &StorageError,
|
||||
) -> Page {
|
||||
let mut page = Page::new(page_id, PageBackground::Grid5mm);
|
||||
match err {
|
||||
StorageError::PageNotFound(_) => {}
|
||||
StorageError::CorruptedPage(..) => {
|
||||
let path = notebook_dir.join(format!("page_{page_id}.json"));
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let backup = notebook_dir.join(format!("page_{page_id}.json.corrupt-{now}"));
|
||||
match fs::rename(&path, &backup) {
|
||||
Ok(()) => eprintln!(
|
||||
"[Storage] Beschädigte Seite gesichert als '{}'.",
|
||||
backup.display()
|
||||
),
|
||||
Err(e) => {
|
||||
eprintln!("[Storage] Konnte beschädigte Seite nicht sichern ({e}). Seite wird nicht gespeichert.");
|
||||
page.load_failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => page.load_failed = true,
|
||||
}
|
||||
page
|
||||
}
|
||||
|
||||
/// 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.load_failed {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Gibt das Standard-Verzeichnis für Notizbücher zurück (gemäß XDG-Spezifikation).
|
||||
pub fn default_storage_dir() -> PathBuf {
|
||||
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
||||
@@ -712,7 +781,7 @@ mod tests {
|
||||
controller.update();
|
||||
|
||||
// Flush abwarten
|
||||
controller.flush();
|
||||
controller.flush().expect("Flush muss gelingen");
|
||||
|
||||
// Es darf genau EIN tatsächlicher Schreibvorgang für die Seite stattgefunden haben
|
||||
assert_eq!(
|
||||
@@ -773,4 +842,190 @@ mod tests {
|
||||
assert_eq!(final_summaries.len(), 1);
|
||||
assert_eq!(final_summaries[0].id, m1.id);
|
||||
}
|
||||
|
||||
/// Hilfsfunktion: Seite mit einem einzelnen Strich.
|
||||
fn page_with_stroke(page_id: u64, stroke_id: u64) -> Page {
|
||||
let mut page = Page::new(page_id, PageBackground::Lined);
|
||||
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 = reconstruct_stroke_vertices(&samples, [0.0, 0.0, 0.0, 1.0]);
|
||||
page.add_stroke(Stroke::new(
|
||||
stroke_id,
|
||||
samples,
|
||||
vertices,
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
));
|
||||
page
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_corrupted_page_is_quarantined_not_overwritten() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let dir = temp_dir.path();
|
||||
|
||||
let mut notebook = Notebook::new();
|
||||
notebook.add_page(PageBackground::Grid5mm);
|
||||
let mut manifest = NotebookManifest::new("nb", "Test");
|
||||
save_notebook(dir, ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
let corrupted = br#"{"id": 2, "background": "Lined", "strokes": [{"id": 1"#;
|
||||
fs::write(dir.join("page_2.json"), corrupted).unwrap();
|
||||
|
||||
let (mut loaded, loaded_manifest) = load_notebook(dir).unwrap();
|
||||
assert!(ensure_page_loaded(dir, &mut loaded, 1).is_err());
|
||||
|
||||
// Original wurde beiseite gelegt und bleibt Byte für Byte erhalten
|
||||
let backups: Vec<_> = fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("page_2.json.corrupt-")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(backups.len(), 1);
|
||||
assert_eq!(fs::read(backups[0].path()).unwrap(), corrupted);
|
||||
|
||||
// Ersatzseite ist normal beschreibbar
|
||||
assert!(!loaded.pages[1].load_failed);
|
||||
let mut autosave = AutosaveController::new(dir.to_path_buf(), Duration::from_secs(60));
|
||||
loaded.current_page_index = 1;
|
||||
autosave.mark_dirty(&loaded, &loaded_manifest);
|
||||
autosave.flush().unwrap();
|
||||
assert!(load_page(dir, 2).is_ok());
|
||||
autosave.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unreadable_page_is_never_overwritten() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let dir = temp_dir.path();
|
||||
|
||||
let mut notebook = Notebook::new();
|
||||
notebook.add_page(PageBackground::Grid5mm);
|
||||
let mut manifest = NotebookManifest::new("nb", "Test");
|
||||
save_notebook(dir, ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
// Unlesbare Seite simulieren (I/O-Fehler): Inhalt liegt in einer Datei, die wir
|
||||
// durch ein gleichnamiges Verzeichnis ersetzen
|
||||
fs::remove_file(dir.join("page_2.json")).unwrap();
|
||||
fs::create_dir(dir.join("page_2.json")).unwrap();
|
||||
fs::write(dir.join("page_2.json").join("marker"), b"original").unwrap();
|
||||
|
||||
let (mut loaded, loaded_manifest) = load_notebook(dir).unwrap();
|
||||
assert!(ensure_page_loaded(dir, &mut loaded, 1).is_err());
|
||||
assert!(loaded.pages[1].load_failed);
|
||||
|
||||
// Navigation + Autosave + Strg+S dürfen die Seite nicht anfassen
|
||||
loaded.current_page_index = 1;
|
||||
let mut autosave = AutosaveController::new(dir.to_path_buf(), Duration::from_secs(60));
|
||||
autosave.mark_dirty(&loaded, &loaded_manifest);
|
||||
autosave.save_now(&loaded, &loaded_manifest).unwrap();
|
||||
save_notebook(dir, &loaded, &mut loaded_manifest.clone()).unwrap();
|
||||
autosave.shutdown();
|
||||
|
||||
assert_eq!(
|
||||
fs::read(dir.join("page_2.json").join("marker")).unwrap(),
|
||||
b"original"
|
||||
);
|
||||
// Die Seite bleibt im Manifest referenziert
|
||||
assert_eq!(load_manifest(dir).unwrap().page_ids, vec![1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_autosave_records_new_pages_in_manifest() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let dir = temp_dir.path();
|
||||
|
||||
let mut notebook = Notebook::new();
|
||||
let mut manifest = NotebookManifest::new("nb", "Test");
|
||||
save_notebook(dir, ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
// Wie in main.rs: neue Seite per Navigation, Strich darauf, Autosave
|
||||
notebook.next_page();
|
||||
notebook.pages[1] = page_with_stroke(notebook.pages[1].id, 1);
|
||||
let mut autosave = AutosaveController::new(dir.to_path_buf(), Duration::from_secs(60));
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
autosave.shutdown();
|
||||
|
||||
let (mut reloaded, reloaded_manifest) = load_notebook(dir).unwrap();
|
||||
assert_eq!(reloaded_manifest.page_ids, vec![1, 2]);
|
||||
assert_eq!(reloaded.pages.len(), 2);
|
||||
ensure_page_loaded(dir, &mut reloaded, 1).unwrap();
|
||||
assert_eq!(reloaded.pages[1].strokes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flush_and_save_now_report_write_errors() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
// Zielpfad ist eine Datei -> Schreiben ins "Notizbuch-Verzeichnis" muss fehlschlagen
|
||||
let blocked = temp_dir.path().join("blocked");
|
||||
fs::write(&blocked, b"").unwrap();
|
||||
|
||||
let notebook = Notebook::new();
|
||||
let manifest = NotebookManifest::new("nb", "Test");
|
||||
let mut autosave = AutosaveController::new(blocked, Duration::from_secs(60));
|
||||
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
assert!(autosave.flush().is_err());
|
||||
assert_eq!(autosave.status(), crate::autosave::AutosaveStatus::Error);
|
||||
|
||||
assert!(autosave.save_now(¬ebook, &manifest).is_err());
|
||||
assert_eq!(autosave.status(), crate::autosave::AutosaveStatus::Error);
|
||||
autosave.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deleted_notebook_is_not_recreated_by_pending_autosave() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let base = temp_dir.path();
|
||||
let (notebook, manifest, dir) = create_new_notebook(base, "Weg damit").unwrap();
|
||||
let (_, _, other_dir) = create_new_notebook(base, "Bleibt").unwrap();
|
||||
|
||||
let mut autosave = AutosaveController::new(dir.clone(), Duration::from_secs(60));
|
||||
autosave.mark_dirty(¬ebook, &manifest);
|
||||
|
||||
// Ablauf wie in main.rs beim Löschen des aktuellen Notizbuchs
|
||||
autosave.flush().unwrap();
|
||||
delete_notebook(&dir).unwrap();
|
||||
autosave.switch_notebook_dir(other_dir).unwrap();
|
||||
autosave.shutdown();
|
||||
|
||||
assert!(
|
||||
!dir.exists(),
|
||||
"Gelöschtes Notizbuch darf nicht wieder auftauchen"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_all_pages_for_export() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let dir = temp_dir.path();
|
||||
|
||||
let mut notebook = Notebook::new();
|
||||
notebook.add_page(PageBackground::Lined);
|
||||
notebook.pages[1] = page_with_stroke(2, 1);
|
||||
let mut manifest = NotebookManifest::new("nb", "Test");
|
||||
save_notebook(dir, ¬ebook, &mut manifest).unwrap();
|
||||
|
||||
let (mut loaded, _) = load_notebook(dir).unwrap();
|
||||
assert!(!loaded.pages[1].is_loaded);
|
||||
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());
|
||||
}
|
||||
}
|
||||
+194
-59
@@ -7,7 +7,7 @@
|
||||
//! - Sichere Asset-Namensbereinigung (Path Traversal Schutz)
|
||||
//! - 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
|
||||
//! - 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`
|
||||
//! - Echtes Fortschritts-Streaming (~10 Updates/s)
|
||||
//! - Korrekte SemVer-Prüfung via `semver` Crate (Prerelease-Schutz standardmäßig aktiv)
|
||||
@@ -313,22 +313,77 @@ pub fn verify_checksum(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Status einer Signaturverifikation (.sig / .minisig).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SignatureVerificationStatus {
|
||||
NotPresent,
|
||||
Verified { signer: String },
|
||||
Failed(String),
|
||||
/// Öffentlicher Minisign-Schlüssel (Base64), mit dem die `SHA256SUMS.txt` jedes Releases
|
||||
/// signiert sein muss. Ist er leer, lehnt diese Version alle In-App-Updates ab.
|
||||
/// Erzeugen: `minisign -G -W -p ink-notes.pub -s ink-notes.key`, dann die zweite Zeile
|
||||
/// aus `ink-notes.pub` hier eintragen (siehe README, Abschnitt Release-Signatur).
|
||||
pub const UPDATE_SIGNING_PUBLIC_KEY: &str = "";
|
||||
|
||||
/// 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.
|
||||
pub fn verify_signature_if_present(
|
||||
_asset_file: &Path,
|
||||
_sig_bytes: Option<&[u8]>,
|
||||
) -> Result<SignatureVerificationStatus, String> {
|
||||
// Erweiterungspunkt: Sobald ein Signaturschlüssel / Minisign-Public-Key
|
||||
// im Projekt konfiguriert ist, kann hier die kryptografische Verifikation erfolgen.
|
||||
Ok(SignatureVerificationStatus::NotPresent)
|
||||
/// Lädt einen Text-Asset des Releases über den abgesicherten HTTP-Client.
|
||||
fn fetch_release_text(
|
||||
agent: &ureq::Agent,
|
||||
release: &GiteaRelease,
|
||||
name: &str,
|
||||
config: &UpdaterConfig,
|
||||
) -> Result<String, String> {
|
||||
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.
|
||||
@@ -368,6 +423,17 @@ pub enum UpdaterStatus {
|
||||
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.
|
||||
fn create_http_agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new()
|
||||
@@ -384,12 +450,15 @@ fn http_get_with_auth_guard(
|
||||
initial_url_str: &str,
|
||||
token: Option<&str>,
|
||||
max_redirects: usize,
|
||||
allow_insecure_http: bool,
|
||||
) -> Result<ureq::Response, String> {
|
||||
let mut current_url = url::Url::parse(initial_url_str)
|
||||
.map_err(|e| format!("Ungültige URL '{initial_url_str}': {e}"))?;
|
||||
let initial_origin = current_url.origin().ascii_serialization();
|
||||
|
||||
for _ in 0..=max_redirects {
|
||||
// Jede Anfrage prüfen – auch Download-Links aus der API und Weiterleitungsziele
|
||||
ensure_secure_scheme(¤t_url, allow_insecure_http)?;
|
||||
let mut req = agent.get(current_url.as_str());
|
||||
|
||||
// Token NUR anhängen, wenn Origin (Scheme, Host, Port) identisch ist
|
||||
@@ -500,7 +569,13 @@ impl UpdaterController {
|
||||
);
|
||||
|
||||
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(release) => {
|
||||
if release.prerelease && !config.include_prereleases {
|
||||
@@ -563,12 +638,37 @@ impl UpdaterController {
|
||||
let target_file = cache_dir.join(&safe_asset_name);
|
||||
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(
|
||||
&agent,
|
||||
&asset.browser_download_url,
|
||||
config.auth_token.as_deref(),
|
||||
5,
|
||||
config.allow_insecure_http,
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -646,53 +746,13 @@ impl UpdaterController {
|
||||
|
||||
drop(file);
|
||||
|
||||
// 2. Integritätsprüfung gegen 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;
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Integritätsprüfung des Pakets gegen die signierte SHA256SUMS.txt
|
||||
if let Err(err) = verify_checksum(&target_file, &safe_asset_name, &checksums_content) {
|
||||
let _ = tx.send(UpdaterEvent::DownloadError(err));
|
||||
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") {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -996,6 +1056,81 @@ dcdeb7ac87d9e6cb8022e5a71bc7f2c56d779b4fce4efdd5bae5be3b0214884a ink-notes_0.1.
|
||||
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]
|
||||
fn test_appimage_atomic_self_update() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in new issue
Block a user