Files
tessera-ctl/apps/desktop/src-tauri/src/lib.rs
T
schalli 5bdabf558b feat(desktop): Client meldet sich per desktop=1, Beta-Hinweis nennt den Stand
- with_desktop_marker haengt desktop=1 an beide Navigationen (save_server_url,
  Startnavigation im setup); der Store-Wert server_url bleibt ohne Parameter
- update_labels liefert Menue-/Benachrichtigungstext: bei Versionswechsel wie
  bisher, bei gleicher Version (Beta-Kanal) nennt der Text den Commit-Stempel
- mod tests deckt beide Helfer ab (fmt/check/clippy/test --lib gruen)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:35:20 +02:00

350 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::time::Duration;
use tauri::{
menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, RunEvent, WindowEvent,
};
use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_notification::NotificationExt;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_store::StoreExt;
#[derive(serde::Deserialize)]
struct VersionResponse {
version: String,
}
#[derive(serde::Deserialize)]
struct DesktopLatest {
version: String,
channel: String,
commit: String,
}
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
/// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem
/// Web-Hostnamen. Dies ist die einzige Stelle, an der der Rewrite-Praefix
/// steht.
fn api_url(server: &str, path: &str) -> String {
format!("{}/api-proxy{}", server.trim_end_matches('/'), path)
}
/// Haengt das Query-Paar `desktop=1` an einen Klon der Adresse an und gibt
/// den Klon zurueck -- das Original bleibt unveraendert. Der Parameter geht
/// NUR in die Navigation, niemals in den gespeicherten Server-Wert
/// (`server_url` im Store bleibt die reine Adresse); daraus liest die
/// Next.js-Middleware (apps/web/src/middleware.ts) das Cookie
/// `tessera_desktop`, an dem der Web-Client den Desktop-Client erkennt. Die
/// Tray-URL "Update herunterladen" bekommt den Parameter bewusst NICHT --
/// sie oeffnet im System-Browser, dort muessen die Download-Links sichtbar
/// bleiben.
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
let mut marked = url.clone();
marked.query_pairs_mut().append_pair("desktop", "1");
marked
}
/// Liefert (Menuetext, Benachrichtigungstext) fuer den Update-Hinweis. Der
/// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version (D-07,
/// desktop-collect.sh) -- hat sich nur der Commit geaendert, waere der reine
/// Versionstext ("Version 1.1.0 herunterladen") verwirrend, weil der Client
/// bereits genau diese Version hat. Der zweite Zweig nennt darum den
/// Commit-Stempel statt der unveraenderten Version.
fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String, String) {
if version_changed {
(
format!("Version {} herunterladen", version),
format!(
"Neue Version {} verfügbar – Download über das Symbol im Infobereich.",
version
),
)
} else {
(
"Neuen Beta-Stand herunterladen".to_string(),
format!(
"Neuer Beta-Stand {} verfügbar – Download über das Symbol im Infobereich.",
commit
),
)
}
}
#[tauri::command]
async fn check_server(url: String) -> Result<String, String> {
let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?;
if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err("Es sind nur Adressen mit http oder https erlaubt.".to_string());
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get(api_url(&url, "/health/version"))
.send()
.await
.map_err(|_| "Unter dieser Adresse antwortet kein Tessera-Server.".to_string())?;
if !resp.status().is_success() {
return Err(format!(
"Der Server antwortete mit Status {}.",
resp.status().as_u16()
));
}
let info = resp
.json::<VersionResponse>()
.await
.map_err(|_| "Unter dieser Adresse antwortet kein Tessera-Server.".to_string())?;
Ok(info.version)
}
#[tauri::command]
fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?;
let normalized = parsed.as_str().to_string();
let store = app.store("config.json").map_err(|e| e.to_string())?;
store.set("server_url", serde_json::json!(normalized));
store.save().map_err(|e| e.to_string())?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.navigate(with_desktop_marker(&parsed));
}
Ok(())
}
pub fn run() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![check_server, save_server_url])
.setup(|app| {
let store = app.store("config.json")?;
let server_url = store.get("server_url");
let mut url_for_check: Option<String> = None;
if let Some(url_value) = server_url {
if let Some(url_str) = url_value.as_str() {
if !url_str.is_empty() {
url_for_check = Some(url_str.to_string());
if let Some(window) = app.get_webview_window("main") {
if let Ok(parsed) = url_str.parse::<tauri::Url>() {
let _ = window.navigate(with_desktop_marker(&parsed));
}
}
}
}
}
// Tray menu: Öffnen · Update herunterladen · — · Autostart-Haken · — · Beenden.
// "update" bleibt gesperrt, bis die Versionspruefung eine neuere Version
// findet; "autostart" spiegelt den tatsaechlichen Systemzustand beim Start.
let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?;
let update = MenuItemBuilder::with_id("update", "Update herunterladen")
.enabled(false)
.build(app)?;
let autostart_label = if cfg!(target_os = "windows") {
"Mit Windows starten"
} else {
"Beim Anmelden starten"
};
let autostart = CheckMenuItemBuilder::with_id("autostart", autostart_label)
.checked(app.autolaunch().is_enabled().unwrap_or(false))
.build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
let menu = MenuBuilder::new(app)
.item(&open)
.item(&update)
.separator()
.item(&autostart)
.separator()
.item(&quit)
.build()?;
let server_for_menu = url_for_check.clone();
let autostart_for_menu = autostart.clone();
let _tray = TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.tooltip("Tessera")
.show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id().as_ref() {
"open" => {
if let Some(w) = app.get_webview_window("main") {
// Minimiertes Fenster (z. B. per Win+D) zuerst wiederherstellen
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
"update" => {
if let Some(server) = &server_for_menu {
let target = format!(
"{}/settings/general/desktop",
server.trim_end_matches('/')
);
let _ = app.opener().open_url(target, None::<&str>);
}
}
"autostart" => {
let mgr = app.autolaunch();
let currently_on = mgr.is_enabled().unwrap_or(false);
let toggled = if currently_on {
mgr.disable()
} else {
mgr.enable()
};
match toggled {
Ok(()) => {
let _ = autostart_for_menu.set_checked(!currently_on);
}
Err(_) => {
let _ = autostart_for_menu.set_checked(currently_on);
}
}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
if let Some(w) = tray.app_handle().get_webview_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
})
.build(app)?;
// Version check (async, non-blocking)
if let Some(server_url) = url_for_check {
let app_handle = app.handle().clone();
let app_version = env!("CARGO_PKG_VERSION").to_string();
let app_commit = env!("APP_COMMIT").to_string();
let update_item = update.clone();
tauri::async_runtime::spawn(async move {
let url = api_url(&server_url, "/desktop/latest");
if let Ok(resp) = reqwest::get(&url).await {
if let Ok(info) = resp.json::<DesktopLatest>().await {
// Beta-Kanal (main) vergibt jedem Commit dieselbe X.Y.Z-Version
// (D-07, desktop-collect.sh) -- ohne den Commit-Vergleich saehe
// ein Beta-Client zwischen zwei Freigabe-Tags nie einen neueren
// Bau (WR-02, Code-Review Phase 18). Fuer den Live-Kanal bleibt
// es beim reinen Versionsvergleich. Die Texte fuer Menue und
// Benachrichtigung liefert `update_labels`.
let version_changed = info.version != app_version;
let is_newer = version_changed
|| (info.channel == "beta" && info.commit != app_commit);
if is_newer {
let (menu_text, body) =
update_labels(version_changed, &info.version, &info.commit);
let _ = app_handle
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
let _ = update_item.set_text(menu_text);
let _ = update_item.set_enabled(true);
}
}
}
});
}
Ok(())
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
let _ = window.hide();
api.prevent_close();
}
})
.build(tauri::generate_context!())
.expect("error while building Tessera desktop application");
app.run(|_app, event| {
// Fenster schliessen setzt code auf None -- App laeuft dann im
// Infobereich weiter (siehe on_window_event). Der Tray-Eintrag
// "Beenden" ruft dagegen app.exit(0), das liefert code: Some --
// dieser Fall muss durchgelassen werden, sonst bleibt der Prozess
// samt Tray-Symbol haengen.
if let RunEvent::ExitRequested {
code: None, api, ..
} = event
{
api.prevent_exit();
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn with_desktop_marker_ohne_pfad_haengt_query_an() {
let url = tauri::Url::parse("https://tessera.example.com").unwrap();
let marked = with_desktop_marker(&url);
assert_eq!(marked.as_str(), "https://tessera.example.com/?desktop=1");
}
#[test]
fn with_desktop_marker_mit_vorhandenem_query_haengt_an() {
let url = tauri::Url::parse("https://host/app?x=1").unwrap();
let marked = with_desktop_marker(&url);
assert_eq!(marked.as_str(), "https://host/app?x=1&desktop=1");
}
#[test]
fn with_desktop_marker_laesst_original_unveraendert() {
let url = tauri::Url::parse("https://host/app").unwrap();
let _ = with_desktop_marker(&url);
assert_eq!(url.query(), None);
}
#[test]
fn update_labels_bei_versionswechsel() {
let (menu_text, body) = update_labels(true, "1.2.0", "abc1234");
assert_eq!(menu_text, "Version 1.2.0 herunterladen");
assert_eq!(
body,
"Neue Version 1.2.0 verfügbar – Download über das Symbol im Infobereich."
);
}
#[test]
fn update_labels_bei_gleicher_version_beta_commit() {
let (menu_text, body) = update_labels(false, "1.1.0", "abc1234");
assert_eq!(menu_text, "Neuen Beta-Stand herunterladen");
assert_eq!(
body,
"Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich."
);
}
}