feat(desktop): Verbundenen Server im Infobereich zeigen, Server-Adresse nachträglich änderbar

- Tray zeigt erste (gesperrte) Menüzeile "Verbunden mit {host}" und Tooltip "Tessera – {host}" (bzw. "nicht verbunden")
- Neuer Menüpunkt "Server-Adresse ändern…" navigiert zur gebündelten Setup-Seite (lokaler Ursprung, kein Capability-Eintrag nötig)
- setup.html erkennt per neuem Command get_server_url den Änderungsmodus (Feld vorbelegt, "Aktuell verbunden mit: …", Knopf "Abbrechen" → open_server)
- Nach save_server_url aktualisieren apply_server (Tooltip + Menüzeile) und spawn_version_check (aus setup herausgezogen, läuft neu gegen den neuen Server) ohne Neustart; Tray-Klick "update" liest die Adresse jetzt beim Klick aus dem Store
- Reine Helfer server_host, tray_labels, setup_page_url, parse_server_url mit 13 neuen Tests (RED zuerst: 13 Compile-Fehler auf die fehlenden Helfer, dann GREEN); 18 Rust-Tests gesamt, fmt/check/clippy sauber
- capabilities/default.json unverändert (T-JN2-01, kein remote-Block)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 14:51:01 +02:00
parent b023d6f726
commit 29c132ecf3
2 changed files with 381 additions and 63 deletions
+312 -62
View File
@@ -1,14 +1,20 @@
use std::time::Duration; use std::time::Duration;
use tauri::{ use tauri::{
menu::{CheckMenuItemBuilder, MenuBuilder, MenuItemBuilder}, menu::{CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, RunEvent, WindowEvent, AppHandle, Manager, RunEvent, WindowEvent, Wry,
}; };
use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_autostart::{MacosLauncher, ManagerExt};
use tauri_plugin_notification::NotificationExt; use tauri_plugin_notification::NotificationExt;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version
/// gefunden wurde. `spawn_version_check` setzt den Eintrag nach einem
/// Serverwechsel hierauf zurück, damit kein Hinweis des alten Servers stehen
/// bleibt.
const UPDATE_ITEM_DEFAULT: &str = "Update herunterladen";
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct VersionResponse { struct VersionResponse {
version: String, version: String,
@@ -71,12 +77,147 @@ fn update_labels(version_changed: bool, version: &str, commit: &str) -> (String,
} }
} }
#[tauri::command] /// Parst eine vom Nutzer eingegebene Server-Adresse und erlaubt nur http/https
async fn check_server(url: String) -> Result<String, String> { /// -- dieselben Fehlertexte wie bisher inline in `check_server`. `check_server`,
let parsed = tauri::Url::parse(&url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?; /// `save_server_url` und `open_server` rufen ausschliesslich diesen Helfer;
/// das Verhalten bleibt unveraendert, das Duplikat ist weg.
fn parse_server_url(url: &str) -> Result<tauri::Url, String> {
let parsed = tauri::Url::parse(url).map_err(|_| "Diese Adresse ist ungültig.".to_string())?;
if parsed.scheme() != "http" && parsed.scheme() != "https" { if parsed.scheme() != "http" && parsed.scheme() != "https" {
return Err("Es sind nur Adressen mit http oder https erlaubt.".to_string()); return Err("Es sind nur Adressen mit http oder https erlaubt.".to_string());
} }
Ok(parsed)
}
/// Liefert nur den Host der gespeicherten Server-Adresse (ohne Schema, ohne
/// Pfad) -- die Anzeige im Tray soll kurz bleiben. `host:port` nur, wenn
/// ausdruecklich ein Nicht-Standardport angegeben wurde (der `url`-Crate
/// laesst Standardports beim Parsen automatisch weg).
fn server_host(url: Option<&str>) -> Option<String> {
let url = url?;
if url.is_empty() {
return None;
}
let parsed = tauri::Url::parse(url).ok()?;
let host = parsed.host_str()?;
match parsed.port() {
Some(port) => Some(format!("{}:{}", host, port)),
None => Some(host.to_string()),
}
}
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
/// `update_labels`.
fn tray_labels(url: Option<&str>) -> (String, String) {
match server_host(url) {
Some(host) => (
format!("Tessera – {}", host),
format!("Verbunden mit {}", host),
),
None => (
"Tessera – nicht verbunden".to_string(),
"Nicht verbunden".to_string(),
),
}
}
/// Spiegelt Tauris nicht öffentliche `tauri_protocol_url`
/// (tauri-2.11.3 manager/mod.rs Z. 339-346): WebView2 kennt kein eigenes
/// Schema, darum unter Windows/Android `http://tauri.localhost`, sonst
/// `tauri://localhost`. `useHttpsScheme` ist in tauri.conf.json nicht
/// gesetzt, darum bewusst `http`. Der `tauri dev`-Fall mit `devUrl` wird in
/// diesem Projekt nicht genutzt (CI baut Release) und ist hier bewusst nicht
/// abgebildet. `WebviewUrl::App` laesst sich nicht an `navigate` geben,
/// darum die feste Adresse.
fn setup_page_url(windows: bool) -> tauri::Url {
let base = if windows {
"http://tauri.localhost/setup.html"
} else {
"tauri://localhost/setup.html"
};
tauri::Url::parse(base).expect("setup_page_url: feste Adresse muss gueltig sein")
}
/// Liest die gespeicherte Server-Adresse aus dem Store -- einzige Lesestelle
/// (Start, `get_server_url`, `open_server`, Tray-Klick "update"); nur
/// nicht-leere Werte gelten als gespeichert.
fn stored_server_url(app: &AppHandle) -> Option<String> {
let store = app.store("config.json").ok()?;
let value = store.get("server_url")?;
let s = value.as_str()?.to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
/// Menü-Handles für die Auffrischung ohne Neustart (`apply_server`,
/// `spawn_version_check`). `MenuItem` ist Send + Sync, `app.manage` verlangt
/// das.
struct TrayItems {
connected: MenuItem<Wry>,
update: MenuItem<Wry>,
}
/// Setzt Tooltip und die gesperrte "Verbunden mit ..."-Zeile aus derselben
/// Quelle -- die EINZIGE Stelle, die beide Anzeigen setzt (Start und
/// Wechsel), sonst laufen die drei Anzeigen auseinander. `set_tooltip` ist
/// unter Linux ein No-Op (Tauri-Doku); die Menüzeile bleibt dort die Anzeige.
/// Fehler werden wie im Bestand ignoriert.
fn apply_server(app: &AppHandle, url: Option<&str>) {
let (tooltip, line) = tray_labels(url);
if let Some(tray) = app.tray_by_id("main") {
let _ = tray.set_tooltip(Some(tooltip));
}
let _ = app.state::<TrayItems>().connected.set_text(line);
}
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt
/// den Update-Eintrag zuerst auf den Standardtext zurück -- nach einem
/// Wechsel darf kein Hinweis des alten Servers stehen bleiben.
fn spawn_version_check(app: AppHandle, server_url: String) {
let update_item = app.state::<TrayItems>().update.clone();
let _ = update_item.set_text(UPDATE_ITEM_DEFAULT);
let _ = update_item.set_enabled(false);
let app_version = env!("CARGO_PKG_VERSION").to_string();
let app_commit = env!("APP_COMMIT").to_string();
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
.notification()
.builder()
.title("Tessera-Update")
.body(body)
.show();
let _ = update_item.set_text(menu_text);
let _ = update_item.set_enabled(true);
}
}
}
});
}
#[tauri::command]
async fn check_server(url: String) -> Result<String, String> {
parse_server_url(&url)?;
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8)) .timeout(Duration::from_secs(8))
@@ -106,13 +247,16 @@ async fn check_server(url: String) -> Result<String, String> {
#[tauri::command] #[tauri::command]
fn save_server_url(app: AppHandle, url: String) -> Result<(), String> { 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 parsed = parse_server_url(&url)?;
let normalized = parsed.as_str().to_string(); let normalized = parsed.as_str().to_string();
let store = app.store("config.json").map_err(|e| e.to_string())?; let store = app.store("config.json").map_err(|e| e.to_string())?;
store.set("server_url", serde_json::json!(normalized)); store.set("server_url", serde_json::json!(normalized));
store.save().map_err(|e| e.to_string())?; store.save().map_err(|e| e.to_string())?;
apply_server(&app, Some(&normalized));
spawn_version_check(app.clone(), normalized.clone());
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.navigate(with_desktop_marker(&parsed)); let _ = window.navigate(with_desktop_marker(&parsed));
} }
@@ -120,6 +264,30 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
Ok(()) Ok(())
} }
/// Liefert NUR den gespeicherten Wert `server_url` aus dem Store, nichts
/// anderes. Nur vom lokalen Ursprung aufrufbar (T-JN2-01): capabilities/
/// default.json hat keinen `remote`-Block, darum verweigert Tauri App-
/// Commands aus der Server-Seite (tauri-2.11.3 webview/mod.rs Z. 1819-1823).
#[tauri::command]
fn get_server_url(app: AppHandle) -> Option<String> {
stored_server_url(&app)
}
/// Navigiert das Fenster `main` zur gespeicherten Server-Adresse -- Gegenstück
/// zum "Abbrechen"-Knopf der Setup-Seite im Änderungsmodus.
#[tauri::command]
fn open_server(app: AppHandle) -> Result<(), String> {
let stored = stored_server_url(&app)
.ok_or_else(|| "Es ist keine Server-Adresse gespeichert.".to_string())?;
let parsed = parse_server_url(&stored)?;
if let Some(window) = app.get_webview_window("main") {
window
.navigate(with_desktop_marker(&parsed))
.map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn run() { pub fn run() {
let app = tauri::Builder::default() let app = tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
@@ -130,31 +298,37 @@ pub fn run() {
None, None,
)) ))
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![check_server, save_server_url]) .invoke_handler(tauri::generate_handler![
check_server,
save_server_url,
get_server_url,
open_server
])
.setup(|app| { .setup(|app| {
let store = app.store("config.json")?; let server_url = stored_server_url(app.handle());
let server_url = store.get("server_url");
let mut url_for_check: Option<String> = None; if let Some(url) = &server_url {
if let Some(window) = app.get_webview_window("main") {
if let Some(url_value) = server_url { if let Ok(parsed) = parse_server_url(url) {
if let Some(url_str) = url_value.as_str() { let _ = window.navigate(with_desktop_marker(&parsed));
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. // Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
// "update" bleibt gesperrt, bis die Versionspruefung eine neuere Version // Server-Adresse ändern... · Update herunterladen · — ·
// findet; "autostart" spiegelt den tatsaechlichen Systemzustand beim Start. // Autostart-Haken · — · Beenden.
// "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis
// die Versionspruefung eine neuere Version findet; "autostart"
// spiegelt den tatsaechlichen Systemzustand beim Start.
let connected =
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
.enabled(false)
.build(app)?;
let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?; let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?;
let update = MenuItemBuilder::with_id("update", "Update herunterladen") let change_server =
MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?;
let update = MenuItemBuilder::with_id("update", UPDATE_ITEM_DEFAULT)
.enabled(false) .enabled(false)
.build(app)?; .build(app)?;
let autostart_label = if cfg!(target_os = "windows") { let autostart_label = if cfg!(target_os = "windows") {
@@ -168,7 +342,10 @@ pub fn run() {
let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Beenden").build(app)?;
let menu = MenuBuilder::new(app) let menu = MenuBuilder::new(app)
.item(&connected)
.separator()
.item(&open) .item(&open)
.item(&change_server)
.item(&update) .item(&update)
.separator() .separator()
.item(&autostart) .item(&autostart)
@@ -176,13 +353,17 @@ pub fn run() {
.item(&quit) .item(&quit)
.build()?; .build()?;
let server_for_menu = url_for_check.clone(); app.manage(TrayItems {
connected: connected.clone(),
update: update.clone(),
});
let autostart_for_menu = autostart.clone(); let autostart_for_menu = autostart.clone();
let _tray = TrayIconBuilder::new() let _tray = TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone()) .icon(app.default_window_icon().unwrap().clone())
.menu(&menu) .menu(&menu)
.tooltip("Tessera") .tooltip(tray_labels(server_url.as_deref()).0)
.show_menu_on_left_click(false) .show_menu_on_left_click(false)
.on_menu_event(move |app, event| match event.id().as_ref() { .on_menu_event(move |app, event| match event.id().as_ref() {
"open" => { "open" => {
@@ -193,8 +374,16 @@ pub fn run() {
let _ = w.set_focus(); let _ = w.set_focus();
} }
} }
"change_server" => {
if let Some(w) = app.get_webview_window("main") {
let _ = w.navigate(setup_page_url(cfg!(windows)));
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
"update" => { "update" => {
if let Some(server) = &server_for_menu { if let Some(server) = stored_server_url(app) {
let target = format!( let target = format!(
"{}/settings/general/desktop", "{}/settings/general/desktop",
server.trim_end_matches('/') server.trim_end_matches('/')
@@ -240,40 +429,10 @@ pub fn run() {
}) })
.build(app)?; .build(app)?;
// Version check (async, non-blocking) apply_server(app.handle(), server_url.as_deref());
if let Some(server_url) = url_for_check {
let app_handle = app.handle().clone(); if let Some(url) = server_url {
let app_version = env!("CARGO_PKG_VERSION").to_string(); spawn_version_check(app.handle().clone(), url);
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(()) Ok(())
@@ -346,4 +505,95 @@ mod tests {
"Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich." "Neuer Beta-Stand abc1234 verfügbar – Download über das Symbol im Infobereich."
); );
} }
#[test]
fn server_host_mit_pfad_und_ohne_port() {
assert_eq!(
server_host(Some("https://tessera.ctl.de/")),
Some("tessera.ctl.de".to_string())
);
}
#[test]
fn server_host_mit_nicht_standard_port() {
assert_eq!(
server_host(Some("http://localhost:3000/")),
Some("localhost:3000".to_string())
);
}
#[test]
fn server_host_laesst_standardport_pfad_und_query_weg() {
assert_eq!(
server_host(Some("https://host:443/pfad?x=1")),
Some("host".to_string())
);
}
#[test]
fn server_host_none_bei_none() {
assert_eq!(server_host(None), None);
}
#[test]
fn server_host_none_bei_leerem_string() {
assert_eq!(server_host(Some("")), None);
}
#[test]
fn server_host_none_bei_unparsbarer_adresse() {
assert_eq!(server_host(Some("kein url")), None);
}
#[test]
fn tray_labels_mit_host() {
let (tooltip, line) = tray_labels(Some("https://tessera.ctl.de/"));
assert_eq!(tooltip, "Tessera – tessera.ctl.de");
assert_eq!(line, "Verbunden mit tessera.ctl.de");
}
#[test]
fn tray_labels_ohne_adresse() {
let (tooltip, line) = tray_labels(None);
assert_eq!(tooltip, "Tessera – nicht verbunden");
assert_eq!(line, "Nicht verbunden");
}
#[test]
fn setup_page_url_unter_windows() {
assert_eq!(
setup_page_url(true).as_str(),
"http://tauri.localhost/setup.html"
);
}
#[test]
fn setup_page_url_unter_linux() {
assert_eq!(
setup_page_url(false).as_str(),
"tauri://localhost/setup.html"
);
}
#[test]
fn parse_server_url_gueltige_adresse() {
let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
assert_eq!(parsed.as_str(), "https://tessera.ctl.de/");
}
#[test]
fn parse_server_url_lehnt_fremdes_schema_ab() {
assert_eq!(
parse_server_url("ftp://host"),
Err("Es sind nur Adressen mit http oder https erlaubt.".to_string())
);
}
#[test]
fn parse_server_url_lehnt_unparsbare_adresse_ab() {
assert_eq!(
parse_server_url("kein url"),
Err("Diese Adresse ist ungültig.".to_string())
);
}
} }
+69 -1
View File
@@ -140,6 +140,25 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.current-server {
display: none;
text-align: left;
font-size: 0.8125rem;
color: oklch(0.75 0 0);
margin: -20px 0 24px;
}
button.secondary {
margin-top: 12px;
background: transparent;
color: oklch(0.85 0 0);
border: 1px solid oklch(0.30 0.01 260);
}
button[hidden] {
display: none;
}
</style> </style>
</head> </head>
<body> <body>
@@ -155,7 +174,8 @@
</g> </g>
</svg> </svg>
<h1>Tessera</h1> <h1>Tessera</h1>
<p class="subtitle">Desktop-App einrichten</p> <p class="subtitle" id="subtitle">Desktop-App einrichten</p>
<p id="current-server" class="current-server"></p>
<label for="server-url">Adresse Ihres Tessera-Servers</label> <label for="server-url">Adresse Ihres Tessera-Servers</label>
<p class="hint">Das ist die Adresse, unter der Sie Tessera auch im Browser öffnen.</p> <p class="hint">Das ist die Adresse, unter der Sie Tessera auch im Browser öffnen.</p>
@@ -171,6 +191,7 @@
<p id="info-msg" class="info-message"></p> <p id="info-msg" class="info-message"></p>
<button id="connect-btn" type="button">Verbinden</button> <button id="connect-btn" type="button">Verbinden</button>
<button id="cancel-btn" type="button" class="secondary" hidden>Abbrechen</button>
</div> </div>
<script type="module"> <script type="module">
@@ -178,9 +199,12 @@
const urlInput = document.getElementById('server-url'); const urlInput = document.getElementById('server-url');
const connectBtn = document.getElementById('connect-btn'); const connectBtn = document.getElementById('connect-btn');
const cancelBtn = document.getElementById('cancel-btn');
const errorMsg = document.getElementById('error-msg'); const errorMsg = document.getElementById('error-msg');
const warningMsg = document.getElementById('warning-msg'); const warningMsg = document.getElementById('warning-msg');
const infoMsg = document.getElementById('info-msg'); const infoMsg = document.getElementById('info-msg');
const subtitle = document.getElementById('subtitle');
const currentServer = document.getElementById('current-server');
/** /**
* Validate a server URL using the URL constructor. * Validate a server URL using the URL constructor.
@@ -303,6 +327,50 @@
// Clear error messages on input change // Clear error messages on input change
urlInput.addEventListener('input', clearMessages); urlInput.addEventListener('input', clearMessages);
/**
* Aenderungsmodus: die Adresse steht bereits im Store (get_server_url
* liefert einen Wert). Feld vorbelegen, aktuelle Adresse anzeigen und
* den "Abbrechen"-Knopf einblenden. Der eingesetzte Wert kommt per
* textContent/value in die Seite, nie als HTML (T-JN2-05).
*/
function enterChangeMode(current) {
urlInput.value = current;
currentServer.textContent = 'Aktuell verbunden mit: ' + current;
currentServer.style.display = 'block';
subtitle.textContent = 'Server-Adresse ändern';
cancelBtn.hidden = false;
urlInput.focus();
urlInput.select();
}
cancelBtn.addEventListener('click', async () => {
clearMessages();
cancelBtn.disabled = true;
try {
await invoke('open_server');
} catch (err) {
showError(String(err));
cancelBtn.disabled = false;
}
});
// Beim Laden pruefen, ob bereits eine Server-Adresse gespeichert ist --
// dann ist dies der Aenderungsmodus, nicht der Erststart. Ohne
// gespeicherte Adresse (oder wenn der Aufruf fehlschlaegt) bleibt es
// beim bisherigen Erststart-Verhalten.
async function init() {
try {
const current = await invoke('get_server_url');
if (typeof current === 'string' && current) {
enterChangeMode(current);
}
} catch {
// Erststart-Verhalten: Feld bleibt leer, kein Aenderungsmodus.
}
}
init();
</script> </script>
</body> </body>
</html> </html>