feat(release): Packaging für Zorin OS 18 (.deb & .AppImage), In-App Gitea Updater und CI/CD Workflow
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 5m26s
Build & Publish Release / Build Packages & Publish to Gitea (push) Successful in 5m26s
This commit is contained in:
@@ -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 }}
|
||||
Generated
+1
@@ -961,6 +961,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"webbrowser",
|
||||
"wgpu",
|
||||
"winit",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <DEIN_GITEA_TOKEN>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,83 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<!-- Hintergrund-Verlauf -->
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1e2638"/>
|
||||
<stop offset="50%" stop-color="#141824"/>
|
||||
<stop offset="100%" stop-color="#0c0e17"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Stiftkörper-Verlauf -->
|
||||
<linearGradient id="penBody" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#1d4ed8"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Goldene Feder-Spitze -->
|
||||
<linearGradient id="nibGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#fbbf24"/>
|
||||
<stop offset="50%" stop-color="#f59e0b"/>
|
||||
<stop offset="100%" stop-color="#d97706"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Tintenspur-Verlauf (Dynamischer Schweif) -->
|
||||
<linearGradient id="inkGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.9"/>
|
||||
<stop offset="50%" stop-color="#3b82f6" stop-opacity="0.75"/>
|
||||
<stop offset="100%" stop-color="#2563eb" stop-opacity="0.3"/>
|
||||
</linearGradient>
|
||||
|
||||
<!-- Leucht-Effekt -->
|
||||
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- App-Icon Hintergrund (Squircle / Zorin OS Style) -->
|
||||
<rect x="24" y="24" width="464" height="464" rx="104" ry="104" fill="url(#bgGrad)" stroke="#384260" stroke-width="4"/>
|
||||
|
||||
<!-- Subtiles DIN-A4 Dokumenten-Gitter im Hintergrund -->
|
||||
<g opacity="0.12" stroke="#94a3b8" stroke-width="2">
|
||||
<line x1="80" y1="160" x2="432" y2="160" />
|
||||
<line x1="80" y1="220" x2="432" y2="220" />
|
||||
<line x1="80" y1="280" x2="432" y2="280" />
|
||||
<line x1="80" y1="340" x2="432" y2="340" />
|
||||
<line x1="80" y1="400" x2="432" y2="400" />
|
||||
|
||||
<line x1="140" y1="100" x2="140" y2="430" />
|
||||
<line x1="200" y1="100" x2="200" y2="430" />
|
||||
<line x1="260" y1="100" x2="260" y2="430" />
|
||||
<line x1="320" y1="100" x2="320" y2="430" />
|
||||
<line x1="380" y1="100" x2="380" y2="430" />
|
||||
</g>
|
||||
|
||||
<!-- Dynamische Tintenspur (Spline-Kurve) -->
|
||||
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||
fill="none" stroke="url(#inkGrad)" stroke-width="26" stroke-linecap="round" filter="url(#glow)"/>
|
||||
|
||||
<path d="M 110 390 C 180 430, 260 380, 240 310 C 220 240, 280 190, 340 220"
|
||||
fill="none" stroke="#93c5fd" stroke-width="12" stroke-linecap="round"/>
|
||||
|
||||
<!-- Stylus / Füllfederhalter (diagonal ausgerichtet von rechts oben nach links unten) -->
|
||||
<g transform="translate(240, 110) rotate(45)">
|
||||
<!-- Schaft des Stifts -->
|
||||
<rect x="-24" y="-140" width="48" height="150" rx="8" fill="url(#penBody)" stroke="#60a5fa" stroke-width="3"/>
|
||||
|
||||
<!-- Griffzone & Zierring -->
|
||||
<rect x="-22" y="10" width="44" height="26" rx="4" fill="#1e293b" stroke="#cbd5e1" stroke-width="2"/>
|
||||
<rect x="-25" y="6" width="50" height="6" fill="#f8fafc"/>
|
||||
|
||||
<!-- Goldene Schreibfeder -->
|
||||
<path d="M -20 36 L 20 36 L 12 85 L 0 115 L -12 85 Z" fill="url(#nibGrad)" stroke="#fef08a" stroke-width="2"/>
|
||||
|
||||
<!-- Tintenschlitz der Feder -->
|
||||
<line x1="0" y1="46" x2="0" y2="108" stroke="#78350f" stroke-width="3" stroke-linecap="round"/>
|
||||
<!-- Atmungsloch der Feder -->
|
||||
<circle cx="0" cy="65" r="4" fill="#78350f"/>
|
||||
</g>
|
||||
|
||||
<!-- Glänzender Tintenfleck / Kontaktpunkt am Pfad-Ursprung -->
|
||||
<circle cx="110" cy="390" r="14" fill="#60a5fa" filter="url(#glow)"/>
|
||||
<circle cx="110" cy="390" r="8" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -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
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>eu.pansi.ink-notes</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>ink-notes</name>
|
||||
<summary>Latenzoptimierte Vektor-Notizbuch- und Inking-App für Linux</summary>
|
||||
<description>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>Hauptmerkmale:</p>
|
||||
<ul>
|
||||
<li>Zwei-Stufen-Inking-Modell: Echtzeit-Glättung mit 1€-Filter und akausale Ecken-Erhaltung</li>
|
||||
<li>Automatische Geometrie- und Formerkennung (Geraden, Rechtecke, Kreise, Koordinatensysteme, Polygone)</li>
|
||||
<li>Stufenloser Viewport mit cursor-zentriertem Zoom (25% bis 500%) und Panning</li>
|
||||
<li>Objektauswahl, Lasso, Skalieren, stufenloses Drehen und Multicolor-Umfärbung</li>
|
||||
<li>Multi-Dokumenten- und Notizbuch-Verwaltung mit atomarem Autosave</li>
|
||||
<li>Vektor-PDF-Export für Druck und Weitergabe</li>
|
||||
</ul>
|
||||
</description>
|
||||
<launchable type="desktop-id">ink-notes.desktop</launchable>
|
||||
<provides>
|
||||
<binary>ink-notes</binary>
|
||||
</provides>
|
||||
<categories>
|
||||
<category>Office</category>
|
||||
<category>Graphics</category>
|
||||
<category>Utility</category>
|
||||
</categories>
|
||||
<developer id="eu.pansi">
|
||||
<name>Harald</name>
|
||||
</developer>
|
||||
<url type="homepage">https://farmersdog.pansi.eu/harald/ink-notes</url>
|
||||
<url type="bugtracker">https://farmersdog.pansi.eu/harald/ink-notes/issues</url>
|
||||
<content_rating type="oars-1.1" />
|
||||
</component>
|
||||
Executable
+40
@@ -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 "======================================================================"
|
||||
@@ -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)")
|
||||
Executable
+81
@@ -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}"
|
||||
Executable
+76
@@ -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 <<EOF > "${STAGING_DIR}/DEBIAN/control"
|
||||
Package: ${PKG_NAME}
|
||||
Version: ${VERSION}
|
||||
Architecture: ${ARCH}
|
||||
Maintainer: Harald <harald@pansi.eu>
|
||||
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!"
|
||||
Executable
+140
@@ -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 <GITEA_API_TOKEN>"
|
||||
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 <<EOF
|
||||
## ink-notes ${TAG_NAME}
|
||||
|
||||
Latenzoptimierte Vektor-Notizbuch- und Inking-Anwendung für Linux (Zorin OS 18 / Ubuntu 24.04).
|
||||
|
||||
### Highlights:
|
||||
- **Zwei-Stufen-Inking-Modell**: Kausale Echtzeit-Glättung via 1€-Filter & akausale Ecken-Erhaltung (Corner Preservation)
|
||||
- **Geometrie- und Formerkennung**: Geraden, Rechtecke, Kreise, Rauten, Polygone & Koordinatensysteme (Draw-and-Hold)
|
||||
- **Viewport**: Stufenloser Zoom (25% - 500%) und Panning
|
||||
- **Multi-Dokumenten-Manager**: Unbegrenzte Notizbücher mit atomarem Autosave
|
||||
- **In-App Updater**: Automatische Versionsprüfung und Aktualisierung via Gitea Releases
|
||||
- **Vektor-PDF-Export**: Verluste freie Ausgabe für Druck und Weitergabe
|
||||
|
||||
### Installation:
|
||||
- **Zorin OS / Ubuntu / Debian (.deb)**:
|
||||
\`\`\`bash
|
||||
sudo dpkg -i ink-notes_${VERSION}_amd64.deb
|
||||
\`\`\`
|
||||
- **Portables AppImage (Alle Linux-Distributionen)**:
|
||||
\`\`\`bash
|
||||
chmod +x ink-notes_${VERSION}_x86_64.AppImage
|
||||
./ink-notes_${VERSION}_x86_64.AppImage
|
||||
\`\`\`
|
||||
EOF
|
||||
)
|
||||
|
||||
# 1. Release via Gitea REST API erstellen (oder ID abfragen, falls bereits existent)
|
||||
CREATE_PAYLOAD=$(python3 -c "
|
||||
import json, sys
|
||||
data = {
|
||||
'tag_name': '${TAG_NAME}',
|
||||
'target_commitish': 'main',
|
||||
'name': '${RELEASE_TITLE}',
|
||||
'body': '''${RELEASE_BODY}''',
|
||||
'draft': False,
|
||||
'prerelease': False
|
||||
}
|
||||
print(json.dumps(data))
|
||||
")
|
||||
|
||||
API_BASE="${GITEA_SERVER}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}"
|
||||
|
||||
echo "Erstelle Release über Gitea API..."
|
||||
HTTP_RESPONSE=$(curl -s -w "\nHTTP_STATUS:%{http_code}" \
|
||||
-X POST "${API_BASE}/releases" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-d "${CREATE_PAYLOAD}")
|
||||
|
||||
HTTP_STATUS=$(echo "${HTTP_RESPONSE}" | grep "HTTP_STATUS:" | cut -d':' -f2)
|
||||
RESPONSE_BODY=$(echo "${HTTP_RESPONSE}" | sed '/HTTP_STATUS:/d')
|
||||
|
||||
RELEASE_ID=""
|
||||
if [ "${HTTP_STATUS}" -eq 201 ]; then
|
||||
RELEASE_ID=$(echo "${RESPONSE_BODY}" | python3 -c "import sys, json; print(json.load(sys.stdin)['id'])")
|
||||
echo "Release erfolgreich angelegt (ID: ${RELEASE_ID})!"
|
||||
else
|
||||
echo "Release existiert womöglich bereits (Status ${HTTP_STATUS}). Suche bestehendes Release..."
|
||||
EXISTING_RELEASE=$(curl -fsSL \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${API_BASE}/releases/tags/${TAG_NAME}" || true)
|
||||
if [ -n "${EXISTING_RELEASE}" ]; then
|
||||
RELEASE_ID=$(echo "${EXISTING_RELEASE}" | python3 -c "import sys, json; print(json.load(sys.stdin).get('id', ''))")
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${RELEASE_ID}" ]; then
|
||||
echo "FEHLER: Konnte Release nicht erstellen oder abfragen. Serverantwort:"
|
||||
echo "${RESPONSE_BODY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Assets hochladen
|
||||
upload_asset() {
|
||||
local file_path="$1"
|
||||
local filename="$(basename "${file_path}")"
|
||||
echo "Lade Asset hoch: ${filename}..."
|
||||
curl -fsSL \
|
||||
-X POST "${API_BASE}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${file_path}" > /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 "======================================================================"
|
||||
@@ -12,4 +12,5 @@ pub mod selection;
|
||||
pub mod spline;
|
||||
pub mod storage;
|
||||
pub mod ui;
|
||||
pub mod updater;
|
||||
pub mod viewport;
|
||||
|
||||
+38
-1
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> },
|
||||
}
|
||||
|
||||
/// Zustand der grafischen Benutzeroberfläche.
|
||||
@@ -144,6 +150,13 @@ pub struct UiState {
|
||||
pub new_notebook_title_input: String,
|
||||
pub renaming_notebook_id: Option<String>,
|
||||
pub renaming_title_input: String,
|
||||
pub document_modal_rect: Option<egui::Rect>,
|
||||
/// 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<egui::Rect>,
|
||||
}
|
||||
|
||||
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<UiAction> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+503
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
pub assets: Vec<GiteaAsset>,
|
||||
}
|
||||
|
||||
/// 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<UpdaterEvent>,
|
||||
sender: Sender<UpdaterEvent>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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::<GiteaRelease>(&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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user