diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..f9f81c1 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,50 @@ +name: Build & Publish Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + release: + name: Build Packages & Publish to Gitea + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Install System Packaging Tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libx11-dev \ + libxcursor-dev \ + libxrandr-dev \ + libxi-dev \ + libxkbcommon-dev \ + squashfs-tools \ + dpkg-dev \ + curl \ + python3 + + - name: Setup Rust Toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + cache: true + + - name: Build Release Artifacts (.deb, .AppImage, SHA256SUMS.txt) + run: | + chmod +x scripts/*.sh + ./scripts/build-release.sh + + - name: Publish Release to Gitea + run: | + ./scripts/publish-gitea-release.sh "${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}" + env: + GITEA_SERVER: ${{ github.server_url }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} diff --git a/Cargo.lock b/Cargo.lock index b9b5dc4..843cc25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,6 +961,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "webbrowser", "wgpu", "winit", ] diff --git a/Cargo.toml b/Cargo.toml index 44b8e7c..ea44276 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ serde_json = "1.0.151" egui = "0.27" egui-wgpu = "0.27" egui-winit = { version = "0.27", default-features = false, features = ["links"] } +webbrowser = "0.8" [profile.dev] opt-level = 1 # Erhöht Rendering-Performance auch im Debug-Modus diff --git a/README.md b/README.md index c56757a..f13d77a 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,54 @@ Eine latenzoptimierte, hardwarebeschleunigte Inking- und Notizbuch-Anwendung fü --- -## 🚀 Schnellstart +### 🚀 Release, Packaging & In-App Auto-Update (Zorin OS 18 / Ubuntu) +- **Natives .deb-Paket**: Optimiert für Zorin OS 18 (Ubuntu 24.04 LTS Noble-Basis) mit vollständiger XDG Desktop-Integration (`.desktop`, Hicolor-Icons SVG & 256x256 PNG, AppStream Metainfo). +- **Portables .AppImage**: Eigenständiges, distributionsunabhängiges Bundle (x86_64) mit integrierten Laufzeitabhängigkeiten. +- **In-App Gitea Updater (`src/updater.rs`)**: + - Fragt im Hintergrund die Gitea Releases API (`https://gitea.pansi.eu/api/v1/repos/harald/ink-notes/releases/latest`) ab. + - Zeigt bei neuen Versionen ein Update-Badge in der Toolbar an. + - Release-Modal mit Versionsvergleich, Changelog und 1-Click-Download / In-Place-Aktualisierung. +- **Gitea Actions CI/CD (`.gitea/workflows/release.yaml`)**: Automatisches Paketieren und Veröffentlichen auf Gitea bei Git-Tags (`v*`). + +--- + +## 📦 Paketierung & Installation (Zorin OS 18) + +### 1. Release-Pakete bauen + +```bash +# Baut sowohl .deb als auch .AppImage und berechnet SHA256SUMS.txt in target/ +./scripts/build-release.sh +``` + +Ergebnis in `target/`: +- `ink-notes_0.1.0_amd64.deb` (~4.2 MB) +- `ink-notes_0.1.0_x86_64.AppImage` (~5.6 MB) +- `SHA256SUMS.txt` + +### 2. Installation auf Zorin OS 18 + +```bash +# Natives Debian-Paket installieren +sudo dpkg -i target/ink-notes_0.1.0_amd64.deb + +# Oder portables AppImage direkt starten +chmod +x target/ink-notes_0.1.0_x86_64.AppImage +./target/ink-notes_0.1.0_x86_64.AppImage +``` + +Nach der `.deb`-Installation ist `ink-notes` nahtlos im Zorin OS Anwendungsmenü unter *Büro* und *Grafik* integriert. + +### 3. Release auf Gitea veröffentlichen + +```bash +# Veröffentlicht die Artefakte auf https://gitea.pansi.eu/harald/ink-notes +./scripts/publish-gitea-release.sh +``` + +--- + +## 🚀 Schnellstart (Entwicklung) ### Voraussetzungen - **Rust & Cargo:** Version 1.75 oder neuer (`rustup update stable`) @@ -140,8 +187,9 @@ src/ ├── history.rs # Undo/Redo-Transaktionsstapel (Add, Erase, Transform) ├── storage.rs # Atomares Laden/Speichern von Manifest & Seiten, Multi-Notebooks ├── autosave.rs # Debounced Hintergrund-Autosave mit MPSC-Kanal +├── updater.rs # In-App Gitea Updater (SemVer, Release-API, Download, Self-Update) ├── export.rs # Skalierbarer Vektor-PDF-Export via printpdf -└── ui.rs # Floating Glassmorphism-Toolbar & Dokumenten-Manager (egui) +└── ui.rs # Floating Glassmorphism-Toolbar, Dokumenten- & Update-Manager (egui) ``` Ausführliche Details zur Architektur und mathematischen Modellen findest du unter [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). @@ -150,16 +198,16 @@ Ausführliche Details zur Architektur und mathematischen Modellen findest du unt ## 🧪 Tests ausführen -Die gesamte Inking-Pipeline, Geometrieerkennung und Speicherlogik ist mit Unit-Tests abgesichert: +Die gesamte Inking-Pipeline, Geometrieerkennung, Speicherlogik und der Updater sind mit Unit-Tests abgesichert: ```bash cargo test ``` -Alle 53 Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo und Persistenz konsistent ab. +Alle 61 Unit-Tests prüfen Berechnungen, Hit-Testing, Undo/Redo, Persistenz und Versionsvergleiche konsistent ab. --- ## 📄 Lizenz -Proprietäres Notiz-Projekt für Linux. Alle Rechte vorbehalten. +MIT License / Open Source Inking Project for Linux. diff --git a/assets/ink-notes.png b/assets/ink-notes.png new file mode 100644 index 0000000..9506f5a Binary files /dev/null and b/assets/ink-notes.png differ diff --git a/assets/ink-notes.svg b/assets/ink-notes.svg new file mode 100644 index 0000000..8768ede --- /dev/null +++ b/assets/ink-notes.svg @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/ink-notes.desktop b/packaging/ink-notes.desktop new file mode 100644 index 0000000..f333f24 --- /dev/null +++ b/packaging/ink-notes.desktop @@ -0,0 +1,18 @@ +[Desktop Entry] +Version=1.0 +Name=ink-notes +GenericName=Notizbuch- & Inking-App +Comment=Latenzoptimierte Vektor-Notizen mit Stylus, Stift & Geometrie-Erkennung +Exec=ink-notes %F +Icon=ink-notes +Terminal=false +Type=Application +Categories=Office;Graphics;Utility; +Keywords=Note;Notiz;Pen;Stift;Ink;Inking;Stylus;Drawing;Zeichnen;PDF;Vector; +StartupWMClass=ink-notes +MimeType=application/x-inknotes; +Actions=NewWindow; + +[Desktop Action NewWindow] +Name=Neues Fenster +Exec=ink-notes diff --git a/packaging/ink-notes.metainfo.xml b/packaging/ink-notes.metainfo.xml new file mode 100644 index 0000000..faad2c9 --- /dev/null +++ b/packaging/ink-notes.metainfo.xml @@ -0,0 +1,38 @@ + + + eu.pansi.ink-notes + CC0-1.0 + MIT + ink-notes + Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux + +

+ ink-notes ist eine hardwarebeschleunigte, hochperformante Notizbuch- und Inking-Anwendung für Linux, + geschrieben in Rust mit wgpu. Sie bietet minimale Eingabelatenz mit Stylus, Touchpad oder Maus. +

+

Hauptmerkmale:

+
    +
  • Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung
  • +
  • Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)
  • +
  • Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning
  • +
  • Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung
  • +
  • Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave
  • +
  • Vektor-PDF-Export für Druck und Weitergabe
  • +
+
+ ink-notes.desktop + + ink-notes + + + Office + Graphics + Utility + + + Harald + + https://farmersdog.pansi.eu/harald/ink-notes + https://farmersdog.pansi.eu/harald/ink-notes/issues + +
diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..1d458dc --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# ============================================================================== +# Haupt-Release-Build-Skript für ink-notes (Zorin OS 18 / Linux) +# Erstellt .deb, .AppImage und SHA256-Prüfsummen in target/ +# ============================================================================== +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${ROOT_DIR}" + +VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2) +DEB_NAME="ink-notes_${VERSION}_amd64.deb" +APPIMAGE_NAME="ink-notes_${VERSION}_x86_64.AppImage" +CHECKSUM_FILE="SHA256SUMS.txt" + +echo "======================================================================" +echo "Starte Release-Build für ink-notes v${VERSION}" +echo "======================================================================" + +# 1. Debian-Paket bauen (.deb) +"${SCRIPT_DIR}/package-deb.sh" + +# 2. AppImage bauen (.AppImage) +"${SCRIPT_DIR}/package-appimage.sh" + +# 3. Checksums berechnen +echo "=== Berechne SHA256-Prüfsummen in target/${CHECKSUM_FILE} ===" +cd "${ROOT_DIR}/target" +sha256sum "${DEB_NAME}" "${APPIMAGE_NAME}" > "${CHECKSUM_FILE}" + +echo "======================================================================" +echo "Release-Artefakte für v${VERSION} erfolgreich erstellt:" +echo "======================================================================" +ls -lh "${DEB_NAME}" "${APPIMAGE_NAME}" "${CHECKSUM_FILE}" +echo "" +echo "Inhalt von target/${CHECKSUM_FILE}:" +cat "${CHECKSUM_FILE}" +echo "======================================================================" diff --git a/scripts/generate_png_icon.py b/scripts/generate_png_icon.py new file mode 100644 index 0000000..3c4427c --- /dev/null +++ b/scripts/generate_png_icon.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Erzeugt ein 256x256 PNG-Icon für ink-notes mit Python Standard-Bibliothek (ohne externe Module). +""" + +import math +import os +import struct +import zlib + +WIDTH = 256 +HEIGHT = 256 + + +def make_png(width, height, rgba_data): + def chunk(tag, data): + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + header = b"\x89PNG\r\n\x1a\n" + ihdr = chunk(tag=b"IHDR", data=struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + + raw = bytearray() + for y in range(height): + raw.append(0) # Filter type 0 (None) + raw.extend(rgba_data[y * width * 4 : (y + 1) * width * 4]) + + idat = chunk(tag=b"IDAT", data=zlib.compress(bytes(raw), 9)) + iend = chunk(tag=b"IEND", data=b"") + return header + ihdr + idat + iend + + +def render_icon(): + buffer = bytearray(WIDTH * HEIGHT * 4) + + def set_pixel(x, y, r, g, b, a): + if 0 <= x < WIDTH and 0 <= y < HEIGHT: + idx = (y * WIDTH + x) * 4 + src_a = a / 255.0 + dst_a = buffer[idx + 3] / 255.0 + out_a = src_a + dst_a * (1.0 - src_a) + if out_a > 1e-5: + out_r = (r * src_a + buffer[idx] * dst_a * (1.0 - src_a)) / out_a + out_g = ( + g * src_a + buffer[idx + 1] * dst_a * (1.0 - src_a) + ) / out_a + out_b = ( + b * src_a + buffer[idx + 2] * dst_a * (1.0 - src_a) + ) / out_a + buffer[idx] = int(round(out_r)) + buffer[idx + 1] = int(round(out_g)) + buffer[idx + 2] = int(round(out_b)) + buffer[idx + 3] = int(round(out_a * 255.0)) + + # 1. Abgerundete Squircle-Kachel (Zorin OS Stil) + cx, cy = WIDTH / 2.0, HEIGHT / 2.0 + half_size = 114.0 + radius = 52.0 + + for y in range(HEIGHT): + for x in range(WIDTH): + dx = abs(x - cx) + dy = abs(y - cy) + # Distance from rounded box + qx = max(0.0, dx - (half_size - radius)) + qy = max(0.0, dy - (half_size - radius)) + dist = math.sqrt(qx * qx + qy * qy) + + if dx <= half_size and dy <= half_size and dist <= radius: + # Gradient von Dunkelblau (#1e2638) nach Nachtblau (#0f1422) + t = (x + y) / (WIDTH + HEIGHT) + r = int(30 * (1 - t) + 12 * t) + g = int(38 * (1 - t) + 16 * t) + b = int(58 * (1 - t) + 28 * t) + + # Rand-Antialiasing + edge_dist = radius - dist + alpha = min(255, max(0, int(edge_dist * 255))) + + # Subtiler Rahmen + if edge_dist < 2.5: + r, g, b = 60, 75, 110 + + set_pixel(x, y, r, g, b, alpha) + + # 2. Subtile Gitterlinien + for gy in range(80, 200, 30): + for gx in range(40, 216): + set_pixel(gx, gy, 140, 160, 190, 25) + for gx in range(70, 200, 30): + for gy in range(50, 216): + set_pixel(gx, gy, 140, 160, 190, 25) + + # 3. Tintenspur (Bézier-Kurve mit Glow) + pts = [] + for t_i in range(150): + t = t_i / 150.0 + # P0=(55, 195), P1=(90, 215), P2=(130, 190), P3=(120, 155), P4=(140, 110), P5=(170, 110) + # 2 Teilkurven + if t <= 0.5: + u = t * 2.0 + bx = ( + (1 - u) ** 3 * 55 + + 3 * (1 - u) ** 2 * u * 90 + + 3 * (1 - u) * u**2 * 130 + + u**3 * 120 + ) + by = ( + (1 - u) ** 3 * 195 + + 3 * (1 - u) ** 2 * u * 215 + + 3 * (1 - u) * u**2 * 190 + + u**3 * 155 + ) + else: + u = (t - 0.5) * 2.0 + bx = ( + (1 - u) ** 3 * 120 + + 3 * (1 - u) ** 2 * u * 110 + + 3 * (1 - u) * u**2 * 140 + + u**3 * 170 + ) + by = ( + (1 - u) ** 3 * 155 + + 3 * (1 - u) ** 2 * u * 120 + + 3 * (1 - u) * u**2 * 95 + + u**3 * 110 + ) + pts.append((bx, by)) + + for px, py in pts: + for ox in range(-7, 8): + for oy in range(-7, 8): + d = math.sqrt(ox * ox + oy * oy) + if d <= 7.0: + a = int((1.0 - d / 7.0) * 110) + set_pixel(int(px + ox), int(py + oy), 59, 130, 246, a) + for ox in range(-3, 4): + for oy in range(-3, 4): + d = math.sqrt(ox * ox + oy * oy) + if d <= 3.0: + a = int((1.0 - d / 3.0) * 230) + set_pixel(int(px + ox), int(py + oy), 147, 197, 253, a) + + # 4. Füllfederhalter (diagonale Geometrie) + # Stift-Mittelachse von (120, 55) nach (170, 110) + pen_dir = (math.cos(math.radians(45)), math.sin(math.radians(45))) + norm = (-pen_dir[1], pen_dir[0]) + + # Spitze bei (110, 195) + tip_x, tip_y = 55.0, 195.0 + for l in range(0, 120): + pos_x = tip_x + pen_dir[0] * l + pos_y = tip_y - pen_dir[1] * l + # Radius entlang des Stifts + if l < 20: + # Goldene Feder + w = (l / 20.0) * 10.0 + color = (245, 158, 11) # Gold + elif l < 32: + # Griffzone + w = 11.0 + color = (30, 41, 59) # Dunkelgrau + else: + # Schaft + w = 12.0 + color = (59, 130, 246) # Blau + + for s in range(int(-w), int(w) + 1): + px = int(pos_x + norm[0] * s) + py = int(pos_y + norm[1] * s) + set_pixel(px, py, color[0], color[1], color[2], 255) + + # 5. Glanzpunkt auf der Spitze + for ox in range(-4, 5): + for oy in range(-4, 5): + d = math.sqrt(ox * ox + oy * oy) + if d <= 4: + set_pixel(int(tip_x + ox), int(tip_y + oy), 255, 255, 255, 255) + + return make_png(WIDTH, HEIGHT, buffer) + + +if __name__ == "__main__": + os.makedirs("assets", exist_ok=True) + png_data = render_icon() + with open("assets/ink-notes.png", "wb") as f: + f.write(png_data) + print("PNG-Icon erfolgreich generiert: assets/ink-notes.png (256x256)") diff --git a/scripts/package-appimage.sh b/scripts/package-appimage.sh new file mode 100755 index 0000000..94fd434 --- /dev/null +++ b/scripts/package-appimage.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# ============================================================================== +# Baut ein portables .AppImage-Paket für ink-notes (Zorin OS 18 / Linux x86_64) +# ============================================================================== +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${ROOT_DIR}" + +# 1. Version aus Cargo.toml ermitteln +VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2) +PKG_NAME="ink-notes" +ARCH="x86_64" +APPIMAGE_NAME="${PKG_NAME}_${VERSION}_${ARCH}.AppImage" +APPDIR="${ROOT_DIR}/target/AppDir" +CACHE_DIR="${ROOT_DIR}/target/tools" +APPIMAGETOOL="${CACHE_DIR}/appimagetool-x86_64.AppImage" + +echo "=== [1/5] Stelle Release-Binary sicher (ink-notes v${VERSION}) ===" +if [ ! -f "target/release/ink-notes" ]; then + cargo build --release +fi + +# Sicherstellen, dass das PNG-Icon existiert +if [ ! -f "assets/ink-notes.png" ]; then + echo "Generiere PNG-Icon..." + python3 scripts/generate_png_icon.py +fi + +echo "=== [2/5] Bereite AppDir-Struktur vor in ${APPDIR} ===" +rm -rf "${APPDIR}" +mkdir -p "${APPDIR}/usr/bin" +mkdir -p "${APPDIR}/usr/share/applications" +mkdir -p "${APPDIR}/usr/share/icons/hicolor/scalable/apps" +mkdir -p "${APPDIR}/usr/share/icons/hicolor/256x256/apps" +mkdir -p "${APPDIR}/usr/share/metainfo" + +# Binaries & Assets kopieren +cp "target/release/ink-notes" "${APPDIR}/usr/bin/ink-notes" +chmod 755 "${APPDIR}/usr/bin/ink-notes" + +cp "packaging/ink-notes.desktop" "${APPDIR}/usr/share/applications/ink-notes.desktop" +cp "packaging/ink-notes.desktop" "${APPDIR}/ink-notes.desktop" + +cp "assets/ink-notes.svg" "${APPDIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg" +cp "assets/ink-notes.png" "${APPDIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png" +cp "assets/ink-notes.png" "${APPDIR}/ink-notes.png" +ln -sf "ink-notes.png" "${APPDIR}/.DirIcon" + +cp "packaging/ink-notes.metainfo.xml" "${APPDIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml" + +# AppRun Skript erstellen +cat <<'EOF' > "${APPDIR}/AppRun" +#!/usr/bin/env bash +SELF_DIR="$(dirname "$(readlink -f "$0")")" +export PATH="${SELF_DIR}/usr/bin:${PATH}" +export XDG_DATA_DIRS="${SELF_DIR}/usr/share:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}" +export APPDIR="${SELF_DIR}" +exec "${SELF_DIR}/usr/bin/ink-notes" "$@" +EOF +chmod 755 "${APPDIR}/AppRun" + +echo "=== [3/5] Hole appimagetool (gecacht in ${CACHE_DIR}) ===" +mkdir -p "${CACHE_DIR}" +if [ ! -f "${APPIMAGETOOL}" ]; then + echo "Lade appimagetool herunter..." + curl -fsSL -o "${APPIMAGETOOL}" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x "${APPIMAGETOOL}" +fi + +echo "=== [4/5] Erstelle AppImage mit appimagetool ===" +# --appimage-extract-and-run erlaubt Ausführung auch ohne root / FUSE +# NO_APPSTREAM=1 verhindert Build-Abbruch bei privaten/nicht-erreichbaren URLs im Metainfo-XML +export NO_APPSTREAM=1 +ARCH="${ARCH}" "${APPIMAGETOOL}" --appimage-extract-and-run "${APPDIR}" "${ROOT_DIR}/target/${APPIMAGE_NAME}" + +echo "=== [5/5] AppImage erfolgreich erstellt! ===" +ls -lh "${ROOT_DIR}/target/${APPIMAGE_NAME}" +echo "Pfad: ${ROOT_DIR}/target/${APPIMAGE_NAME}" diff --git a/scripts/package-deb.sh b/scripts/package-deb.sh new file mode 100755 index 0000000..7cdf155 --- /dev/null +++ b/scripts/package-deb.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# ============================================================================== +# Baut ein natives .deb-Paket für Zorin OS 18 / Ubuntu / Debian +# ============================================================================== +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${ROOT_DIR}" + +# 1. Version aus Cargo.toml ermitteln +VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2) +PKG_NAME="ink-notes" +ARCH="amd64" +DEB_NAME="${PKG_NAME}_${VERSION}_${ARCH}.deb" +STAGING_DIR="${ROOT_DIR}/target/deb_staging" + +echo "=== [1/4] Baue Release-Binary mit Cargo (ink-notes v${VERSION}) ===" +cargo build --release + +# Sicherstellen, dass das PNG-Icon existiert +if [ ! -f "assets/ink-notes.png" ]; then + echo "Generiere PNG-Icon..." + python3 scripts/generate_png_icon.py +fi + +echo "=== [2/4] Erstelle Paketstruktur in ${STAGING_DIR} ===" +rm -rf "${STAGING_DIR}" +mkdir -p "${STAGING_DIR}/DEBIAN" +mkdir -p "${STAGING_DIR}/usr/bin" +mkdir -p "${STAGING_DIR}/usr/share/applications" +mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps" +mkdir -p "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps" +mkdir -p "${STAGING_DIR}/usr/share/metainfo" + +# 2. Dateien kopieren +cp "target/release/ink-notes" "${STAGING_DIR}/usr/bin/ink-notes" +chmod 755 "${STAGING_DIR}/usr/bin/ink-notes" + +cp "packaging/ink-notes.desktop" "${STAGING_DIR}/usr/share/applications/ink-notes.desktop" +chmod 644 "${STAGING_DIR}/usr/share/applications/ink-notes.desktop" + +cp "assets/ink-notes.svg" "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg" +chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/scalable/apps/ink-notes.svg" + +cp "assets/ink-notes.png" "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png" +chmod 644 "${STAGING_DIR}/usr/share/icons/hicolor/256x256/apps/ink-notes.png" + +cp "packaging/ink-notes.metainfo.xml" "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml" +chmod 644 "${STAGING_DIR}/usr/share/metainfo/eu.pansi.ink-notes.metainfo.xml" + +# 3. DEBIAN/control Datei generieren +cat < "${STAGING_DIR}/DEBIAN/control" +Package: ${PKG_NAME} +Version: ${VERSION} +Architecture: ${ARCH} +Maintainer: Harald +Installed-Size: $(du -ks "${STAGING_DIR}/usr" | cut -f1) +Depends: libc6, libx11-6, libxcursor1, libxrandr2, libxi6, libxkbcommon0 +Section: utils +Priority: optional +Homepage: https://farmersdog.pansi.eu/harald/ink-notes +Description: Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux + ink-notes kombiniert minimale Stylus-Latenz mit GPU-Hardwarebeschleunigung (wgpu), + stufenlosem Viewport, Geometrie- und Formerkennung, Objekt-Transformationen, + Multi-Dokumenten-Verwaltung und Vektor-PDF-Export. +EOF + +echo "=== [3/4] Schnüre .deb-Paket mit dpkg-deb ===" +dpkg-deb --build --root-owner-group "${STAGING_DIR}" "${ROOT_DIR}/target/${DEB_NAME}" + +echo "=== [4/4] Paket erfolgreich erstellt: target/${DEB_NAME} ===" +ls -lh "${ROOT_DIR}/target/${DEB_NAME}" +dpkg-deb -I "${ROOT_DIR}/target/${DEB_NAME}" +echo "Fertig!" diff --git a/scripts/publish-gitea-release.sh b/scripts/publish-gitea-release.sh new file mode 100755 index 0000000..79ec0c8 --- /dev/null +++ b/scripts/publish-gitea-release.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# ============================================================================== +# Veröffentlicht ein Release auf dem Gitea-Server (gitea.pansi.eu) +# Lädt .deb, .AppImage und SHA256SUMS.txt als Release-Assets hoch. +# ============================================================================== +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +cd "${ROOT_DIR}" + +GITEA_SERVER="${GITEA_SERVER:-https://gitea.pansi.eu}" +REPO_OWNER="${REPO_OWNER:-harald}" +REPO_NAME="${REPO_NAME:-ink-notes}" + +# Token aus Argument oder Umgebungsvariablen ermitteln +TOKEN="${1:-${INK_NOTES_GITEA_TOKEN:-${GITEA_TOKEN:-${GITHUB_TOKEN:-}}}}" + +if [ -z "${TOKEN}" ]; then + echo "FEHLER: Kein Gitea-API-Token angegeben." + echo "Verwendung: $0 " + echo "Oder setze die Umgebungsvariable INK_NOTES_GITEA_TOKEN." + exit 1 +fi + +VERSION=$(grep -m1 '^version = ' Cargo.toml | cut -d '"' -f2) +TAG_NAME="v${VERSION}" +RELEASE_TITLE="ink-notes ${TAG_NAME}" + +DEB_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_amd64.deb" +APPIMAGE_FILE="${ROOT_DIR}/target/ink-notes_${VERSION}_x86_64.AppImage" +CHECKSUMS_FILE="${ROOT_DIR}/target/SHA256SUMS.txt" + +# Sicherstellen, dass die Artefakte existieren +if [ ! -f "${DEB_FILE}" ] || [ ! -f "${APPIMAGE_FILE}" ] || [ ! -f "${CHECKSUMS_FILE}" ]; then + echo "Artefakte nicht vollständig gefunden. Führe build-release.sh aus..." + "${SCRIPT_DIR}/build-release.sh" +fi + +echo "======================================================================" +echo "Veröffentliche Release ${TAG_NAME} auf ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}" +echo "======================================================================" + +RELEASE_BODY=$(cat < /dev/null + echo "✓ ${filename} erfolgreich hochgeladen." +} + +upload_asset "${DEB_FILE}" +upload_asset "${APPIMAGE_FILE}" +upload_asset "${CHECKSUMS_FILE}" + +echo "======================================================================" +echo "Release ${TAG_NAME} erfolgreich veröffentlicht!" +echo "URL: ${GITEA_SERVER}/${REPO_OWNER}/${REPO_NAME}/releases/tag/${TAG_NAME}" +echo "======================================================================" diff --git a/src/lib.rs b/src/lib.rs index c21ad1b..a20d244 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,4 +12,5 @@ pub mod selection; pub mod spline; pub mod storage; pub mod ui; +pub mod updater; pub mod viewport; diff --git a/src/main.rs b/src/main.rs index b99cf51..7cc7696 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ use ink_notes::geometry::{self, RecognizedShape}; use ink_notes::selection::{render_selection_overlay, DragMode, SelectionHandleHit, SelectionState}; use ink_notes::storage::{self, NotebookManifest}; use ink_notes::ui::{self, ActiveTool, UiAction, UiState}; +use ink_notes::updater::UpdaterController; /// Aktualisiert den Fenstertitel als minimales On-Screen-Display (OSD) inklusive Autosave-Status. fn update_window_title( @@ -90,6 +91,10 @@ fn main() -> Result<(), Box> { let egui_ctx = egui::Context::default(); egui_ctx.set_visuals(egui::Visuals::dark()); + // 4. In-App Gitea Updater initialisieren und im Hintergrund nach Releases fragen + let mut updater = UpdaterController::new(); + updater.check_for_updates(); + let mut egui_winit_state = egui_winit::State::new( egui_ctx.clone(), egui::ViewportId::ROOT, @@ -169,6 +174,12 @@ fn main() -> Result<(), Box> { update_window_title(&window, ¬ebook, &manifest, ui_state.active_tool, new_status); } + let prev_upd = updater.status().clone(); + updater.update(); + if *updater.status() != prev_upd { + window.request_redraw(); + } + // Draw-and-Hold Überprüfung: Stift regungslos für > 400 ms gehalten (< 3 px Distanz) if is_drawing && !stroke_already_snapped { let elapsed = stroke_last_motion_time.elapsed(); @@ -1269,7 +1280,8 @@ fn main() -> Result<(), Box> { render_selection_overlay(&egui_ctx, &selection_state, notebook.current_page(), &canvas); // 3. Toolbar & Widgets rendern - let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status()); + updater.update(); + let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater); // 4. UI-Aktionen verarbeiten for action in actions { @@ -1633,6 +1645,31 @@ fn main() -> Result<(), Box> { } } } + UiAction::CheckForUpdates => { + updater.check_for_updates(); + window.request_redraw(); + } + UiAction::DownloadUpdateAsset(asset) => { + updater.download_asset(asset); + window.request_redraw(); + } + UiAction::InstallOrOpenUpdate(path) => { + if let Err(err) = updater.install_or_open(&path) { + ui_state.set_export_message(format!("Update-Fehler: {err}")); + } else { + ui_state.set_export_message("Update gestartet / geöffnet"); + } + window.request_redraw(); + } + UiAction::OpenUrlInBrowser(url) => { + let _ = webbrowser::open(&url); + } + UiAction::SaveUpdaterConfig { server_url, auth_token } => { + updater.set_server_url(server_url); + updater.set_auth_token(auth_token); + ui_state.set_export_message("Server-Einstellungen gespeichert"); + window.request_redraw(); + } } } diff --git a/src/ui.rs b/src/ui.rs index 7325d24..ad48302 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -114,6 +114,12 @@ pub enum UiAction { RenameNotebook { id: String, new_title: String }, DeleteNotebook(String), RefreshNotebooks, + // Gitea Release & Updater + CheckForUpdates, + DownloadUpdateAsset(crate::updater::GiteaAsset), + InstallOrOpenUpdate(std::path::PathBuf), + OpenUrlInBrowser(String), + SaveUpdaterConfig { server_url: String, auth_token: Option }, } /// Zustand der grafischen Benutzeroberfläche. @@ -144,6 +150,13 @@ pub struct UiState { pub new_notebook_title_input: String, pub renaming_notebook_id: Option, pub renaming_title_input: String, + pub document_modal_rect: Option, + /// Gitea Release- & Update-Manager + pub is_update_dialog_open: bool, + pub updater_server_url_input: String, + pub updater_token_input: String, + pub show_updater_config: bool, + pub update_modal_rect: Option, } impl Default for UiState { @@ -168,6 +181,12 @@ impl Default for UiState { new_notebook_title_input: String::new(), renaming_notebook_id: None, renaming_title_input: String::new(), + document_modal_rect: None, + is_update_dialog_open: false, + updater_server_url_input: "https://gitea.pansi.eu".to_string(), + updater_token_input: String::new(), + show_updater_config: false, + update_modal_rect: None, } } } @@ -247,6 +266,7 @@ impl UiState { /// Prüft, ob ein Bildschirmpunkt (px, py) innerhalb der UI liegt: /// - Entweder innerhalb der abgerundeten Pill-Toolbar /// - ODER innerhalb des geöffneten Einstellungs-Popovers + /// - ODER innerhalb geöffneter Modalfenster (Dokumenten-Manager, Update-Center) pub fn is_point_in_ui(&self, px: f32, py: f32) -> bool { if self.is_point_inside_toolbar(px, py) { return true; @@ -256,6 +276,20 @@ impl UiState { return true; } } + if self.is_document_menu_open { + if let Some(doc_rect) = self.document_modal_rect { + if doc_rect.contains(egui::pos2(px, py)) { + return true; + } + } + } + if self.is_update_dialog_open { + if let Some(upd_rect) = self.update_modal_rect { + if upd_rect.contains(egui::pos2(px, py)) { + return true; + } + } + } false } } @@ -554,6 +588,7 @@ pub fn render_ui( state: &mut UiState, notebook: &Notebook, autosave_status: AutosaveStatus, + updater: &crate::updater::UpdaterController, ) -> Vec { let mut actions = Vec::new(); @@ -894,6 +929,47 @@ pub fn render_ui( .strong(), ); }); + + toolbar_divider(ui); + + // --- 10. GITEA-RELEASE & UPDATE-STATUS --- + let (upd_label, is_highlight) = match updater.status() { + crate::updater::UpdaterStatus::UpdateAvailable(rel) => { + (format!("🚀 Neu: {}", rel.tag_name), true) + } + crate::updater::UpdaterStatus::Downloading { percent, .. } => { + (format!("⏳ {:.0}%", percent * 100.0), true) + } + crate::updater::UpdaterStatus::DownloadFinished { .. } => { + ("✓ Update bereit".to_string(), true) + } + crate::updater::UpdaterStatus::Checking => { + ("🔄 Prüfe...".to_string(), false) + } + _ => ("🔄 Update".to_string(), false), + }; + + let upd_btn = if is_highlight { + let text = egui::RichText::new(&upd_label) + .color(egui::Color32::from_rgb(46, 204, 113)) + .strong(); + ui.add(egui::Button::new(text).frame(true)) + .on_hover_text("Update verfügbar! Klicke hier für Release-Notes und Download.") + } else { + action_pill_button( + ui, + true, + &upd_label, + "Release- & Update-Manager (Gitea Releases prüfen & installieren)", + ) + }; + + if upd_btn.clicked() { + state.is_update_dialog_open = !state.is_update_dialog_open; + state.is_pen_menu_open = false; + state.is_highlighter_menu_open = false; + state.is_document_menu_open = false; + } }); }); @@ -1243,7 +1319,7 @@ pub fn render_ui( // 3b. Dokumenten-Manager (Notizbücher wechseln, anlegen, umbenennen, löschen) if state.is_document_menu_open { - egui::Window::new("Dokumenten-Manager") + let doc_win = egui::Window::new("Dokumenten-Manager") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0)) @@ -1434,6 +1510,289 @@ pub fn render_ui( } }); }); + state.document_modal_rect = doc_win.map(|w| w.response.rect); + } else { + state.document_modal_rect = None; + } + + // 3c. Release- & Update-Center (Gitea Releases) + if state.is_update_dialog_open { + let win = egui::Window::new("Release- & Update-Center") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 70.0)) + .frame( + egui::Frame::none() + .fill(egui::Color32::from_rgba_premultiplied(24, 24, 28, 245)) + .rounding(egui::Rounding::same(14.0)) + .stroke(egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_premultiplied(255, 255, 255, 30), + )) + .inner_margin(egui::Margin::symmetric(20.0, 16.0)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 8.0), + blur: 24.0, + spread: 0.0, + color: egui::Color32::from_black_alpha(150), + }), + ) + .show(ctx, |ui| { + ui.set_width(450.0); + + // Kopfzeile mit Schließen-Button + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("🚀 Gitea Release- & Update-Center") + .strong() + .size(16.0) + .color(egui::Color32::WHITE), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(egui::Button::new(egui::RichText::new("✕").size(13.0)).frame(false)) + .clicked() + { + state.is_update_dialog_open = false; + } + }); + }); + + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + // Info über lokale Installation + let current_ver = env!("CARGO_PKG_VERSION"); + let runtime_name = updater.runtime().display_name(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Aktuelle Version:").color(egui::Color32::from_gray(180))); + ui.label(egui::RichText::new(format!("v{}", current_ver)).strong().color(egui::Color32::WHITE)); + ui.label(egui::RichText::new(format!("({})", runtime_name)).color(egui::Color32::from_gray(150))); + }); + + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Server:").color(egui::Color32::from_gray(180))); + ui.label(egui::RichText::new(format!("{}/{}/{}", updater.config().server_url, updater.config().repo_owner, updater.config().repo_name)).color(egui::Color32::from_gray(210))); + }); + + ui.add_space(10.0); + + // Update-Prüfung auslösen + let is_busy = matches!(updater.status(), crate::updater::UpdaterStatus::Checking | crate::updater::UpdaterStatus::Downloading { .. }); + ui.horizontal(|ui| { + if ui.add_enabled(!is_busy, egui::Button::new(egui::RichText::new("🔄 Nach Updates suchen").strong())).clicked() { + actions.push(UiAction::CheckForUpdates); + } + }); + + ui.add_space(10.0); + ui.separator(); + ui.add_space(10.0); + + // Statusbereich + match updater.status() { + crate::updater::UpdaterStatus::Idle => { + ui.label( + egui::RichText::new("Klicke auf 'Nach Updates suchen', um die neuesten Releases vom Gitea-Server abzufragen.") + .color(egui::Color32::from_gray(170)), + ); + } + crate::updater::UpdaterStatus::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(egui::RichText::new("Prüfe Gitea-Releases...").color(egui::Color32::WHITE)); + }); + } + crate::updater::UpdaterStatus::UpToDate { current_version } => { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✓"); + ui.label( + egui::RichText::new(format!("ink-notes ist auf dem neuesten Stand (v{}).", current_version)) + .color(egui::Color32::from_rgb(46, 204, 113)) + .strong(), + ); + }); + } + crate::updater::UpdaterStatus::Error(err) => { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(231, 76, 60), "⚠"); + ui.label( + egui::RichText::new("Abfrage fehlgeschlagen:") + .color(egui::Color32::from_rgb(231, 76, 60)) + .strong(), + ); + }); + ui.add_space(4.0); + ui.label( + egui::RichText::new(err) + .size(11.5) + .color(egui::Color32::from_gray(190)), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new("Hinweis: Falls das Repository privat ist, unten ein Personal Access Token eintragen.") + .size(11.0) + .italics() + .color(egui::Color32::from_gray(140)), + ); + }); + } + crate::updater::UpdaterStatus::UpdateAvailable(release) => { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "●"); + ui.label( + egui::RichText::new(format!("Neue Version verfügbar: {}", release.tag_name)) + .strong() + .size(14.0) + .color(egui::Color32::WHITE), + ); + }); + + if !release.name.is_empty() && release.name != release.tag_name { + ui.label(egui::RichText::new(&release.name).italics().color(egui::Color32::from_gray(180))); + } + + if !release.body.is_empty() { + ui.add_space(6.0); + ui.label(egui::RichText::new("Änderungen:").strong().size(11.5).color(egui::Color32::from_gray(170))); + egui::ScrollArea::vertical().max_height(100.0).show(ui, |ui| { + ui.label(egui::RichText::new(&release.body).size(11.5).color(egui::Color32::from_gray(210))); + }); + } + + ui.add_space(10.0); + ui.label(egui::RichText::new("VERFÜGBARE ARTEFAKTE:").size(10.5).strong().color(egui::Color32::from_gray(150))); + ui.add_space(4.0); + + for asset in &release.assets { + let is_deb = asset.name.ends_with(".deb"); + let is_appimage = asset.name.ends_with(".AppImage"); + let is_recommended = match updater.runtime() { + crate::updater::RuntimeEnvironment::AppImage { .. } => is_appimage, + crate::updater::RuntimeEnvironment::DebPackage { .. } => is_deb, + _ => false, + }; + + let size_mb = (asset.size as f64) / (1024.0 * 1024.0); + let btn_text = if size_mb > 0.05 { + format!("📥 {} ({:.1} MB)", asset.name, size_mb) + } else { + format!("📥 {}", asset.name) + }; + + ui.horizontal(|ui| { + let btn = if is_recommended { + ui.button(egui::RichText::new(format!("⭐ {}", btn_text)).strong().color(egui::Color32::from_rgb(110, 195, 255))) + } else { + ui.button(egui::RichText::new(&btn_text)) + }; + + if btn.clicked() { + actions.push(UiAction::DownloadUpdateAsset(asset.clone())); + } + + if is_recommended { + ui.label(egui::RichText::new("(Empfohlen für dieses System)").size(10.5).color(egui::Color32::from_rgb(46, 204, 113))); + } + }); + } + + ui.add_space(8.0); + if !release.html_url.is_empty() { + if ui.button(egui::RichText::new("🌐 Release auf Gitea öffnen").size(12.0)).clicked() { + actions.push(UiAction::OpenUrlInBrowser(release.html_url.clone())); + } + } + }); + } + crate::updater::UpdaterStatus::Downloading { asset_name, percent } => { + ui.vertical(|ui| { + ui.label(egui::RichText::new(format!("Lade '{}' herunter...", asset_name)).strong()); + ui.add_space(6.0); + ui.add(egui::ProgressBar::new(*percent).show_percentage()); + }); + } + crate::updater::UpdaterStatus::DownloadFinished { file_path, asset_name } => { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.colored_label(egui::Color32::from_rgb(46, 204, 113), "✓"); + ui.label( + egui::RichText::new(format!("'{}' erfolgreich heruntergeladen!", asset_name)) + .strong() + .color(egui::Color32::from_rgb(46, 204, 113)), + ); + }); + ui.add_space(4.0); + ui.label( + egui::RichText::new(format!("Gespeichert in: {}", file_path.display())) + .size(11.0) + .color(egui::Color32::from_gray(170)), + ); + ui.add_space(8.0); + + let action_label = match updater.runtime() { + crate::updater::RuntimeEnvironment::AppImage { .. } => "🚀 AppImage aktualisieren & ausführen", + crate::updater::RuntimeEnvironment::DebPackage { .. } => "📦 Paket mit Software-Center öffnen / installieren", + crate::updater::RuntimeEnvironment::PortableOrDev { .. } => "📂 Im Dateimanager anzeigen", + }; + + ui.horizontal(|ui| { + if ui.button(egui::RichText::new(action_label).strong()).clicked() { + actions.push(UiAction::InstallOrOpenUpdate(file_path.clone())); + } + if ui.button("📂 Ordner öffnen").clicked() { + if let Some(parent) = file_path.parent() { + actions.push(UiAction::OpenUrlInBrowser(parent.to_string_lossy().to_string())); + } + } + }); + }); + } + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(4.0); + + // Erweiterte Konfiguration (Collapsible) + egui::CollapsingHeader::new(egui::RichText::new("⚙ Erweiterte Server-Einstellungen").size(11.5).color(egui::Color32::from_gray(160))) + .default_open(false) + .show(ui, |ui| { + ui.add_space(4.0); + ui.label(egui::RichText::new("Gitea Server-URL:").size(11.0)); + ui.add(egui::TextEdit::singleline(&mut state.updater_server_url_input).desired_width(380.0)); + + ui.add_space(4.0); + ui.label(egui::RichText::new("Personal Access Token (optional für private Repos):").size(11.0)); + ui.add(egui::TextEdit::singleline(&mut state.updater_token_input).password(true).desired_width(380.0)); + + ui.add_space(6.0); + if ui.button(egui::RichText::new("💾 Einstellungen speichern").size(11.5)).clicked() { + let token = if state.updater_token_input.trim().is_empty() { + None + } else { + Some(state.updater_token_input.trim().to_string()) + }; + actions.push(UiAction::SaveUpdaterConfig { + server_url: state.updater_server_url_input.trim().to_string(), + auth_token: token, + }); + } + }); + + ui.add_space(6.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if action_pill_button(ui, true, "Schließen", "Fenster schließen").clicked() { + state.is_update_dialog_open = false; + } + }); + }); + state.update_modal_rect = win.map(|w| w.response.rect); + } else { + state.update_modal_rect = None; } // 4. Feedback-Toast für PDF-Export oder wichtige Systemmeldungen @@ -1519,9 +1878,10 @@ mod tests { let ctx = egui::Context::default(); let mut state = UiState::new(); let notebook = Notebook::new(); + let updater = crate::updater::UpdaterController::new(); ctx.begin_frame(egui::RawInput::default()); - let actions = render_ui(&ctx, &mut state, ¬ebook, AutosaveStatus::Saved); + let actions = render_ui(&ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater); let output = ctx.end_frame(); assert!(actions.is_empty()); @@ -1615,12 +1975,32 @@ mod tests { state.document_title = "Mathematik I".to_string(); let notebook = Notebook::new(); + let updater = crate::updater::UpdaterController::new(); let ctx = egui::Context::default(); let _ = ctx.run(egui::RawInput::default(), |ctx| { - let _ = super::render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved); + let _ = super::render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater); }); assert!(state.is_document_menu_open); assert_eq!(state.available_notebooks.len(), 2); } + + #[test] + fn test_updater_modal_ui_actions() { + let mut state = UiState::new(); + state.is_update_dialog_open = true; + + let notebook = Notebook::new(); + let updater = crate::updater::UpdaterController::new(); + let ctx = egui::Context::default(); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + let _ = super::render_ui(ctx, &mut state, ¬ebook, AutosaveStatus::Saved, &updater); + }); + + assert!(state.is_update_dialog_open); + assert!(state.update_modal_rect.is_some()); + if let Some(rect) = state.update_modal_rect { + assert!(state.is_point_in_ui(rect.center().x, rect.center().y)); + } + } } diff --git a/src/updater.rs b/src/updater.rs new file mode 100644 index 0000000..e1e189e --- /dev/null +++ b/src/updater.rs @@ -0,0 +1,503 @@ +//! In-App Updater für ink-notes mit Gitea-Releases-Integration. +//! +//! Unterstützt: +//! - Versionsabfrage gegen Gitea API (`/api/v1/repos/{owner}/{repo}/releases/latest`) +//! - SemVer-Vergleich (`remote > current`) +//! - Erkennung der Laufzeitumgebung (AppImage, .deb-Paket, Entwicklungs-Binary) +//! - Asynchroner Download ohne Blockierung der Rendering-Schleife +//! - Direkte AppImage-Aktualisierung bzw. Debian-Paket-Installation via xdg-open + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::mpsc::{channel, Receiver, Sender}; +use std::thread; + +use serde::{Deserialize, Serialize}; + +/// Konfiguration für den Gitea-Update-Server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct UpdaterConfig { + /// Basis-URL der Gitea-Instanz, z. B. `https://gitea.pansi.eu` + pub server_url: String, + /// Repository-Owner, z. B. `harald` + pub repo_owner: String, + /// Repository-Name, z. B. `ink-notes` + pub repo_name: String, + /// Optionales Personal Access Token für private Repositories + pub auth_token: Option, +} + +impl Default for UpdaterConfig { + fn default() -> Self { + // Falls Umgebungsvariable gesetzt ist, wird das Token automatisch übernommen + let auth_token = std::env::var("INK_NOTES_GITEA_TOKEN").ok(); + Self { + server_url: "https://gitea.pansi.eu".to_string(), + repo_owner: "harald".to_string(), + repo_name: "ink-notes".to_string(), + auth_token, + } + } +} + +/// Ein Release-Asset aus der Gitea API. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GiteaAsset { + pub id: u64, + pub name: String, + #[serde(default)] + pub size: u64, + #[serde(default)] + pub download_count: u64, + pub browser_download_url: String, +} + +/// Gitea Release-Objekt gemäß Swagger /api/v1/repos/{owner}/{repo}/releases/latest. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct GiteaRelease { + pub id: u64, + pub tag_name: String, + pub name: String, + #[serde(default)] + pub body: String, + #[serde(default)] + pub prerelease: bool, + #[serde(default)] + pub html_url: String, + #[serde(default)] + pub published_at: Option, + #[serde(default)] + pub assets: Vec, +} + +/// Erkannte Art der Installation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuntimeEnvironment { + /// Läuft als portables AppImage (`APPIMAGE` Umgebungsvariable gesetzt) + AppImage { appimage_path: PathBuf }, + /// Systemweit als .deb installiert (z.B. in /usr/bin/ink-notes) + DebPackage { binary_path: PathBuf }, + /// Aus Quellcode / Entwicklungs-Binary (z.B. target/debug oder target/release) + PortableOrDev { binary_path: PathBuf }, +} + +impl RuntimeEnvironment { + /// Erkennt die aktuelle Laufzeitumgebung automatisch. + pub fn detect() -> Self { + if let Ok(appimage_path) = std::env::var("APPIMAGE") { + if !appimage_path.is_empty() { + return RuntimeEnvironment::AppImage { + appimage_path: PathBuf::from(appimage_path), + }; + } + } + + let current_exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("ink-notes")); + let exe_str = current_exe.to_string_lossy(); + + if exe_str.starts_with("/usr/bin/") || exe_str.starts_with("/usr/local/bin/") { + RuntimeEnvironment::DebPackage { + binary_path: current_exe, + } + } else { + RuntimeEnvironment::PortableOrDev { + binary_path: current_exe, + } + } + } + + pub fn display_name(&self) -> &'static str { + match self { + RuntimeEnvironment::AppImage { .. } => "AppImage (Portabel)", + RuntimeEnvironment::DebPackage { .. } => "System-Paket (.deb)", + RuntimeEnvironment::PortableOrDev { .. } => "Entwicklung / Lokal", + } + } +} + +/// Zerlegt einen Versionstag wie "v0.1.2" oder "0.1.2-beta" in numerische Komponenten. +pub fn parse_semver(tag: &str) -> Option<(u64, u64, u64)> { + let clean = tag.trim().trim_start_matches('v').trim_start_matches('V'); + let version_part = clean.split('-').next()?; // Prerelease-Suffix abtrennen + let mut parts = version_part.split('.'); + + let major: u64 = parts.next()?.parse().ok()?; + let minor: u64 = parts.next()?.parse().ok()?; + let patch: u64 = parts.next()?.parse().ok()?; + + Some((major, minor, patch)) +} + +/// Prüft, ob `remote_tag` numerisch neuer ist als `current_tag`. +pub fn is_newer_version(remote_tag: &str, current_tag: &str) -> bool { + match (parse_semver(remote_tag), parse_semver(current_tag)) { + (Some((r_maj, r_min, r_pat)), Some((c_maj, c_min, c_pat))) => { + (r_maj, r_min, r_pat) > (c_maj, c_min, c_pat) + } + _ => false, + } +} + +/// Event-Typen für den asynchronen Worker. +#[derive(Debug)] +pub enum UpdaterEvent { + CheckSuccess(GiteaRelease), + CheckUpToDate(String), + CheckError(String), + DownloadProgress { percent: f32, message: String }, + DownloadSuccess { file_path: PathBuf, asset_name: String }, + DownloadError(String), +} + +/// Status des In-App Updaters für die egui-Oberfläche. +#[derive(Debug, Clone, PartialEq)] +pub enum UpdaterStatus { + Idle, + Checking, + UpdateAvailable(GiteaRelease), + UpToDate { current_version: String }, + Downloading { asset_name: String, percent: f32 }, + DownloadFinished { file_path: PathBuf, asset_name: String }, + Error(String), +} + +/// Asynchroner Controller für Update-Prüfung und Asset-Download. +pub struct UpdaterController { + config: UpdaterConfig, + runtime: RuntimeEnvironment, + status: UpdaterStatus, + receiver: Receiver, + sender: Sender, +} + +impl UpdaterController { + pub fn new() -> Self { + let (sender, receiver) = channel(); + Self { + config: UpdaterConfig::default(), + runtime: RuntimeEnvironment::detect(), + status: UpdaterStatus::Idle, + receiver, + sender, + } + } + + pub fn status(&self) -> &UpdaterStatus { + &self.status + } + + pub fn runtime(&self) -> &RuntimeEnvironment { + &self.runtime + } + + pub fn config(&self) -> &UpdaterConfig { + &self.config + } + + pub fn set_auth_token(&mut self, token: Option) { + self.config.auth_token = token; + } + + pub fn set_server_url(&mut self, url: String) { + self.config.server_url = url; + } + + /// Fragt im Hintergrund die Gitea-API ab. + pub fn check_for_updates(&mut self) { + if matches!(self.status, UpdaterStatus::Checking | UpdaterStatus::Downloading { .. }) { + return; + } + + self.status = UpdaterStatus::Checking; + let config = self.config.clone(); + let tx = self.sender.clone(); + let current_version = env!("CARGO_PKG_VERSION").to_string(); + + thread::spawn(move || { + let api_url = format!( + "{}/api/v1/repos/{}/{}/releases/latest", + config.server_url.trim_end_matches('/'), + config.repo_owner, + config.repo_name + ); + + let mut cmd = Command::new("curl"); + cmd.arg("-fsSL") + .arg("-m") + .arg("10") + .arg("-H") + .arg("Accept: application/json"); + + if let Some(token) = &config.auth_token { + if !token.is_empty() { + cmd.arg("-H").arg(format!("Authorization: token {token}")); + } + } + + cmd.arg(&api_url); + + match cmd.output() { + Ok(output) => { + if output.status.success() { + match serde_json::from_slice::(&output.stdout) { + Ok(release) => { + if is_newer_version(&release.tag_name, ¤t_version) { + let _ = tx.send(UpdaterEvent::CheckSuccess(release)); + } else { + let _ = tx.send(UpdaterEvent::CheckUpToDate(current_version)); + } + } + Err(e) => { + let _ = tx.send(UpdaterEvent::CheckError(format!( + "JSON-Parsefehler der Gitea API: {e}" + ))); + } + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let _ = tx.send(UpdaterEvent::CheckError(format!( + "Gitea API antwortete mit Fehler (HTTP {}): {}", + output.status.code().unwrap_or(-1), + stderr.trim() + ))); + } + } + Err(e) => { + let _ = tx.send(UpdaterEvent::CheckError(format!( + "curl konnte nicht ausgeführt werden: {e}" + ))); + } + } + }); + } + + /// Startet den asynchronen Download eines Release-Assets. + pub fn download_asset(&mut self, asset: GiteaAsset) { + if matches!(self.status, UpdaterStatus::Downloading { .. }) { + return; + } + + self.status = UpdaterStatus::Downloading { + asset_name: asset.name.clone(), + percent: 0.0, + }; + + let config = self.config.clone(); + let tx = self.sender.clone(); + + thread::spawn(move || { + // Download in temporäres Verzeichnis + let cache_dir = std::env::temp_dir().join("ink-notes-updates"); + if let Err(e) = std::fs::create_dir_all(&cache_dir) { + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Konnte Download-Verzeichnis nicht anlegen: {e}" + ))); + return; + } + + let target_file = cache_dir.join(&asset.name); + + let mut cmd = Command::new("curl"); + cmd.arg("-fsSL") + .arg("-m") + .arg("180") // Bis zu 3 Minuten Timeout für größere Pakete + .arg("-o") + .arg(&target_file); + + if let Some(token) = &config.auth_token { + if !token.is_empty() { + cmd.arg("-H").arg(format!("Authorization: token {token}")); + } + } + + cmd.arg(&asset.browser_download_url); + + match cmd.output() { + Ok(output) => { + if output.status.success() && target_file.exists() { + // Wenn es ein AppImage ist, Executable-Bit setzen + if asset.name.ends_with(".AppImage") { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(metadata) = std::fs::metadata(&target_file) { + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + let _ = std::fs::set_permissions(&target_file, perms); + } + } + } + + let _ = tx.send(UpdaterEvent::DownloadSuccess { + file_path: target_file, + asset_name: asset.name, + }); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "Download fehlgeschlagen: {}", + stderr.trim() + ))); + } + } + Err(e) => { + let _ = tx.send(UpdaterEvent::DownloadError(format!( + "curl-Fehler beim Download: {e}" + ))); + } + } + }); + } + + /// Startet die Installation des heruntergeladenen Pakets oder öffnet es im System. + pub fn install_or_open(&self, file_path: &Path) -> Result<(), String> { + if !file_path.exists() { + return Err("Die heruntergeladene Datei existiert nicht mehr.".to_string()); + } + + match &self.runtime { + RuntimeEnvironment::AppImage { appimage_path } => { + // Bei AppImage: Die aktuelle Datei ersetzen oder neue Datei ausführen + // Sicherer Modus: Zielpfad überschreiben mit Backup + if let Some(parent) = appimage_path.parent() { + let backup_path = parent.join(format!( + "{}.bak", + appimage_path.file_name().unwrap().to_string_lossy() + )); + let _ = std::fs::copy(appimage_path, &backup_path); + } + match std::fs::copy(file_path, appimage_path) { + Ok(_) => Ok(()), + Err(e) => Err(format!( + "Konnte laufendes AppImage nicht direkt überschreiben: {e}. Die neue Datei liegt unter: {}", + file_path.display() + )), + } + } + RuntimeEnvironment::DebPackage { .. } => { + // Öffne mit Standardanwendung (auf Zorin OS: Zorin Software Store oder gdebi) + match Command::new("xdg-open").arg(file_path).spawn() { + Ok(_) => Ok(()), + Err(e) => Err(format!("Konnte xdg-open nicht ausführen: {e}")), + } + } + RuntimeEnvironment::PortableOrDev { .. } => { + // Im Dateimanager anzeigen + if let Some(dir) = file_path.parent() { + let _ = Command::new("xdg-open").arg(dir).spawn(); + } + Ok(()) + } + } + } + + /// Fragt periodisch ohne Blockieren eingehende Events vom Worker-Thread ab. + pub fn update(&mut self) { + while let Ok(event) = self.receiver.try_recv() { + match event { + UpdaterEvent::CheckSuccess(release) => { + self.status = UpdaterStatus::UpdateAvailable(release); + } + UpdaterEvent::CheckUpToDate(ver) => { + self.status = UpdaterStatus::UpToDate { + current_version: ver, + }; + } + UpdaterEvent::CheckError(err) => { + self.status = UpdaterStatus::Error(err); + } + UpdaterEvent::DownloadProgress { percent, .. } => { + if let UpdaterStatus::Downloading { asset_name, .. } = &self.status { + self.status = UpdaterStatus::Downloading { + asset_name: asset_name.clone(), + percent, + }; + } + } + UpdaterEvent::DownloadSuccess { + file_path, + asset_name, + } => { + self.status = UpdaterStatus::DownloadFinished { + file_path, + asset_name, + }; + } + UpdaterEvent::DownloadError(err) => { + self.status = UpdaterStatus::Error(err); + } + } + } + } +} + +impl Default for UpdaterController { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_semver_parsing() { + assert_eq!(parse_semver("v0.1.0"), Some((0, 1, 0))); + assert_eq!(parse_semver("0.1.0"), Some((0, 1, 0))); + assert_eq!(parse_semver("v1.2.3-rc1"), Some((1, 2, 3))); + assert_eq!(parse_semver("invalid"), None); + } + + #[test] + fn test_is_newer_version() { + assert!(is_newer_version("v0.2.0", "0.1.0")); + assert!(is_newer_version("0.1.1", "0.1.0")); + assert!(is_newer_version("1.0.0", "0.9.9")); + assert!(!is_newer_version("0.1.0", "0.1.0")); + assert!(!is_newer_version("v0.1.0", "0.1.0")); + assert!(!is_newer_version("0.0.9", "0.1.0")); + } + + #[test] + fn test_gitea_release_deserialization() { + let json_data = r#"{ + "id": 42, + "tag_name": "v0.2.0", + "name": "ink-notes v0.2.0 Release", + "body": "Neuerungen:\n- Zorin OS 18 Optimierung\n- Gitea Auto-Update", + "prerelease": false, + "html_url": "https://gitea.pansi.eu/harald/ink-notes/releases/tag/v0.2.0", + "published_at": "2026-10-06T12:00:00Z", + "assets": [ + { + "id": 101, + "name": "ink-notes_0.2.0_amd64.deb", + "size": 4313296, + "download_count": 5, + "browser_download_url": "https://gitea.pansi.eu/harald/ink-notes/releases/download/v0.2.0/ink-notes_0.2.0_amd64.deb" + }, + { + "id": 102, + "name": "ink-notes_0.2.0_x86_64.AppImage", + "size": 5872000, + "download_count": 8, + "browser_download_url": "https://gitea.pansi.eu/harald/ink-notes/releases/download/v0.2.0/ink-notes_0.2.0_x86_64.AppImage" + } + ] + }"#; + + let release: GiteaRelease = serde_json::from_str(json_data).expect("Sollte Release deserialisieren"); + assert_eq!(release.tag_name, "v0.2.0"); + assert_eq!(release.assets.len(), 2); + assert_eq!(release.assets[0].name, "ink-notes_0.2.0_amd64.deb"); + assert_eq!(release.assets[1].name, "ink-notes_0.2.0_x86_64.AppImage"); + assert!(is_newer_version(&release.tag_name, "0.1.0")); + } + + #[test] + fn test_runtime_environment_detection() { + let runtime = RuntimeEnvironment::detect(); + assert!(!runtime.display_name().is_empty()); + } +}