From dfb5a6491759f2ba336b6ee9d73486f9e7c2d914 Mon Sep 17 00:00:00 2001 From: Harald Date: Tue, 6 Oct 2026 11:30:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(release):=20Packaging=20f=C3=BCr=20Zorin?= =?UTF-8?q?=20OS=2018=20(.deb=20&=20.AppImage),=20In-App=20Gitea=20Updater?= =?UTF-8?q?=20und=20CI/CD=20Workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/release.yaml | 50 +++ Cargo.lock | 1 + Cargo.toml | 1 + README.md | 58 +++- assets/ink-notes.png | Bin 0 -> 13906 bytes assets/ink-notes.svg | 83 +++++ packaging/ink-notes.desktop | 18 ++ packaging/ink-notes.metainfo.xml | 38 +++ scripts/build-release.sh | 40 +++ scripts/generate_png_icon.py | 192 ++++++++++++ scripts/package-appimage.sh | 81 +++++ scripts/package-deb.sh | 76 +++++ scripts/publish-gitea-release.sh | 140 +++++++++ src/lib.rs | 1 + src/main.rs | 39 ++- src/ui.rs | 386 +++++++++++++++++++++++- src/updater.rs | 503 +++++++++++++++++++++++++++++++ 17 files changed, 1698 insertions(+), 9 deletions(-) create mode 100644 .gitea/workflows/release.yaml create mode 100644 assets/ink-notes.png create mode 100644 assets/ink-notes.svg create mode 100644 packaging/ink-notes.desktop create mode 100644 packaging/ink-notes.metainfo.xml create mode 100755 scripts/build-release.sh create mode 100644 scripts/generate_png_icon.py create mode 100755 scripts/package-appimage.sh create mode 100755 scripts/package-deb.sh create mode 100755 scripts/publish-gitea-release.sh create mode 100644 src/updater.rs 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 0000000000000000000000000000000000000000..9506f5aaacf496fc1b18cdeee85ea6887d83c50f GIT binary patch literal 13906 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 K7OziVPWWG8^+}ch literal 0 HcmV?d00001 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()); + } +}