EuerChat 0.1.0: Tauri-Client mit Direktverbindung, ZNC, Livestream, Updater und Installer-Build

This commit is contained in:
Davito committed 2026-10-14 10:21:15 +02:00
commit df88f8abaa
27 files changed
+13167

No files matched your search

+4577
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "euer-chat"
version = "0.1.0"
description = "EuerChat Client fuer euer.tv"
edition = "2021"
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["rt", "net", "io-util", "time", "sync", "fs", "macros"] }
tokio-native-tls = "0.3"
native-tls = "0.2"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
reqwest = { version = "0.12", default-features = false, features = ["native-tls"] }
bytes = "1"
sha2 = "0.10"
# Kein panic = "abort": ein Panic in einem Netzwerk-Task soll nicht die ganze App beenden.
[profile.release]
codegen-units = 1
lto = true
opt-level = "s"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Standard Berechtigungen fuer das Hauptfenster",
"windows": ["main"],
"permissions": [
"core:event:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

+1110
View File
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
// Kein Konsolenfenster unter Windows
#![windows_subsystem = "windows"]
mod irc;
mod storage;
mod system;
mod updater;
use std::time::Duration;
use tauri::Manager;
fn main() {
tauri::Builder::default()
.manage(irc::AppState::default())
.manage(updater::UpdaterState::default())
.setup(|app| {
tauri::async_runtime::spawn(updater::cleanup_installers());
if let Some(window) = app.get_webview_window("main") {
storage::restore_window_state(&window);
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
storage::save_window_state(window);
// Dem Server kurz Zeit geben, das QUIT zu empfangen.
if window.state::<irc::AppState>().request_quit() {
std::thread::sleep(Duration::from_millis(300));
}
}
})
.invoke_handler(tauri::generate_handler![
irc::connect_irc,
irc::send_irc_message,
irc::send_irc_raw,
irc::disconnect_irc,
irc::scan_clones,
storage::save_app_config,
storage::load_app_config,
system::send_grussbox,
system::get_installed_fonts,
system::set_ui_zoom,
updater::get_app_version,
updater::check_for_update,
updater::install_update,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+139
View File
@@ -0,0 +1,139 @@
//! Persistenz: Benutzerkonfiguration und Fensterposition.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use tauri::{PhysicalPosition, PhysicalSize};
fn exe_dir() -> PathBuf {
std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn data_dir() -> PathBuf {
std::env::var_os("APPDATA")
.map(|d| PathBuf::from(d).join("EuerChat"))
.unwrap_or_else(exe_dir)
}
fn config_path() -> PathBuf {
data_dir().join("config.json")
}
/// Bis Version 0.1 lag die config.json neben der EXE; wird nur noch gelesen.
fn legacy_config_path() -> PathBuf {
exe_dir().join("config.json")
}
#[tauri::command]
pub async fn load_app_config() -> Result<String, String> {
for path in [config_path(), legacy_config_path()] {
match tokio::fs::read_to_string(&path).await {
Ok(content) => return Ok(content),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(format!("Fehler beim Lesen der Konfigurationsdatei: {e}")),
}
}
Err("Konfigurationsdatei existiert noch nicht.".to_string())
}
#[tauri::command]
pub async fn save_app_config(config_content: String) -> Result<String, String> {
serde_json::from_str::<serde_json::Value>(&config_content)
.map_err(|e| format!("Ungültige Konfiguration: {e}"))?;
let path = config_path();
let err = |e: std::io::Error| format!("Fehler beim Speichern der Konfiguration: {e}");
tokio::fs::create_dir_all(data_dir()).await.map_err(err)?;
// Erst in eine Temp-Datei schreiben, damit ein Absturz die Konfiguration nicht zerstoert.
let tmp = path.with_extension("json.tmp");
tokio::fs::write(&tmp, config_content).await.map_err(err)?;
tokio::fs::rename(&tmp, &path).await.map_err(err)?;
Ok(format!("Konfiguration gespeichert in: {}", path.display()))
}
#[derive(Serialize, Deserialize, Clone, Copy)]
struct WindowState {
x: i32,
y: i32,
width: u32,
height: u32,
maximized: bool,
}
fn window_state_path() -> PathBuf {
data_dir().join("window_state.json")
}
fn read_window_state() -> Option<WindowState> {
serde_json::from_str(&std::fs::read_to_string(window_state_path()).ok()?).ok()
}
/// Wird nur beim Schliessen aufgerufen. Maximiert/minimiert bleibt die letzte normale Geometrie erhalten.
pub fn save_window_state(window: &tauri::Window) {
let maximized = window.is_maximized().unwrap_or(false);
let minimized = window.is_minimized().unwrap_or(false);
let state = if maximized || minimized {
match read_window_state() {
Some(previous) => WindowState { maximized, ..previous },
None => return,
}
} else {
let (Ok(pos), Ok(size)) = (window.outer_position(), window.outer_size()) else {
return;
};
if size.width < 400 || size.height < 300 {
return;
}
WindowState {
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
maximized: false,
}
};
if let Ok(json) = serde_json::to_string(&state) {
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(window_state_path(), json);
}
}
fn is_on_a_monitor(window: &tauri::WebviewWindow, state: &WindowState) -> bool {
let (px, py) = (state.x + 50, state.y + 10);
window
.available_monitors()
.map(|monitors| {
monitors.iter().any(|m| {
let (pos, size) = (m.position(), m.size());
px >= pos.x
&& px < pos.x + size.width as i32
&& py >= pos.y
&& py < pos.y + size.height as i32
})
})
.unwrap_or(false)
}
pub fn restore_window_state(window: &tauri::WebviewWindow) {
let Some(state) = read_window_state() else {
return;
};
if state.width < 600 || state.height < 400 {
return;
}
let _ = window.set_size(PhysicalSize::new(state.width, state.height));
if is_on_a_monitor(window, &state) {
let _ = window.set_position(PhysicalPosition::new(state.x, state.y));
} else {
let _ = window.center();
}
if state.maximized {
let _ = window.maximize();
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Windows-spezifische Helfer und die Grussbox.
use std::collections::HashSet;
use std::os::windows::process::CommandExt;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::timeout;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const GRUSSBOX_HOST: &str = "www.euer-radio.de";
const FONT_SUFFIXES: [&str; 15] = [
" Bold Italic", " Bold", " Italic", " Light Italic", " Light",
" Semibold Italic", " Semibold", " SemiLight Italic", " SemiLight",
" Black Italic", " Black", " Medium Italic", " Medium",
" ExtraBold", " Regular",
];
fn read_installed_fonts() -> Vec<String> {
let hives = [
"HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts",
"HKCU\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Fonts",
];
let mut fonts = HashSet::new();
for hive in hives {
let Ok(output) = std::process::Command::new("reg")
.args(["query", hive])
.creation_flags(CREATE_NO_WINDOW)
.output()
else {
continue;
};
for line in String::from_utf8_lossy(&output.stdout).lines() {
let Some(pos) = line.find("REG_SZ") else {
continue;
};
let mut name = line[..pos].trim();
if let Some(paren) = name.find('(') {
name = name[..paren].trim();
}
let cleaned = FONT_SUFFIXES
.iter()
.find_map(|s| name.strip_suffix(s))
.unwrap_or(name)
.trim();
// "Cambria & Cambria Math" enthaelt mehrere Familien.
for family in cleaned.split('&').map(str::trim) {
if family.len() > 1 {
fonts.insert(family.to_string());
}
}
}
}
let mut list: Vec<String> = fonts.into_iter().collect();
list.sort_by_key(|f| f.to_lowercase());
list
}
#[tauri::command]
pub async fn get_installed_fonts() -> Vec<String> {
tauri::async_runtime::spawn_blocking(read_installed_fonts)
.await
.unwrap_or_default()
}
/// Skaliert die gesamte Oberflaeche (echter Webview-Zoom, Layout passt sich an).
#[tauri::command]
pub fn set_ui_zoom(window: tauri::WebviewWindow, factor: f64) -> Result<(), String> {
if !factor.is_finite() {
return Err("Ungültiger Zoom-Faktor".to_string());
}
window.set_zoom(factor.clamp(0.5, 3.0)).map_err(|e| e.to_string())
}
fn form_urlencode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for b in input.bytes() {
match b {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
async fn post_grussbox(name: &str, text: &str) -> Result<String, String> {
let body = format!("gbname={}&gbtext={}", form_urlencode(name), form_urlencode(text));
let connector = tokio_native_tls::TlsConnector::from(
native_tls::TlsConnector::new().map_err(|e| e.to_string())?,
);
let tcp = TcpStream::connect((GRUSSBOX_HOST, 443))
.await
.map_err(|e| format!("Verbindung zum Grußbox-Server fehlgeschlagen: {e}"))?;
let mut tls = connector
.connect(GRUSSBOX_HOST, tcp)
.await
.map_err(|e| format!("SSL-Verbindung fehlgeschlagen: {e}"))?;
let request = format!(
"POST /gbentry.php HTTP/1.1\r\nHost: {GRUSSBOX_HOST}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nUser-Agent: EuerChat/{}\r\nConnection: close\r\n\r\n{body}",
body.len(),
env!("CARGO_PKG_VERSION"),
);
tls.write_all(request.as_bytes()).await.map_err(|e| e.to_string())?;
tls.flush().await.map_err(|e| e.to_string())?;
let mut response = Vec::new();
tls.take(64 * 1024)
.read_to_end(&mut response)
.await
.map_err(|e| e.to_string())?;
let response = String::from_utf8_lossy(&response);
let status_ok = response.lines().next().is_some_and(|l| l.contains(" 200"));
if status_ok || response.contains("bermittelt") {
Ok("Eintrag erfolgreich übermittelt!".to_string())
} else {
Err("Der Grußbox-Server hat die Nachricht nicht angenommen.".to_string())
}
}
#[tauri::command]
pub async fn send_grussbox(name: String, text: String) -> Result<String, String> {
let (name, text) = (name.trim(), text.trim());
if name.is_empty() || text.is_empty() {
return Err("Name und Text dürfen nicht leer sein.".to_string());
}
if name.chars().count() > 100 || text.chars().count() > 2000 {
return Err("Name oder Text ist zu lang.".to_string());
}
timeout(Duration::from_secs(20), post_grussbox(name, text))
.await
.map_err(|_| "Zeitüberschreitung beim Senden der Grußbox.".to_string())?
}
+685
View File
@@ -0,0 +1,685 @@
//! Selbst-Updater: laedt neue Versionen aus den Releases eines oeffentlichen Gitea-Repositories.
//!
//! Das Repository braucht kein Token. Es werden nur HTTPS-URLs des vertrauenswuerdigen Hosts
//! akzeptiert (auch bei Weiterleitungen), und der Installer wird nur gestartet, wenn seine
//! SHA-256-Pruefsumme mit der Datei `<installer>.sha256` aus demselben Release uebereinstimmt.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use reqwest::{redirect, Client, Response, Url};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::io::AsyncWriteExt;
use tokio::sync::Mutex;
use tokio::time::timeout;
const GITEA_BASE_URL: &str = "https://gitea.davito.synology.me";
const GITEA_REPO: &str = "Davito/EuerChat";
const MAX_RELEASE_BYTES: usize = 2 * 1024 * 1024;
const MAX_CHECKSUM_BYTES: usize = 64 * 1024;
const MAX_INSTALLER_BYTES: u64 = 300 * 1024 * 1024;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const READ_TIMEOUT: Duration = Duration::from_secs(30);
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DETACHED_PROCESS: u32 = 0x0000_0008;
#[derive(Clone)]
struct Source {
base: Url,
repo: String,
// Nur fuer Tests mit lokalem Mock-Server.
allow_http: bool,
}
impl Source {
fn production() -> Self {
Self {
base: Url::parse(GITEA_BASE_URL).expect("gueltige Gitea-URL"),
repo: GITEA_REPO.to_string(),
allow_http: false,
}
}
fn latest_release_url(&self) -> String {
format!(
"{}/api/v1/repos/{}/releases/latest",
self.base.as_str().trim_end_matches('/'),
self.repo
)
}
fn is_trusted(&self, url: &Url) -> bool {
let scheme_ok = url.scheme() == "https" || (self.allow_http && url.scheme() == "http");
scheme_ok
&& url.host_str() == self.base.host_str()
&& url.port_or_known_default() == self.base.port_or_known_default()
&& url.username().is_empty()
&& url.password().is_none()
}
}
#[derive(Debug, Clone)]
struct Asset {
name: String,
url: String,
}
#[derive(Debug, Clone)]
struct Release {
version: String,
tag: String,
name: String,
body: String,
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct RawRelease {
tag_name: Option<String>,
name: Option<String>,
body: Option<String>,
assets: Option<Vec<RawAsset>>,
}
#[derive(Deserialize)]
struct RawAsset {
name: Option<String>,
browser_download_url: Option<String>,
}
#[derive(Default)]
pub struct UpdaterState {
pending: Mutex<Option<Release>>,
busy: AtomicBool,
}
#[derive(Serialize)]
pub struct UpdateCheck {
current: String,
latest: String,
available: bool,
name: String,
notes: String,
}
// ---------------------------------------------------------------------------
// Reine Hilfen
// ---------------------------------------------------------------------------
fn parse_version(value: &str) -> Option<(u32, u32, u32)> {
let mut parts = value.trim().trim_start_matches('v').split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = match parts.next() {
Some(p) => p.parse().ok()?,
None => 0,
};
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
fn is_newer(candidate: &str, installed: &str) -> Result<bool, String> {
let parse = |v: &str| parse_version(v).ok_or_else(|| format!("Ungültige Versionsnummer: {v:?}"));
Ok(parse(candidate)? > parse(installed)?)
}
fn installer_name(version: &str) -> String {
format!("EuerChat_{version}_x64-setup.exe")
}
fn is_managed_installer_file(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
let base = lower.strip_suffix(".part").unwrap_or(&lower);
base.starts_with("euerchat_") && base.ends_with("_x64-setup.exe")
}
fn select_assets(release: &Release) -> Result<(&Asset, &Asset), String> {
let expected = installer_name(&release.version);
let find = |wanted: &str| {
release
.assets
.iter()
.find(|a| a.name.eq_ignore_ascii_case(wanted))
};
let installer = find(&expected).ok_or_else(|| {
format!("Release {} enthält nicht den erwarteten Installer {expected}.", release.tag)
})?;
let checksum_name = format!("{expected}.sha256");
let checksum = find(&checksum_name).ok_or_else(|| {
format!("Release {} enthält keine SHA-256-Datei {checksum_name}.", release.tag)
})?;
Ok((installer, checksum))
}
/// Akzeptiert einen reinen Hash oder das `sha256sum`-Format (`<hash> <datei>`).
fn parse_expected_checksum(manifest: &str, filename: &str) -> Result<String, String> {
let mut fallback = None;
for line in manifest.lines().map(str::trim).filter(|l| !l.is_empty()) {
let mut parts = line.split_whitespace();
let Some(hash) = parts.next() else { continue };
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
let hash = hash.to_ascii_lowercase();
match parts.next() {
None => fallback = Some(hash),
Some(listed) if listed.trim_start_matches('*').eq_ignore_ascii_case(filename) => {
return Ok(hash)
}
Some(_) => {}
}
}
fallback.ok_or_else(|| format!("Keine SHA-256-Prüfsumme für {filename} gefunden."))
}
fn updates_dir() -> PathBuf {
crate::storage::data_dir().join("updates")
}
// ---------------------------------------------------------------------------
// Netzwerk
// ---------------------------------------------------------------------------
fn build_client(source: &Source) -> Result<Client, String> {
let trusted = source.clone();
Client::builder()
.user_agent(format!("EuerChat-Updater/{}", env!("CARGO_PKG_VERSION")))
.connect_timeout(CONNECT_TIMEOUT)
.redirect(redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= 5 {
attempt.error("zu viele Weiterleitungen")
} else if trusted.is_trusted(attempt.url()) {
attempt.follow()
} else {
attempt.error("nicht vertrauenswürdige Weiterleitung")
}
}))
.build()
.map_err(|e| format!("HTTP-Client konnte nicht erstellt werden: {e}"))
}
async fn get(client: &Client, source: &Source, url: &str) -> Result<Response, String> {
let parsed = Url::parse(url).map_err(|_| "Ungültige Update-URL.".to_string())?;
if !source.is_trusted(&parsed) {
return Err("Gitea lieferte eine nicht vertrauenswürdige URL.".to_string());
}
let response = client
.get(parsed)
.send()
.await
.map_err(|e| format!("Der Update-Server ist nicht erreichbar: {e}"))?;
if !source.is_trusted(response.url()) {
return Err("Gitea leitete auf eine nicht vertrauenswürdige URL weiter.".to_string());
}
Ok(response)
}
async fn next_chunk(response: &mut Response) -> Result<Option<bytes::Bytes>, String> {
timeout(READ_TIMEOUT, response.chunk())
.await
.map_err(|_| "Zeitüberschreitung beim Laden vom Update-Server.".to_string())?
.map_err(|e| format!("Fehler beim Laden vom Update-Server: {e}"))
}
async fn read_limited(mut response: Response, limit: usize) -> Result<Vec<u8>, String> {
let too_big = || "Die Antwort des Update-Servers ist unerwartet groß.".to_string();
if response.content_length().is_some_and(|l| l > limit as u64) {
return Err(too_big());
}
let mut buf = Vec::new();
while let Some(chunk) = next_chunk(&mut response).await? {
if buf.len() + chunk.len() > limit {
return Err(too_big());
}
buf.extend_from_slice(&chunk);
}
Ok(buf)
}
async fn fetch_latest_release(client: &Client, source: &Source) -> Result<Release, String> {
let response = get(client, source, &source.latest_release_url()).await?;
match response.status().as_u16() {
200 => {}
404 => return Err("Auf Gitea ist noch kein veröffentlichter Release vorhanden.".to_string()),
code => return Err(format!("Gitea antwortete mit HTTP {code}.")),
}
let payload = read_limited(response, MAX_RELEASE_BYTES).await?;
let raw: RawRelease = serde_json::from_slice(&payload)
.map_err(|_| "Gitea lieferte keine gültigen Release-Daten.".to_string())?;
let tag = raw.tag_name.unwrap_or_default().trim().to_string();
let (major, minor, patch) = parse_version(&tag)
.ok_or_else(|| format!("Ungültige Versionsnummer im Release: {tag:?}"))?;
let mut assets = Vec::new();
for asset in raw.assets.unwrap_or_default() {
let (Some(name), Some(url)) = (asset.name, asset.browser_download_url) else {
continue;
};
let parsed = Url::parse(&url).map_err(|_| "Gitea lieferte eine ungültige Asset-URL.".to_string())?;
if !source.is_trusted(&parsed) {
return Err("Gitea lieferte eine nicht vertrauenswürdige Asset-URL.".to_string());
}
assets.push(Asset { name, url });
}
Ok(Release {
version: format!("{major}.{minor}.{patch}"),
name: raw.name.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| tag.clone()),
body: raw.body.unwrap_or_default(),
tag,
assets,
})
}
/// Laedt den Installer, prueft die SHA-256-Summe und gibt den endgueltigen Pfad zurueck.
async fn download_installer(
client: &Client,
source: &Source,
release: &Release,
dir: &Path,
mut progress: impl FnMut(u8),
) -> Result<PathBuf, String> {
let (installer, checksum) = select_assets(release)?;
if installer.name.contains(['/', '\\']) {
return Err("Der Installername im Release ist ungültig.".to_string());
}
let manifest = read_limited(get(client, source, &checksum.url).await?, MAX_CHECKSUM_BYTES).await?;
let expected = parse_expected_checksum(&String::from_utf8_lossy(&manifest), &installer.name)?;
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| format!("Update-Ordner konnte nicht angelegt werden: {e}"))?;
let final_path = dir.join(&installer.name);
let partial_path = dir.join(format!("{}.part", installer.name));
let result = download_to_file(client, source, &installer.url, &partial_path, &expected, &mut progress).await;
if let Err(e) = result {
let _ = tokio::fs::remove_file(&partial_path).await;
return Err(e);
}
tokio::fs::rename(&partial_path, &final_path)
.await
.map_err(|e| format!("Der Installer konnte nicht gespeichert werden: {e}"))?;
progress(100);
Ok(final_path)
}
async fn download_to_file(
client: &Client,
source: &Source,
url: &str,
path: &Path,
expected_sha256: &str,
progress: &mut impl FnMut(u8),
) -> Result<(), String> {
let mut response = get(client, source, url).await?;
if !response.status().is_success() {
return Err(format!("Der Installer-Download schlug fehl (HTTP {}).", response.status().as_u16()));
}
let total = response.content_length().unwrap_or(0);
if total > MAX_INSTALLER_BYTES {
return Err("Der angebotene Installer ist unerwartet groß.".to_string());
}
let io_err = |e: std::io::Error| format!("Der Installer konnte nicht geschrieben werden: {e}");
let mut file = tokio::fs::File::create(path).await.map_err(io_err)?;
let mut hasher = Sha256::new();
let mut downloaded: u64 = 0;
let mut last_percent = 0u8;
while let Some(chunk) = next_chunk(&mut response).await? {
downloaded += chunk.len() as u64;
if downloaded > MAX_INSTALLER_BYTES {
return Err("Der geladene Installer ist unerwartet groß.".to_string());
}
hasher.update(&chunk);
file.write_all(&chunk).await.map_err(io_err)?;
if let Some(percent) = (downloaded * 100).checked_div(total) {
let percent = percent.min(99) as u8;
if percent != last_percent {
last_percent = percent;
progress(percent);
}
}
}
file.flush().await.map_err(io_err)?;
drop(file);
if downloaded == 0 {
return Err("Der heruntergeladene Installer ist leer.".to_string());
}
let actual: String = hasher.finalize().iter().map(|b| format!("{b:02x}")).collect();
if actual != expected_sha256 {
return Err("Die SHA-256-Prüfung des Installers ist fehlgeschlagen; die Datei wird nicht ausgeführt.".to_string());
}
Ok(())
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
/// Startet den Installer erst, wenn diese App beendet ist, und danach EuerChat neu.
fn spawn_installer_after_exit(installer: &Path) -> std::io::Result<()> {
use std::os::windows::process::CommandExt;
let quote = |p: &Path| p.to_string_lossy().replace('\'', "''");
let current = std::env::current_exe()?;
let installed_here = current
.parent()
.is_some_and(|dir| dir.join("uninstall.exe").exists());
// Eine portable EXE wird per Installer in %LOCALAPPDATA%\EuerChat aktualisiert; dort neu starten.
let script = format!(
"Wait-Process -Id {pid} -Timeout 30 -ErrorAction SilentlyContinue; \
Start-Process -FilePath '{installer}' -ArgumentList '/S','/UPDATE' -Wait; \
$installed = Join-Path $env:LOCALAPPDATA 'EuerChat\\euer-chat.exe'; \
if (-not {installed_here} -and (Test-Path $installed)) {{ Start-Process -FilePath $installed }} \
else {{ Start-Process -FilePath '{current}' }}",
pid = std::process::id(),
installer = quote(installer),
installed_here = if installed_here { "$true" } else { "$false" },
current = quote(&current),
);
std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", &script])
.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS)
.spawn()
.map(|_| ())
}
/// Raeumt alte Installer-Downloads auf; gesperrte Dateien werden beim naechsten Start erneut versucht.
pub async fn cleanup_installers() {
let Ok(mut entries) = tokio::fs::read_dir(updates_dir()).await else {
return;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let is_file = entry.file_type().await.map(|t| t.is_file()).unwrap_or(false);
if is_file && is_managed_installer_file(&entry.file_name().to_string_lossy()) {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
// ---------------------------------------------------------------------------
// Tauri-Commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn get_app_version(app: AppHandle) -> String {
app.package_info().version.to_string()
}
#[tauri::command]
pub async fn check_for_update(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateCheck, String> {
let current = app.package_info().version.to_string();
let source = Source::production();
let client = build_client(&source)?;
let release = fetch_latest_release(&client, &source).await?;
let available = is_newer(&release.version, &current)?;
if available {
// Fehlende Dateien sofort melden, nicht erst beim Installieren.
select_assets(&release)?;
}
let check = UpdateCheck {
current,
latest: release.version.clone(),
available,
name: release.name.clone(),
notes: release.body.clone(),
};
*state.pending.lock().await = available.then_some(release);
Ok(check)
}
#[tauri::command]
pub async fn install_update(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<(), String> {
if state.busy.swap(true, Ordering::SeqCst) {
return Err("Ein Update läuft bereits.".to_string());
}
let result = run_install(&app, &state).await;
if result.is_err() {
state.busy.store(false, Ordering::SeqCst);
}
result
}
async fn run_install(app: &AppHandle, state: &UpdaterState) -> Result<(), String> {
let release = state
.pending
.lock()
.await
.clone()
.ok_or_else(|| "Es ist kein Update vorgemerkt. Bitte erneut nach Updates suchen.".to_string())?;
let source = Source::production();
let client = build_client(&source)?;
let installer = download_installer(&client, &source, &release, &updates_dir(), |percent| {
let _ = app.emit("update-progress", percent);
})
.await?;
spawn_installer_after_exit(&installer)
.map_err(|e| format!("Die Installation konnte nicht gestartet werden: {e}"))?;
// Dem IRC-Server noch ein sauberes QUIT schicken, dann beenden.
if app.state::<crate::irc::AppState>().request_quit() {
tokio::time::sleep(Duration::from_millis(300)).await;
}
app.exit(0);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test]
fn version_parsing_and_comparison() {
assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_version("0.2"), Some((0, 2, 0)));
assert_eq!(parse_version("1.2.3-beta"), None);
assert_eq!(parse_version("1.2.3.4"), None);
assert!(is_newer("0.10.0", "0.9.9").unwrap());
assert!(!is_newer("v0.1.0", "0.1.0").unwrap());
assert!(is_newer("x", "0.1.0").is_err());
}
#[test]
fn only_the_trusted_https_host_is_accepted() {
let source = Source::production();
let ok = |u: &str| source.is_trusted(&Url::parse(u).unwrap());
assert!(ok("https://gitea.davito.synology.me/Davito/EuerChat/releases/download/v1/a.exe"));
assert!(ok("https://gitea.davito.synology.me:443/x"));
assert!(!ok("http://gitea.davito.synology.me/x"));
assert!(!ok("https://gitea.davito.synology.me:8443/x"));
assert!(!ok("https://evil.example.com/x"));
assert!(!ok("https://gitea.davito.synology.me.evil.com/x"));
assert!(!ok("https://user:pw@gitea.davito.synology.me/x"));
}
#[test]
fn checksum_formats() {
let hash = "a".repeat(64);
assert_eq!(parse_expected_checksum(&hash, "x.exe").unwrap(), hash);
assert_eq!(
parse_expected_checksum(&format!("{} *X.exe\n", hash.to_uppercase()), "x.exe").unwrap(),
hash
);
assert!(parse_expected_checksum("nonsense", "x.exe").is_err());
let other = format!("{} other.exe", "b".repeat(64));
assert!(parse_expected_checksum(&other, "x.exe").unwrap_err().contains("x.exe"));
}
#[test]
fn managed_installer_files() {
assert!(is_managed_installer_file("EuerChat_0.2.0_x64-setup.exe"));
assert!(is_managed_installer_file("EuerChat_0.2.0_x64-setup.exe.part"));
assert!(!is_managed_installer_file("config.json"));
assert!(!is_managed_installer_file("other-setup.exe"));
}
fn release_with(version: &str, names: &[&str]) -> Release {
Release {
version: version.to_string(),
tag: format!("v{version}"),
name: String::new(),
body: String::new(),
assets: names
.iter()
.map(|n| Asset { name: n.to_string(), url: String::new() })
.collect(),
}
}
#[test]
fn asset_selection_requires_installer_and_checksum() {
let both = release_with("0.2.0", &["EuerChat_0.2.0_x64-setup.exe", "EuerChat_0.2.0_x64-setup.exe.sha256"]);
assert!(select_assets(&both).is_ok());
let no_sum = release_with("0.2.0", &["EuerChat_0.2.0_x64-setup.exe"]);
assert!(select_assets(&no_sum).is_err());
let wrong = release_with("0.2.0", &["EuerChat_0.1.0_x64-setup.exe", "EuerChat_0.1.0_x64-setup.exe.sha256"]);
assert!(select_assets(&wrong).is_err());
}
type Routes = Vec<(String, u16, Option<String>, Vec<u8>)>;
/// Minimaler HTTP-Server: Pfad -> (Status, Header-Zusatz, Body).
async fn serve(routes: Routes) -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
spawn_server(listener, routes);
port
}
fn spawn_server(listener: TcpListener, routes: Routes) {
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let routes = routes.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).await.unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let path = request.split_whitespace().nth(1).unwrap_or("/").to_string();
let (status, extra, body) = routes
.iter()
.find(|(p, ..)| *p == path)
.map(|(_, s, e, b)| (*s, e.clone(), b.clone()))
.unwrap_or((404, None, Vec::new()));
let head = format!(
"HTTP/1.1 {status} X\r\nContent-Length: {}\r\n{}Connection: close\r\n\r\n",
body.len(),
extra.map(|e| format!("{e}\r\n")).unwrap_or_default()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(&body).await;
});
}
});
}
fn test_source(port: u16) -> Source {
Source {
base: Url::parse(&format!("http://127.0.0.1:{port}")).unwrap(),
repo: "Davito/EuerChat".to_string(),
allow_http: true,
}
}
fn sha256_hex(data: &[u8]) -> String {
Sha256::digest(data).iter().map(|b| format!("{b:02x}")).collect()
}
async fn setup(installer: &[u8], checksum_hash: &str) -> (Source, Client) {
// Port vorab reservieren, damit die Asset-URLs im JSON stimmen.
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let name = "EuerChat_0.2.0_x64-setup.exe";
let json = serde_json::json!({
"tag_name": "v0.2.0",
"name": "EuerChat 0.2.0",
"body": "Neu: Updater",
"assets": [
{"name": name, "browser_download_url": format!("http://127.0.0.1:{port}/dl/{name}")},
{"name": format!("{name}.sha256"), "browser_download_url": format!("http://127.0.0.1:{port}/dl/{name}.sha256")}
]
});
let routes = vec![
("/api/v1/repos/Davito/EuerChat/releases/latest".to_string(), 200, None, json.to_string().into_bytes()),
(format!("/dl/{name}"), 200, None, installer.to_vec()),
(format!("/dl/{name}.sha256"), 200, None, format!("{checksum_hash} {name}\n").into_bytes()),
("/redirect".to_string(), 302, Some("Location: https://evil.example.com/x".to_string()), Vec::new()),
];
spawn_server(listener, routes);
let source = test_source(port);
let client = build_client(&source).unwrap();
(source, client)
}
#[tokio::test]
async fn fetches_release_and_downloads_verified_installer() {
let data = b"fake installer bytes".repeat(1000);
let (source, client) = setup(&data, &sha256_hex(&data)).await;
let release = fetch_latest_release(&client, &source).await.unwrap();
assert_eq!(release.version, "0.2.0");
assert_eq!(release.body, "Neu: Updater");
let dir = std::env::temp_dir().join(format!("euerchat-updater-test-{}", std::process::id()));
let mut last = 0u8;
let path = download_installer(&client, &source, &release, &dir, |p| last = p).await.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), data);
assert_eq!(last, 100);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn rejects_installer_with_wrong_checksum_and_removes_partial_file() {
let data = b"tampered".repeat(100);
let (source, client) = setup(&data, &"0".repeat(64)).await;
let release = fetch_latest_release(&client, &source).await.unwrap();
let dir = std::env::temp_dir().join(format!("euerchat-updater-bad-{}", std::process::id()));
let err = download_installer(&client, &source, &release, &dir, |_| {}).await.unwrap_err();
assert!(err.contains("SHA-256"));
let leftovers: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().collect();
assert!(leftovers.is_empty(), "keine .part- oder fertige Datei erwartet");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn rejects_redirect_to_untrusted_host() {
let (source, client) = setup(b"x", &"0".repeat(64)).await;
let url = format!("{}/redirect", source.base.as_str().trim_end_matches('/'));
assert!(get(&client, &source, &url).await.is_err());
}
#[tokio::test]
async fn missing_release_is_reported() {
let port = serve(vec![]).await;
let source = test_source(port);
let client = build_client(&source).unwrap();
let err = fetch_latest_release(&client, &source).await.unwrap_err();
assert!(err.contains("noch kein"));
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "EuerChat",
"version": "0.1.0",
"identifier": "tv.euer.chat",
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "EuerChat",
"width": 1100,
"height": 720,
"minWidth": 800,
"minHeight": 550,
"center": true,
"resizable": true
}
],
"security": {
"csp": {
"default-src": "'self'",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'",
"style-src-attr": "'unsafe-inline'",
"img-src": "'self' data: blob: https://chat.euer.tv",
"media-src": "'self' blob: https://*.euer.tv",
"worker-src": "'self' blob:",
"connect-src": "'self' ipc: http://ipc.localhost https://*.euer.tv",
"font-src": "'self'",
"object-src": "'none'",
"frame-src": "'none'",
"base-uri": "'self'",
"form-action": "'self'"
}
}
},
"bundle": {
"active": true,
"targets": ["nsis"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico"],
"shortDescription": "EuerChat - IRC-Chat und Livestream-Client fuer euer.tv",
"windows": {
"nsis": {
"installMode": "currentUser",
"languages": ["German"],
"displayLanguageSelector": false
}
}
}
}