Compare commits
18
Commits
12755d8eea
...
v0.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e35f2e77 | ||
|
|
a77e6cde90 | ||
|
|
40eb9a666f | ||
|
|
85d1ba1647 | ||
|
|
b03faedd27 | ||
|
|
f17e50ec32 | ||
|
|
5cd45831e2 | ||
|
|
71f6a70829 | ||
|
|
b751b71223 | ||
|
|
afb057692d | ||
|
|
eeae143dc6 | ||
|
|
992c3daad7 | ||
|
|
9684238a53 | ||
|
|
e8674e730f | ||
|
|
7e99d59042 | ||
|
|
7ed971d2a2 | ||
|
|
5ce2c250bb | ||
|
|
d61e1819ed |
No files matched your search
@@ -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
@@ -1048,7 +1048,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.1.1"
|
version = "0.3.2"
|
||||||
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
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "ink-notes"
|
name = "ink-notes"
|
||||||
version = "0.1.1"
|
version = "0.3.2"
|
||||||
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]
|
||||||
|
|||||||
@@ -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,12 +3,16 @@
|
|||||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||||
|
|
||||||
### Highlights:
|
### Highlights:
|
||||||
|
- **Neue Werkzeugleiste**: Schmale senkrechte Leiste mit Symbol-Knöpfen, links oder rechts andockbar; selten genutzte Funktionen im ☰-Menü, das Blatt wird daneben zentriert
|
||||||
|
- **Einheitliche Dialoge**: Deckende Fenster ohne doppelte Titel, Seitenauswahl als Kachelraster, größere Bedienelemente in allen Popovern
|
||||||
|
- **UI per Stift und Touch bedienbar**: Werkzeugleiste, Popover und Dialoge reagieren jetzt auch auf Stift und Finger, auch bei Display-Skalierung
|
||||||
- **Aktive Stylus-Unterstützung (Lazarite / MPP / AES)**: Natives Wayland-Tablet-Protokoll mit präziser Druck- und Neigungserfassung
|
- **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
|
- **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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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!"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+29
-8
@@ -94,6 +94,9 @@ pub struct CanvasState {
|
|||||||
pub config: wgpu::SurfaceConfiguration,
|
pub config: wgpu::SurfaceConfiguration,
|
||||||
pub size: PhysicalSize<u32>,
|
pub size: PhysicalSize<u32>,
|
||||||
pub viewport: PageViewport,
|
pub viewport: PageViewport,
|
||||||
|
/// Von seitlicher UI belegte Breite links/rechts in physischen Pixeln
|
||||||
|
ui_inset_left: f32,
|
||||||
|
ui_inset_right: f32,
|
||||||
pub camera: crate::viewport::ViewportState,
|
pub camera: crate::viewport::ViewportState,
|
||||||
|
|
||||||
// Pipelines
|
// Pipelines
|
||||||
@@ -371,6 +374,8 @@ impl CanvasState {
|
|||||||
config,
|
config,
|
||||||
size,
|
size,
|
||||||
viewport,
|
viewport,
|
||||||
|
ui_inset_left: 0.0,
|
||||||
|
ui_inset_right: 0.0,
|
||||||
strip_pipeline,
|
strip_pipeline,
|
||||||
triangle_pipeline,
|
triangle_pipeline,
|
||||||
camera_buffer,
|
camera_buffer,
|
||||||
@@ -418,6 +423,29 @@ impl CanvasState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Setzt die von seitlicher UI belegten Streifen (physische Pixel), damit die Seite
|
||||||
|
/// im freien Bereich zentriert wird. Gibt `true` zurück, wenn sich etwas geändert hat.
|
||||||
|
pub fn set_ui_insets(&mut self, left: f32, right: f32) -> bool {
|
||||||
|
if (self.ui_inset_left - left).abs() < 0.5 && (self.ui_inset_right - right).abs() < 0.5 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.ui_inset_left = left;
|
||||||
|
self.ui_inset_right = right;
|
||||||
|
self.recalculate_viewport();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recalculate_viewport(&mut self) {
|
||||||
|
self.viewport = PageViewport::calculate_with_insets(
|
||||||
|
self.size.width as f32,
|
||||||
|
self.size.height as f32,
|
||||||
|
Self::VIEWPORT_MARGIN,
|
||||||
|
self.ui_inset_left,
|
||||||
|
self.ui_inset_right,
|
||||||
|
);
|
||||||
|
self.update_uniforms();
|
||||||
|
}
|
||||||
|
|
||||||
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
/// Passt die Surface und Projektionsmatrizen an eine neue Fenstergröße an.
|
||||||
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
pub fn resize(&mut self, new_size: PhysicalSize<u32>) {
|
||||||
if new_size.width > 0 && new_size.height > 0 {
|
if new_size.width > 0 && new_size.height > 0 {
|
||||||
@@ -425,14 +453,7 @@ impl CanvasState {
|
|||||||
self.config.width = new_size.width;
|
self.config.width = new_size.width;
|
||||||
self.config.height = new_size.height;
|
self.config.height = new_size.height;
|
||||||
self.surface.configure(&self.device, &self.config);
|
self.surface.configure(&self.device, &self.config);
|
||||||
|
self.recalculate_viewport();
|
||||||
self.viewport = PageViewport::calculate(
|
|
||||||
new_size.width as f32,
|
|
||||||
new_size.height as f32,
|
|
||||||
Self::VIEWPORT_MARGIN,
|
|
||||||
);
|
|
||||||
|
|
||||||
self.update_uniforms();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -772,7 +772,21 @@ pub struct PageViewport {
|
|||||||
impl PageViewport {
|
impl PageViewport {
|
||||||
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
/// Berechnet die zentrierte DIN-A4-Projektionsfläche unter Beibehaltung des 210x297-Seitenverhältnisses.
|
||||||
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
pub fn calculate(window_width: f32, window_height: f32, margin: f32) -> Self {
|
||||||
let avail_w = (window_width - 2.0 * margin).max(10.0);
|
Self::calculate_with_insets(window_width, window_height, margin, 0.0, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie [`Self::calculate`], aber ohne die von seitlicher UI (z. B. Werkzeugleiste)
|
||||||
|
/// belegten Streifen links/rechts: Die Seite wird im verbleibenden Bereich zentriert.
|
||||||
|
pub fn calculate_with_insets(
|
||||||
|
window_width: f32,
|
||||||
|
window_height: f32,
|
||||||
|
margin: f32,
|
||||||
|
inset_left: f32,
|
||||||
|
inset_right: f32,
|
||||||
|
) -> Self {
|
||||||
|
let inset_left = inset_left.clamp(0.0, window_width * 0.5);
|
||||||
|
let inset_right = inset_right.clamp(0.0, window_width * 0.5);
|
||||||
|
let avail_w = (window_width - inset_left - inset_right - 2.0 * margin).max(10.0);
|
||||||
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
let avail_h = (window_height - 2.0 * margin).max(10.0);
|
||||||
|
|
||||||
let window_ratio = avail_w / avail_h;
|
let window_ratio = avail_w / avail_h;
|
||||||
@@ -790,7 +804,7 @@ impl PageViewport {
|
|||||||
(w, h)
|
(w, h)
|
||||||
};
|
};
|
||||||
|
|
||||||
let page_x = (window_width - page_w) / 2.0;
|
let page_x = inset_left + (window_width - inset_left - inset_right - page_w) / 2.0;
|
||||||
let page_y = (window_height - page_h) / 2.0;
|
let page_y = (window_height - page_h) / 2.0;
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -931,6 +945,25 @@ mod tests {
|
|||||||
assert_eq!(nb.current_page_index, 0);
|
assert_eq!(nb.current_page_index, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_viewport_insets_center_page_in_free_area() {
|
||||||
|
// Breites Fenster: Seite wird durch die Höhe begrenzt und im freien Bereich zentriert
|
||||||
|
let plain = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
|
let inset = PageViewport::calculate_with_insets(1600.0, 900.0, 20.0, 120.0, 0.0);
|
||||||
|
assert!((inset.page_width - plain.page_width).abs() < 0.01);
|
||||||
|
let free_center = 120.0 + (1600.0 - 120.0) / 2.0;
|
||||||
|
assert!((inset.page_x + inset.page_width / 2.0 - free_center).abs() < 0.01);
|
||||||
|
|
||||||
|
// Hohes Fenster: Breite begrenzt, Seite wird schmaler und bleibt rechts der Leiste
|
||||||
|
let narrow = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 100.0, 0.0);
|
||||||
|
assert!(narrow.page_x >= 100.0 + 20.0 - 0.01);
|
||||||
|
assert!(narrow.page_x + narrow.page_width <= 600.0 - 20.0 + 0.01);
|
||||||
|
|
||||||
|
// Leiste rechts
|
||||||
|
let right = PageViewport::calculate_with_insets(600.0, 1200.0, 20.0, 0.0, 100.0);
|
||||||
|
assert!(right.page_x + right.page_width <= 500.0 - 20.0 + 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_page_viewport_projection() {
|
fn test_page_viewport_projection() {
|
||||||
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
let vp = PageViewport::calculate(1600.0, 900.0, 20.0);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod filter;
|
|||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
|
pub mod settings;
|
||||||
pub mod spline;
|
pub mod spline;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|||||||
+260
-158
@@ -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::{
|
||||||
@@ -21,6 +23,7 @@ use ink_notes::geometry::{self, RecognizedShape};
|
|||||||
use ink_notes::selection::{
|
use ink_notes::selection::{
|
||||||
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
render_selection_overlay, DragMode, SelectionHandleHit, SelectionState,
|
||||||
};
|
};
|
||||||
|
use ink_notes::settings::{SidebarSide, UiSettings};
|
||||||
use ink_notes::storage::{self, NotebookManifest};
|
use ink_notes::storage::{self, NotebookManifest};
|
||||||
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
use ink_notes::ui::{self, ActiveTool, UiAction, UiState};
|
||||||
use ink_notes::updater::UpdaterController;
|
use ink_notes::updater::UpdaterController;
|
||||||
@@ -52,6 +55,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(¬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)
|
/// 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 +124,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 +166,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 +216,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();
|
||||||
|
|
||||||
@@ -175,9 +231,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut ui_state = UiState::new();
|
let mut ui_state = UiState::new();
|
||||||
|
// Gespeicherte UI-Einstellungen (Seite der Werkzeugleiste)
|
||||||
|
let ui_settings_path = UiSettings::default_path();
|
||||||
|
let mut ui_settings = ui_settings_path
|
||||||
|
.as_deref()
|
||||||
|
.map(UiSettings::load_from)
|
||||||
|
.unwrap_or_default();
|
||||||
|
ui_state.sidebar_side = ui_settings.sidebar_side;
|
||||||
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) {
|
||||||
@@ -188,7 +255,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
Err(storage::StorageError::AlreadyLocked(msg)) => {
|
||||||
eprintln!("[Storage-Lock] {msg}");
|
eprintln!("[Storage-Lock] {msg}");
|
||||||
autosave.set_read_only(true);
|
autosave.set_read_only(true);
|
||||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -216,6 +283,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
|
let mut active_touches: std::collections::HashMap<u64, (f32, f32)> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
let mut pinch_prev_distance: Option<f32> = None;
|
let mut pinch_prev_distance: Option<f32> = None;
|
||||||
|
// Berührungen, die auf der UI begonnen haben, gehören bis zum Abheben egui
|
||||||
|
let mut ui_touch_ids: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||||
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
let mut pinch_prev_center: Option<(f32, f32)> = None;
|
||||||
let mut is_pinch_gesturing = false;
|
let mut is_pinch_gesturing = false;
|
||||||
let mut is_touch_drawing = false;
|
let mut is_touch_drawing = false;
|
||||||
@@ -228,6 +297,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
let mut stylus_tool_id: Option<octotablet::tool::ID> = None;
|
||||||
let mut stylus_barrel_pressed = false;
|
let mut stylus_barrel_pressed = false;
|
||||||
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
let mut stylus_screen_pos = (0.0_f32, 0.0_f32);
|
||||||
|
// Stift hat auf der UI aufgesetzt: alle Ereignisse bis zum Abheben gehen an egui
|
||||||
|
let mut stylus_ui_capture = false;
|
||||||
|
// Von egui angeforderter Zeitpunkt für das nächste Neuzeichnen (Layout-Vermessung, Tooltips)
|
||||||
|
let mut egui_repaint_at: Option<Instant> = None;
|
||||||
|
|
||||||
// Viewport-Pan & Zoom-Zustand
|
// Viewport-Pan & Zoom-Zustand
|
||||||
let mut is_panning = false;
|
let mut is_panning = false;
|
||||||
@@ -287,6 +360,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, new_status);
|
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();
|
let prev_upd = updater.status().clone();
|
||||||
updater.update();
|
updater.update();
|
||||||
if *updater.status() != prev_upd {
|
if *updater.status() != prev_upd {
|
||||||
@@ -312,6 +393,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
octotablet::events::ToolEvent::Out
|
octotablet::events::ToolEvent::Out
|
||||||
| octotablet::events::ToolEvent::Removed => {
|
| octotablet::events::ToolEvent::Removed => {
|
||||||
is_stylus_in_proximity = false;
|
is_stylus_in_proximity = false;
|
||||||
|
if stylus_ui_capture {
|
||||||
|
stylus_ui_capture = false;
|
||||||
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
let input = egui_winit_state.egui_input_mut();
|
||||||
|
input.events.push(egui::Event::PointerButton {
|
||||||
|
pos: egui::pos2(sx / scale, sy / scale),
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
|
pressed: false,
|
||||||
|
modifiers: egui::Modifiers::default(),
|
||||||
|
});
|
||||||
|
input.events.push(egui::Event::PointerGone);
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
if stylus_tool_id == Some(tool.id()) {
|
if stylus_tool_id == Some(tool.id()) {
|
||||||
stylus_tool_id = None;
|
stylus_tool_id = None;
|
||||||
if is_stylus_drawing {
|
if is_stylus_drawing {
|
||||||
@@ -359,6 +453,24 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
current_pressure = p;
|
current_pressure = p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stiftbewegung an egui weitergeben (Hover, Schieberegler ziehen),
|
||||||
|
// solange nicht auf dem Blatt gezeichnet oder radiert wird
|
||||||
|
if stylus_ui_capture || (!is_stylus_drawing && !is_erasing) {
|
||||||
|
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||||
|
egui_winit_state
|
||||||
|
.egui_input_mut()
|
||||||
|
.events
|
||||||
|
.push(egui::Event::PointerMoved(ui_pos));
|
||||||
|
if stylus_ui_capture
|
||||||
|
|| ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale)
|
||||||
|
{
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stylus_ui_capture {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if is_erasing {
|
if is_erasing {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
let page = notebook.current_page_mut();
|
let page = notebook.current_page_mut();
|
||||||
@@ -369,10 +481,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(¬ebook, &mut manifest);
|
||||||
notebook.current_page(),
|
|
||||||
&manifest,
|
|
||||||
);
|
|
||||||
update_window_title(
|
update_window_title(
|
||||||
&window,
|
&window,
|
||||||
¬ebook,
|
¬ebook,
|
||||||
@@ -408,28 +517,19 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
stylus_tool_id = Some(tool.id());
|
stylus_tool_id = Some(tool.id());
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
|
|
||||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
if ui::is_screen_point_in_ui(&egui_ctx, &ui_state, (sx, sy), scale) {
|
||||||
|| ui_state.is_point_in_ui(sx, sy)
|
// In die Eingabe-Warteschlange des NÄCHSTEN egui-Frames
|
||||||
|| (ui_state.show_page_jump_popup
|
// (egui_ctx.input_mut würde vom nächsten Frame überschrieben)
|
||||||
&& egui_ctx.is_pointer_over_area())
|
let ui_pos = egui::pos2(sx / scale, sy / scale);
|
||||||
|| (ui_state.is_document_menu_open
|
let input = egui_winit_state.egui_input_mut();
|
||||||
&& egui_ctx.is_pointer_over_area())
|
input.events.push(egui::Event::PointerMoved(ui_pos));
|
||||||
|| (ui_state.is_update_dialog_open
|
input.events.push(egui::Event::PointerButton {
|
||||||
&& egui_ctx.is_pointer_over_area());
|
pos: ui_pos,
|
||||||
|
button: egui::PointerButton::Primary,
|
||||||
if is_stylus_in_ui {
|
pressed: true,
|
||||||
egui_ctx.input_mut(|i| {
|
modifiers: egui::Modifiers::default(),
|
||||||
i.events.push(egui::Event::PointerMoved(egui::pos2(
|
|
||||||
sx / scale,
|
|
||||||
sy / scale,
|
|
||||||
)));
|
|
||||||
i.events.push(egui::Event::PointerButton {
|
|
||||||
pos: egui::pos2(sx / scale, sy / scale),
|
|
||||||
button: egui::PointerButton::Primary,
|
|
||||||
pressed: true,
|
|
||||||
modifiers: egui::Modifiers::default(),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
stylus_ui_capture = true;
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -449,10 +549,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(¬ebook, &mut manifest);
|
||||||
notebook.current_page(),
|
|
||||||
&manifest,
|
|
||||||
);
|
|
||||||
update_window_title(
|
update_window_title(
|
||||||
&window,
|
&window,
|
||||||
¬ebook,
|
¬ebook,
|
||||||
@@ -485,24 +582,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
octotablet::events::ToolEvent::Up => {
|
octotablet::events::ToolEvent::Up => {
|
||||||
let (sx, sy) = stylus_screen_pos;
|
let (sx, sy) = stylus_screen_pos;
|
||||||
let is_stylus_in_ui = egui_ctx.wants_pointer_input()
|
if stylus_ui_capture {
|
||||||
|| ui_state.is_point_in_ui(sx, sy)
|
stylus_ui_capture = false;
|
||||||
|| (ui_state.show_page_jump_popup
|
egui_winit_state.egui_input_mut().events.push(
|
||||||
&& egui_ctx.is_pointer_over_area())
|
egui::Event::PointerButton {
|
||||||
|| (ui_state.is_document_menu_open
|
|
||||||
&& egui_ctx.is_pointer_over_area())
|
|
||||||
|| (ui_state.is_update_dialog_open
|
|
||||||
&& egui_ctx.is_pointer_over_area());
|
|
||||||
|
|
||||||
if is_stylus_in_ui {
|
|
||||||
egui_ctx.input_mut(|i| {
|
|
||||||
i.events.push(egui::Event::PointerButton {
|
|
||||||
pos: egui::pos2(sx / scale, sy / scale),
|
pos: egui::pos2(sx / scale, sy / scale),
|
||||||
button: egui::PointerButton::Primary,
|
button: egui::PointerButton::Primary,
|
||||||
pressed: false,
|
pressed: false,
|
||||||
modifiers: egui::Modifiers::default(),
|
modifiers: egui::Modifiers::default(),
|
||||||
});
|
},
|
||||||
});
|
);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -572,6 +661,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Von egui angefordertes, verzögertes Neuzeichnen (Tooltips, Animationen)
|
||||||
|
if let Some(at) = egui_repaint_at {
|
||||||
|
if Instant::now() >= at {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
|
// Bei anstehenden Änderungen, aktivem Toast, Stylus in Proximity oder aktivem Zeichnen regelmäßig aufwachen
|
||||||
if new_status == AutosaveStatus::Pending
|
if new_status == AutosaveStatus::Pending
|
||||||
|| new_status == AutosaveStatus::Saving
|
|| new_status == AutosaveStatus::Saving
|
||||||
@@ -581,6 +678,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|| is_stylus_in_proximity
|
|| is_stylus_in_proximity
|
||||||
{
|
{
|
||||||
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
target.set_control_flow(ControlFlow::wait_duration(Duration::from_millis(8)));
|
||||||
|
} else if let Some(at) = egui_repaint_at {
|
||||||
|
target.set_control_flow(ControlFlow::WaitUntil(at));
|
||||||
} else {
|
} else {
|
||||||
target.set_control_flow(ControlFlow::Wait);
|
target.set_control_flow(ControlFlow::Wait);
|
||||||
}
|
}
|
||||||
@@ -596,9 +695,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
// 2. Prüfen, ob das UI Pointer-Interaktionen beansprucht (Event-Isolation)
|
||||||
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
// Konsumiert Klicks innerhalb der abgerundeten Pill-Toolbar, des Popovers oder offener Menüs
|
||||||
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
let is_pointer_in_ui = egui_ctx.wants_pointer_input()
|
||||||
|| ui_state.is_point_in_ui(cursor_screen_pos.0, cursor_screen_pos.1)
|
|| ui::is_screen_point_in_ui(
|
||||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
&egui_ctx,
|
||||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area());
|
&ui_state,
|
||||||
|
cursor_screen_pos,
|
||||||
|
window.scale_factor() as f32,
|
||||||
|
);
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
@@ -625,6 +727,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
is_erasing = false;
|
is_erasing = false;
|
||||||
is_mouse_drawing = false;
|
is_mouse_drawing = false;
|
||||||
active_touches.clear();
|
active_touches.clear();
|
||||||
|
ui_touch_ids.clear();
|
||||||
|
stylus_ui_capture = false;
|
||||||
is_pinch_gesturing = false;
|
is_pinch_gesturing = false;
|
||||||
pinch_prev_distance = None;
|
pinch_prev_distance = None;
|
||||||
pinch_prev_center = None;
|
pinch_prev_center = None;
|
||||||
@@ -713,7 +817,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -727,7 +831,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -757,7 +861,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(¬ebook, &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 +873,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(¬ebook, &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 +905,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(¬ebook, &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(
|
|
||||||
¬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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +942,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(¬ebook, &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 +962,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(¬ebook, &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 +981,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(¬ebook, &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 +1005,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -945,8 +1019,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -958,8 +1033,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -972,8 +1048,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -986,8 +1063,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1000,8 +1078,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1090,7 +1169,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1101,7 +1180,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// 2. Objektauswahl & Transformation im Select-Modus
|
// 2. Objektauswahl & Transformation im Select-Modus
|
||||||
if ui_state.active_tool == ActiveTool::Select {
|
if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(sx, sy);
|
let screen_vec = Vec2::new(sx, sy);
|
||||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||||
match hit {
|
match hit {
|
||||||
SelectionHandleHit::RotateHandle { center_page } => {
|
SelectionHandleHit::RotateHandle { center_page } => {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
if let Some(page_pt) = canvas.screen_to_page(sx, sy) {
|
||||||
@@ -1188,7 +1267,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(¬ebook, &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 +1324,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(¬ebook, &mut manifest);
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1358,7 +1437,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1433,15 +1512,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Berührungen der UI (egui-winit hat sie bereits an egui weitergegeben)
|
||||||
|
if ui_touch_ids.contains(&touch.id) {
|
||||||
|
if matches!(touch.phase, TouchPhase::Ended | TouchPhase::Cancelled) {
|
||||||
|
ui_touch_ids.remove(&touch.id);
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match touch.phase {
|
match touch.phase {
|
||||||
TouchPhase::Started => {
|
TouchPhase::Started => {
|
||||||
// Event-Isolation für Touch-Eingaben
|
// Event-Isolation für Touch-Eingaben
|
||||||
let is_touch_in_ui = egui_ctx.wants_pointer_input()
|
let is_touch_in_ui = ui::is_screen_point_in_ui(
|
||||||
|| ui_state.is_point_in_ui(tx, ty)
|
&egui_ctx,
|
||||||
|| (ui_state.show_page_jump_popup && egui_ctx.is_pointer_over_area())
|
&ui_state,
|
||||||
|| (ui_state.is_document_menu_open && egui_ctx.is_pointer_over_area())
|
(tx, ty),
|
||||||
|| (ui_state.is_update_dialog_open && egui_ctx.is_pointer_over_area());
|
window.scale_factor() as f32,
|
||||||
|
);
|
||||||
if is_touch_in_ui {
|
if is_touch_in_ui {
|
||||||
|
ui_touch_ids.insert(touch.id);
|
||||||
|
window.request_redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1482,14 +1573,14 @@ 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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if ui_state.active_tool == ActiveTool::Select {
|
} else if ui_state.active_tool == ActiveTool::Select {
|
||||||
let screen_vec = Vec2::new(tx, ty);
|
let screen_vec = Vec2::new(tx, ty);
|
||||||
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec);
|
let hit = selection_state.hit_test_handles(notebook.current_page(), &canvas, screen_vec, egui_ctx.pixels_per_point());
|
||||||
match hit {
|
match hit {
|
||||||
SelectionHandleHit::RotateHandle { center_page } => {
|
SelectionHandleHit::RotateHandle { center_page } => {
|
||||||
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
if let Some(page_pt) = canvas.screen_to_page(tx, ty) {
|
||||||
@@ -1623,7 +1714,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1714,7 +1805,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(¬ebook, &mut manifest);
|
||||||
}
|
}
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1771,6 +1862,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
updater.update();
|
updater.update();
|
||||||
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater);
|
||||||
|
|
||||||
|
// Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln)
|
||||||
|
let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32;
|
||||||
|
let (inset_left, inset_right) = match ui_state.sidebar_side {
|
||||||
|
SidebarSide::Left => (reserved, 0.0),
|
||||||
|
SidebarSide::Right => (0.0, reserved),
|
||||||
|
};
|
||||||
|
if canvas.set_ui_insets(inset_left, inset_right) {
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
// 4. UI-Aktionen verarbeiten
|
// 4. UI-Aktionen verarbeiten
|
||||||
for action in actions {
|
for action in actions {
|
||||||
match action {
|
match action {
|
||||||
@@ -1798,7 +1899,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(¬ebook, &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 +1934,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(¬ebook, &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 +1961,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(¬ebook, &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 +1970,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(¬ebook, &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 +1998,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1906,7 +2007,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1918,8 +2019,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1939,8 +2041,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
background: Some(p.background),
|
background: Some(p.background),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
manifest.sync_from_notebook(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1959,8 +2062,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1971,8 +2075,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
autosave.mark_page_dirty(notebook.current_page(), &manifest);
|
autosave.mark_dirty(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
@@ -1981,43 +2086,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(¬ebook, &mut manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &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(¬ebook, &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(
|
|
||||||
¬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}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2043,7 +2118,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
eprintln!("[Storage-Lock] {msg}");
|
eprintln!("[Storage-Lock] {msg}");
|
||||||
_notebook_lock = None;
|
_notebook_lock = None;
|
||||||
autosave.set_read_only(true);
|
autosave.set_read_only(true);
|
||||||
ui_state.set_export_message("⚠️ In anderer Instanz geöffnet (Schreibschutz)");
|
ui_state.set_export_message("⚠ In anderer Instanz geöffnet (Schreibschutz)");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
eprintln!("[Storage-Lock] Konnte Lock nicht erwerben: {e}");
|
||||||
@@ -2107,6 +2182,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(¬ebook);
|
||||||
autosave.mark_manifest_dirty(&manifest);
|
autosave.mark_manifest_dirty(&manifest);
|
||||||
update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, autosave.status());
|
update_window_title(&window, ¬ebook, &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 +2341,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(¬ebook, &mut manifest);
|
||||||
window.request_redraw();
|
window.request_redraw();
|
||||||
}
|
}
|
||||||
UiAction::RetryShutdown => {
|
UiAction::RetryShutdown => {
|
||||||
@@ -2301,6 +2377,16 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UiAction::SetSidebarSide(side) => {
|
||||||
|
ui_settings.sidebar_side = side;
|
||||||
|
if let Some(path) = ui_settings_path.as_deref() {
|
||||||
|
if let Err(err) = ui_settings.save_to(path) {
|
||||||
|
eprintln!("[Einstellungen] Konnte '{}' nicht speichern: {err}", path.display());
|
||||||
|
ui_state.set_export_message(format!("Einstellung nicht gespeichert: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.request_redraw();
|
||||||
|
}
|
||||||
UiAction::ForceExit => {
|
UiAction::ForceExit => {
|
||||||
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
println!("[Shutdown] Beenden erzwungen trotz ungespeicherter Änderungen.");
|
||||||
target.exit();
|
target.exit();
|
||||||
@@ -2312,6 +2398,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let full_output = egui_ctx.end_frame();
|
let full_output = egui_ctx.end_frame();
|
||||||
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
egui_winit_state.handle_platform_output(&window, full_output.platform_output);
|
||||||
|
|
||||||
|
// egui braucht Folge-Frames, z. B. um neu angeordnete Bereiche zu vermessen
|
||||||
|
// (die Werkzeugleiste wird erst im 2.–3. Frame korrekt zentriert)
|
||||||
|
let repaint_delay = full_output
|
||||||
|
.viewport_output
|
||||||
|
.get(&egui::ViewportId::ROOT)
|
||||||
|
.map(|v| v.repaint_delay)
|
||||||
|
.unwrap_or(Duration::MAX);
|
||||||
|
if repaint_delay.is_zero() {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
window.request_redraw();
|
||||||
|
} else if repaint_delay < Duration::from_secs(10) {
|
||||||
|
egui_repaint_at = Some(Instant::now() + repaint_delay);
|
||||||
|
} else {
|
||||||
|
egui_repaint_at = None;
|
||||||
|
}
|
||||||
|
|
||||||
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
|
let clipped_primitives = egui_ctx.tessellate(full_output.shapes, egui_ctx.pixels_per_point());
|
||||||
let screen_descriptor = egui_wgpu::ScreenDescriptor {
|
let screen_descriptor = egui_wgpu::ScreenDescriptor {
|
||||||
size_in_pixels: [canvas.size.width, canvas.size.height],
|
size_in_pixels: [canvas.size.width, canvas.size.height],
|
||||||
|
|||||||
+28
-12
@@ -115,12 +115,15 @@ impl SelectionState {
|
|||||||
Page::selection_aabb(&strokes)
|
Page::selection_aabb(&strokes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten durch.
|
/// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten
|
||||||
|
/// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt
|
||||||
|
/// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen.
|
||||||
pub fn hit_test_handles(
|
pub fn hit_test_handles(
|
||||||
&self,
|
&self,
|
||||||
page: &Page,
|
page: &Page,
|
||||||
canvas: &CanvasState,
|
canvas: &CanvasState,
|
||||||
screen_pos: Vec2,
|
screen_pos: Vec2,
|
||||||
|
pixels_per_point: f32,
|
||||||
) -> SelectionHandleHit {
|
) -> SelectionHandleHit {
|
||||||
let Some(aabb) = self.get_selection_aabb(page) else {
|
let Some(aabb) = self.get_selection_aabb(page) else {
|
||||||
return SelectionHandleHit::None;
|
return SelectionHandleHit::None;
|
||||||
@@ -134,9 +137,10 @@ impl SelectionState {
|
|||||||
let screen_center = (screen_min + screen_max) * 0.5;
|
let screen_center = (screen_min + screen_max) * 0.5;
|
||||||
let page_center = (aabb.min + aabb.max) * 0.5;
|
let page_center = (aabb.min + aabb.max) * 0.5;
|
||||||
|
|
||||||
// 1. Drehgriff testen (26 px oberhalb des oberen Randes)
|
// 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet)
|
||||||
let rot_screen = Vec2::new(screen_center.x, screen_min.y - 26.0);
|
let ppp = pixels_per_point;
|
||||||
if (screen_pos - rot_screen).length() <= 14.0 {
|
let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp);
|
||||||
|
if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::RotateHandle {
|
return SelectionHandleHit::RotateHandle {
|
||||||
center_page: page_center,
|
center_page: page_center,
|
||||||
};
|
};
|
||||||
@@ -164,7 +168,7 @@ impl SelectionState {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for i in 0..4 {
|
for i in 0..4 {
|
||||||
if (screen_pos - corners_screen[i]).length() <= 12.0 {
|
if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp {
|
||||||
return SelectionHandleHit::CornerHandle {
|
return SelectionHandleHit::CornerHandle {
|
||||||
corner_idx: i,
|
corner_idx: i,
|
||||||
fixed_corner_page: fixed_corners_page[i],
|
fixed_corner_page: fixed_corners_page[i],
|
||||||
@@ -273,6 +277,16 @@ impl SelectionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
/// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui.
|
||||||
|
/// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte).
|
||||||
|
const ROTATE_HANDLE_OFFSET: f32 = 24.0;
|
||||||
|
/// Trefferradius der Griffe (logische Punkte), groß genug für Finger.
|
||||||
|
const HANDLE_HIT_RADIUS: f32 = 18.0;
|
||||||
|
|
||||||
|
/// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um.
|
||||||
|
fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 {
|
||||||
|
egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_selection_overlay(
|
pub fn render_selection_overlay(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
selection: &SelectionState,
|
selection: &SelectionState,
|
||||||
@@ -283,6 +297,8 @@ pub fn render_selection_overlay(
|
|||||||
egui::Order::Foreground,
|
egui::Order::Foreground,
|
||||||
egui::Id::new("selection_overlay_layer"),
|
egui::Id::new("selection_overlay_layer"),
|
||||||
));
|
));
|
||||||
|
// Fensterpixel sind physisch, egui zeichnet in logischen Punkten
|
||||||
|
let ppp = ctx.pixels_per_point();
|
||||||
|
|
||||||
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
// 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box)
|
||||||
if let DragMode::BoxSelect {
|
if let DragMode::BoxSelect {
|
||||||
@@ -290,8 +306,8 @@ pub fn render_selection_overlay(
|
|||||||
current_page,
|
current_page,
|
||||||
} = selection.drag_mode
|
} = selection.drag_mode
|
||||||
{
|
{
|
||||||
let p1 = canvas.page_to_screen(start_page);
|
let p1 = to_ui(canvas.page_to_screen(start_page), ppp);
|
||||||
let p2 = canvas.page_to_screen(current_page);
|
let p2 = to_ui(canvas.page_to_screen(current_page), ppp);
|
||||||
let min_x = p1[0].min(p2[0]);
|
let min_x = p1[0].min(p2[0]);
|
||||||
let min_y = p1[1].min(p2[1]);
|
let min_y = p1[1].min(p2[1]);
|
||||||
let max_x = p1[0].max(p2[0]);
|
let max_x = p1[0].max(p2[0]);
|
||||||
@@ -303,14 +319,14 @@ pub fn render_selection_overlay(
|
|||||||
painter.rect_filled(
|
painter.rect_filled(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Color32::from_rgba_premultiplied(40, 120, 240, 35),
|
egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35),
|
||||||
);
|
);
|
||||||
painter.rect_stroke(
|
painter.rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
egui::Rounding::same(4.0_f32),
|
egui::Rounding::same(4.0_f32),
|
||||||
egui::Stroke::new(
|
egui::Stroke::new(
|
||||||
1.5_f32,
|
1.5_f32,
|
||||||
egui::Color32::from_rgba_premultiplied(80, 160, 255, 220),
|
egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -321,8 +337,8 @@ pub fn render_selection_overlay(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let p_min = canvas.page_to_screen(aabb.min);
|
let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp);
|
||||||
let p_max = canvas.page_to_screen(aabb.max);
|
let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp);
|
||||||
|
|
||||||
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1]));
|
||||||
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1]));
|
||||||
@@ -338,7 +354,7 @@ pub fn render_selection_overlay(
|
|||||||
|
|
||||||
// Dreh-Stängel (Stalk) und Drehgriff
|
// Dreh-Stängel (Stalk) und Drehgriff
|
||||||
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
let top_center = egui::pos2(rect.center().x, rect.min.y);
|
||||||
let rot_handle = egui::pos2(rect.center().x, rect.min.y - 24.0_f32);
|
let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET);
|
||||||
|
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[top_center, rot_handle],
|
[top_center, rot_handle],
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Persistente UI-Einstellungen (`$XDG_CONFIG_HOME/ink-notes/ui.json`).
|
||||||
|
//!
|
||||||
|
//! Fehlende oder unlesbare Dateien führen zu Standardwerten, damit die App immer startet.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::storage::{self, StorageError};
|
||||||
|
|
||||||
|
/// Fensterseite, an der die Werkzeugleiste angedockt ist.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum SidebarSide {
|
||||||
|
/// Links (Standard: die Schreibhand von Rechtshändern verdeckt die Leiste nicht)
|
||||||
|
#[default]
|
||||||
|
Left,
|
||||||
|
/// Rechts (für Linkshänder)
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Benutzereinstellungen der Oberfläche.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct UiSettings {
|
||||||
|
pub sidebar_side: SidebarSide,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UiSettings {
|
||||||
|
/// Standardpfad gemäß XDG-Spezifikation.
|
||||||
|
pub fn default_path() -> Option<PathBuf> {
|
||||||
|
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||||
|
Ok(dir) if !dir.trim().is_empty() => PathBuf::from(dir),
|
||||||
|
_ => PathBuf::from(std::env::var("HOME").ok()?).join(".config"),
|
||||||
|
};
|
||||||
|
Some(base.join("ink-notes").join("ui.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt die Einstellungen; bei fehlender oder beschädigter Datei gelten Standardwerte.
|
||||||
|
pub fn load_from(path: &Path) -> Self {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
|
eprintln!(
|
||||||
|
"[Einstellungen] '{}' ist ungültig ({e}), verwende Standardwerte.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speichert die Einstellungen atomar.
|
||||||
|
pub fn save_to(&self, path: &Path) -> Result<(), StorageError> {
|
||||||
|
storage::atomic_write_json(path, self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_settings_round_trip_and_fallbacks() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("ink-notes").join("ui.json");
|
||||||
|
|
||||||
|
// Fehlende Datei -> Standard (links)
|
||||||
|
assert_eq!(UiSettings::load_from(&path).sidebar_side, SidebarSide::Left);
|
||||||
|
|
||||||
|
let settings = UiSettings {
|
||||||
|
sidebar_side: SidebarSide::Right,
|
||||||
|
};
|
||||||
|
settings.save_to(&path).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), settings);
|
||||||
|
|
||||||
|
// Beschädigte Datei -> Standard statt Absturz
|
||||||
|
std::fs::write(&path, b"{ kaputt").unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
|
||||||
|
// Unbekannte / fehlende Felder werden toleriert
|
||||||
|
std::fs::write(&path, br#"{"zukunft": 1}"#).unwrap();
|
||||||
|
assert_eq!(UiSettings::load_from(&path), UiSettings::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-16
@@ -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(¬ebook, &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, ¬ebook, &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
@@ -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(¤t_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();
|
||||||
|
|||||||
Reference in new issue
Block a user