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

This commit is contained in:
2026-10-06 11:30:04 +02:00
parent 8a25b1e2f3
commit dfb5a64917
17 changed files with 1698 additions and 9 deletions
+1
View File
@@ -12,4 +12,5 @@ pub mod selection;
pub mod spline;
pub mod storage;
pub mod ui;
pub mod updater;
pub mod viewport;
+38 -1
View File
@@ -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, &notebook, &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, &notebook, autosave.status());
updater.update();
let actions = ui::render_ui(&egui_ctx, &mut ui_state, &notebook, 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();
}
}
}
+383 -3
View File
@@ -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, &notebook, AutosaveStatus::Saved);
let actions = render_ui(&ctx, &mut state, &notebook, 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, &notebook, AutosaveStatus::Saved);
let _ = super::render_ui(ctx, &mut state, &notebook, 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, &notebook, 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
View File
@@ -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, &current_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());
}
}