Verbindungen speichern – der Fährmann merkt sich die Häfen
Die letzte Etappe! Bisher musst du bei jeder Verbindung Server, Port und Benutzernamen neu eintippen. Heute merkt sich der Fährmann deine Verbindungen in einer kleinen Textdatei: Mit F4 speicherst du eine, mit Bild↑ und Bild↓ blätterst du durch die gespeicherten, mit F8 löschst du eine wieder. Dabei lernst du serde kennen, das Werkzeug, mit dem fast jedes Rust-Programm seine Daten in Dateien schreibt und wieder liest.
Bevor wir loslegen
Wir machen da weiter, wo Etappe 12 aufgehört hat. Den kompletten Stand findest du dort unter Zwischenstand.
Und das Passwort?
Der Fährmann speichert Name, Protokoll, Server, Port und Benutzernamen, aber kein Passwort. Das ist Absicht. Eine Textdatei mit Passwörtern kann jedes andere Programm auf deinem Rechner lesen, und landet sie in einem Backup oder in der Cloud, liegt dein Passwort dort auch. Profi-Programme legen Passwörter deshalb in den Schlüsselbund des Betriebssystems, der sie verschlüsselt aufbewahrt. Wie das geht, ist die letzte Übung dieser Etappe. Bis dahin tippst du das Passwort beim Verbinden einfach ein. Und wenn du dich per SFTP mit SSH-Schlüsseln anmeldest, wie es viele Profis tun, brauchst du eines gar nicht.
Eine Datei für die Häfen
Drei neue Crates kommen an Bord:
cargo add serde --features derive
cargo add toml@0.9
cargo add dirs@7
- serde (von serialize und deserialize) verwandelt
Rust-Werte in ein Dateiformat und zurück. Mit dem Feature
derivekönnen wir das für eigenestructs mit einer einzigen Zeile einschalten. - toml ist das Dateiformat. Du kennst es schon: Deine
Cargo.tomlist so eine Datei. Sie ist leicht von Menschen lesbar, und man kann sie zur Not auch von Hand bearbeiten. - dirs weiß, wo auf welchem Betriebssystem Einstellungen hingehören: unter
Linux
~/.config, unter macOS~/Library/Application Support, unter Windows im OrdnerAppData.
In der Cargo.toml stehen danach drei neue Zeilen:
serde = { version = "1", features = ["derive"] }
toml = "0.9"
dirs = "7"
Oben in der Datei holen wir serde herein:
use suppaftp::list::{File, PosixPexQuery};use serde::{Deserialize, Serialize}; use suppaftp::list::{File, PosixPexQuery};
Und dann alles, was mit der Datei zu tun hat, ans Ende:
format!("{} ({})", erklaerung, fehler) }// Eine gespeicherte Verbindung. Das Passwort speichern wir absichtlich nicht. #[derive(Debug, Clone, Serialize, Deserialize)] struct Gespeichert { name: String, sftp: bool, host: String, port: String, benutzer: String, } // So sieht die ganze Datei aus: eine Liste von [[verbindung]]-Abschnitten. #[derive(Serialize, Deserialize, Default)] struct Verbindungsdatei { #[serde(default)] verbindung: Vec<Gespeichert>, } // Wo die Datei liegt, unter Linux zum Beispiel ~/.config/faehrmann/verbindungen.toml fn verbindungsdatei() -> Option<PathBuf> { Some(dirs::config_dir()?.join("faehrmann").join("verbindungen.toml")) } // Liest die gespeicherten Verbindungen. Gibt es noch keine Datei, ist die Liste leer. fn verbindungen_laden() -> Vec<Gespeichert> { let Some(pfad) = verbindungsdatei() else { return Vec::new(); }; let Ok(inhalt) = fs::read_to_string(&pfad) else { return Vec::new(); }; match toml::from_str::<Verbindungsdatei>(&inhalt) { Ok(datei) => datei.verbindung, Err(_) => Vec::new(), // kaputte Datei: lieber leer starten als abstürzen } } // Schreibt alle Verbindungen in die Datei. fn verbindungen_speichern(liste: &[Gespeichert]) -> Result<(), String> { let pfad = verbindungsdatei().ok_or("Kein Ordner für Einstellungen gefunden.")?; if let Some(ordner) = pfad.parent() { fs::create_dir_all(ordner).map_err(als_text)?; } let datei = Verbindungsdatei { verbindung: liste.to_vec(), }; let inhalt = toml::to_string_pretty(&datei).map_err(als_text)?; fs::write(&pfad, inhalt).map_err(als_text) }
#[derive(Serialize, Deserialize)]ist die ganze Magie: serde schreibt sich damit selbst den Code, der einGespeichertin Text verwandelt und zurück. Jedes Feld wird zu einer Zeilename = wert.Verbindungsdateibeschreibt die Datei als Ganzes: eine Liste namensverbindung. In TOML wird jede davon ein Abschnitt, der mit[[verbindung]]beginnt.#[serde(default)]sagt: Fehlt die Liste ganz, nimm eine leere.verbindungsdateibaut den Pfad zur Datei.config_dir()liefert eineOption, denn auf exotischen Systemen gibt es so einen Ordner vielleicht nicht. Das?reicht dasNoneeinfach weiter.verbindungen_ladenverzeiht alles: keine Datei, nicht lesbar oder kaputt? Dann eben eine leere Liste. Beim Laden von Einstellungen ist das genau richtig, denn wegen einer kaputten Einstellungsdatei soll kein Programm den Start verweigern.verbindungen_speichernist dagegen streng und meldet jeden Fehler.create_dir_alllegt den Ordnerfaehrmannan, falls es ihn noch nicht gibt,to_string_prettybaut den Text, undfs::writeschreibt ihn in einem Rutsch.&[Gespeichert]ist ein Slice: Die Funktion nimmt eine ausgeliehene Liste, egal wo sie herkommt.
So sieht eine fertige Datei später aus:
[[verbindung]]
name = "Testserver"
sftp = false
host = "localhost"
port = "2121"
benutzer = "fahrgast"
[[verbindung]]
name = "Mein Rechner"
sftp = true
host = "localhost"
port = "22"
benutzer = "volker"
cargo run
Die neuen Crates werden übersetzt, dann läuft alles wie bisher. Rust warnt, dass
die neuen Funktionen nie benutzt werden.Ein Name für jede Verbindung
Damit man Verbindungen auseinanderhalten kann, bekommt jede einen Namen, zum Beispiel „Testserver“ oder „Meine Webseite“. Der Verbindungsdialog bekommt dafür ein fünftes Feld, und er merkt sich, welche gespeicherte Verbindung er gerade zeigt:
sftp: bool, // true: SFTP, false: FTPname: String, // wie die Verbindung heißen soll nr: Option<usize>, // welche gespeicherte Verbindung gerade angezeigt wirdhost: String,⋮enum Feld {Name,Host,
Beim Start lädt der Fährmann die gespeicherten Verbindungen gleich mit:
fehler: bool, // ist die Meldung ein Fehler? Dann rotgespeichert: Vec<Gespeichert>, // die gespeicherten Verbindungen}⋮fehler: false,gespeichert: verbindungen_laden(),}
Das neue Feld braucht seinen Eingabe-Zweig, einen Anfangswert und eine Zeile im Dialog:
Message::Eingabe(Feld::Host, wert) => zugang.host = wert,Message::Eingabe(Feld::Name, wert) => zugang.name = wert, Message::Eingabe(Feld::Host, wert) => zugang.host = wert,
self.verbindungsdialog = Some(Zugang {name: String::from("Testserver"), nr: None,sftp: false,
"Protokoll: FTP (unverschlüsselt) · F3 wechselt" }),eingabezeile("Name", &zugang.name, Feld::Name, "name", false),eingabezeile("Server", &zugang.host, Feld::Host, "host", false),
cargo run
Drück F2: Ganz oben steht jetzt ein Feld Name mit „Testserver“.
Mit Tab kommst du auch dorthin. Zum Fokus beim Öffnen springt der Dialog
weiterhin ins Feld Server.Speichern, blättern, löschen
Vier neue Briefsorten für die vier neuen Tasten im Verbindungsdialog:
Tick,Speichern, Vorige, Naechste, Loeschen,
Die Tasten dazu. Bild↑ und Bild↓ heißen auf englischen Tastaturen Page Up und Page Down, daher die Namen:
Key::Named(Named::F3) => Some(Message::ProtokollWechseln),Key::Named(Named::F4) => Some(Message::Speichern), Key::Named(Named::F8) => Some(Message::Loeschen), Key::Named(Named::PageUp) => Some(Message::Vorige), Key::Named(Named::PageDown) => Some(Message::Naechste),
Im Verbindungsdialog leiten wir die Briefe an drei neue Funktionen weiter. Außerhalb des Dialogs haben sie keine Bedeutung:
Message::Verbinden => return self.verbinden(),Message::Speichern => self.verbindung_merken(), Message::Vorige => return self.blaettern(-1), Message::Naechste => return self.blaettern(1), Message::Loeschen => self.verbindung_vergessen(),⋮| Message::ProtokollWechseln => {} // ohne Dialog ohne Bedeutung| Message::ProtokollWechseln | Message::Speichern | Message::Vorige | Message::Naechste | Message::Loeschen => {} // ohne Dialog ohne Bedeutung
Und die drei Funktionen, direkt hinter verbindung_zeigen:
passwort: String::from("geheim"), }); operation::focus("host") }// F4: die Verbindung im Dialog speichern, ohne Passwort. fn verbindung_merken(&mut self) { let Some(zugang) = &self.verbindungsdialog else { return; }; let name = if zugang.name.trim().is_empty() { format!("{}@{}", zugang.benutzer, zugang.host) } else { zugang.name.trim().to_string() }; let neu = Gespeichert { name: name.clone(), sftp: zugang.sftp, host: zugang.host.trim().to_string(), port: zugang.port.trim().to_string(), benutzer: zugang.benutzer.clone(), }; // Gibt es schon eine mit diesem Namen, wird sie ersetzt match self.gespeichert.iter().position(|alt| alt.name == name) { Some(nr) => self.gespeichert[nr] = neu, None => self.gespeichert.push(neu), } self.meldung = match verbindungen_speichern(&self.gespeichert) { Ok(()) => format!("Verbindung „{}“ gespeichert (ohne Passwort).", name), Err(fehler) => format!("Speichern fehlgeschlagen: {}", fehler), }; } // Bild↑ und Bild↓: durch die gespeicherten Verbindungen blättern. fn blaettern(&mut self, schritt: isize) -> Task<Message> { let anzahl = self.gespeichert.len(); if anzahl == 0 { self.meldung = String::from("Noch keine Verbindung gespeichert. F4 speichert die aktuelle."); return Task::none(); } let Some(zugang) = &mut self.verbindungsdialog else { return Task::none(); }; let nr = match zugang.nr { Some(nr) => (nr as isize + schritt).rem_euclid(anzahl as isize) as usize, None => 0, }; let gewaehlt = &self.gespeichert[nr]; zugang.nr = Some(nr); zugang.name = gewaehlt.name.clone(); zugang.sftp = gewaehlt.sftp; zugang.host = gewaehlt.host.clone(); zugang.port = gewaehlt.port.clone(); zugang.benutzer = gewaehlt.benutzer.clone(); zugang.passwort = String::new(); operation::focus("passwort") // gleich das Passwort eintippen } // F8: die gerade gezeigte gespeicherte Verbindung löschen. fn verbindung_vergessen(&mut self) { let Some(nr) = self.verbindungsdialog.as_ref().and_then(|zugang| zugang.nr) else { self.meldung = String::from("Wähl erst mit Bild↑/Bild↓ eine gespeicherte Verbindung."); return; }; let weg = self.gespeichert.remove(nr); if let Some(zugang) = &mut self.verbindungsdialog { zugang.nr = None; } self.meldung = match verbindungen_speichern(&self.gespeichert) { Ok(()) => format!("Verbindung „{}“ gelöscht.", weg.name), Err(fehler) => format!("Löschen fehlgeschlagen: {}", fehler), }; }
verbindung_merkenbaut aus dem Dialog einGespeichert. Ohne Namen erfindet es einen aus Benutzer und Server.positionsucht, ob es schon eine Verbindung mit diesem Namen gibt, und liefert gegebenenfalls ihre Nummer. Dann wird ersetzt, sonst angehängt. Zum Schluss wird die ganze Liste gespeichert.blaetternrechnet die nächste Nummer aus.rem_euclidist wie%, kommt aber auch mit negativen Zahlen richtig zurecht: Von der ersten Verbindung einen Schritt zurück landet man bei der letzten. Weil man dafür kurz mit Vorzeichen rechnen muss, wandeln wir mitas isizeum. Dann füllt die Funktion alle Felder, leert das Passwort und springt mit dem Fokus dorthin, sodass du es gleich eintippen und mit Enter losfahren kannst.verbindung_vergessenlöscht die gerade gezeigte Verbindung mitremove, das den gelöschten Eintrag gleich zurückgibt. So können wir seinen Namen noch in der Meldung nennen.as_ref().and_then(…)schaut in dieOptiondes Dialogs und dann in dieOptionder Nummer, beides in einer Zeile.
blaettern leihen wir uns gleichzeitig self.verbindungsdialog
zum Verändern und self.gespeichert zum Lesen aus. Das erlaubt Rust, weil es zwei
verschiedene Felder sind. Würden wir stattdessen eine Funktion wie
self.aktive_verbindung() aufrufen, die &self nimmt, ginge es nicht
mehr, denn dann wäre ganz self ausgeliehen.cargo run
Drück F2 und dann F4: Unten steht „Verbindung „Testserver“
gespeichert“. Ändere den Namen in „Mein Rechner“, schalte mit F3 auf SFTP und drück
wieder F4. Jetzt blättere mit Bild↓: Der Dialog zeigt abwechselnd beide
Verbindungen, und der Cursor wartet im Passwortfeld. Schau dir auch die Datei an, unter Linux
mit cat ~/.config/faehrmann/verbindungen.toml. Und beende den Fährmann, starte ihn
neu: Die Verbindungen sind noch da.Die Liste im Dialog
Zum Schluss soll der Dialog zeigen, welche Verbindungen es gibt und welche gerade gewählt ist. Er bekommt die Liste dafür mitgeliefert und zeigt sie in einer Zeile unter der Überschrift. Die gewählte steht in eckigen Klammern:
// Zeichnet den Verbindungsdialog mit vier Eingabefeldern.fn verbindung_ansicht(zugang: &Zugang) -> Element<'_, Message> {fn verbindung_ansicht<'a>(zugang: &'a Zugang, gespeichert: &[Gespeichert]) -> Element<'a, Message> { let namen: Vec<String> = gespeichert .iter() .enumerate() .map(|(nr, verbindung)| { if zugang.nr == Some(nr) { format!("[{}]", verbindung.name) } else { verbindung.name.clone() } }) .collect(); let liste = if namen.is_empty() { String::from("Gespeichert: noch nichts") } else { format!("Gespeichert: {}", namen.join(" · ")) };let inhalt = column![⋮text("Mit einem Server verbinden"),text(liste).size(13),⋮text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13),text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13), text("Bild↑/Bild↓: gespeicherte Verbindung · F4: speichern · F8: löschen").size(13),
Neu ist das <'a> in der ersten Zeile. Die Funktion bekommt jetzt zwei
Ausleihen, und Rust will wissen, von welcher das Ergebnis abhängt. Die Antwort: nur vom
zugang, denn aus ihm stammen die Texte in den Eingabefeldern. Die Liste dagegen
wird zu einem neuen String zusammengebaut, das Ergebnis braucht sie danach nicht
mehr. .collect() sammelt die Namen in einem Vec<String>, und
join klebt sie mit Mittelpunkten zusammen. In view geben wir die Liste
mit:
stack![hauptteil, container(verbindung_ansicht(zugang)).center(Fill)].into()let dialog = verbindung_ansicht(zugang, &self.gespeichert); stack![hauptteil, container(dialog).center(Fill)].into()
cargo run
Drück F2: Oben im Dialog steht „Gespeichert: Testserver · Mein Rechner“.
Blättere mit Bild↓, und die gewählte Verbindung bekommt eckige Klammern. Drück
F8, und sie verschwindet aus der Liste und aus der Datei. Und dann: Passwort
eintippen, Enter, und los geht die Fahrt.
Der komplette Code nach Etappe 13
Das ist der komplette, fertige Fährmann: ein Dateimanager mit zwei Panels, der Dateien und ganze Ordner samt Rechten kopiert, per FTP und SFTP mit Servern spricht, den Fortschritt zeigt, Fehler erklärt und sich deine Verbindungen merkt. Rund 1700 Zeilen Rust, mit Kommentaren und Leerzeilen.
Cargo.toml und src/main.rs anzeigen
Cargo.toml
[package]
name = "faehrmann"
version = "0.1.0"
edition = "2024"
[dependencies]
iced = { version = "0.14", features = ["tokio"] }
suppaftp = { version = "12", features = ["tokio"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
russh = { version = "0.63", default-features = false, features = ["ring", "flate2", "rsa"] }
russh-sftp = "3"
serde = { version = "1", features = ["derive"] }
toml = "0.9"
dirs = "7"
src/main.rs
use std::fmt;
use std::fs;
use std::future::Future;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use russh::keys::{HashAlg, PublicKeyOrCertificate};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::FileAttributes;
use serde::{Deserialize, Serialize};
use suppaftp::list::{File, PosixPexQuery};
use suppaftp::tokio::AsyncFtpStream;
use suppaftp::types::FileType;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::Mutex;
use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
use iced::time::{self, Duration, Instant};
use iced::{
Background, Border, Center, Color, Element, Fill, Font, Right, Subscription, Task, Theme,
};
// Die Farben des Norton Commander
const BLAU: Color = Color::from_rgb8(0, 0, 170);
const TUERKIS: Color = Color::from_rgb8(0, 170, 170);
const SCHWARZ: Color = Color::BLACK;
const WEISS: Color = Color::WHITE;
const HELLTUERKIS: Color = Color::from_rgb8(85, 255, 255);
const GELB: Color = Color::from_rgb8(255, 255, 85);
const GRAU: Color = Color::from_rgb8(170, 170, 170);
const DUNKELROT: Color = Color::from_rgb8(170, 0, 0);
// So viele Einträge passen untereinander in ein Panel
const ZEILEN: usize = 24;
// Ein Eintrag in einem Ordner: eine Datei oder ein Unterordner.
#[derive(Debug, Clone)]
struct Eintrag {
name: String,
ist_ordner: bool,
groesse: u64,
rechte: u32, // Zugriffsrechte, zum Beispiel 0o644
}
fn main() -> iced::Result {
iced::application(Faehrmann::neu, Faehrmann::update, Faehrmann::view)
.subscription(Faehrmann::subscription)
.title("Fährmann")
.default_font(Font::MONOSPACE)
.window_size((1000.0, 640.0))
.run()
}
// Eine Übertragung, die gerade läuft.
struct Laufend {
titel: String, // zum Beispiel "Lade „Fotos“ hoch"
zaehler: Arc<AtomicU64>, // bisher übertragene Bytes
gesamt: Option<u64>, // so viele werden es insgesamt, falls bekannt
start: Instant, // wann es losging, für das Tempo
}
// Das Gedächtnis des Fährmanns: welcher Ordner offen ist und was darin liegt.
struct Faehrmann {
links: Panel,
rechts: Panel,
aktiv: Seite,
meldung: String, // was der Fährmann zuletzt zu sagen hatte
dialog: Option<RechteDialog>, // offener Dialog, falls es einen gibt
verbindungsdialog: Option<Zugang>, // offener Verbindungsdialog, falls es einen gibt
laufend: Option<Laufend>, // die aktuelle Übertragung, falls eine läuft
fehler: bool, // ist die Meldung ein Fehler? Dann rot
gespeichert: Vec<Gespeichert>, // die gespeicherten Verbindungen
}
// Welches der beiden Panels ist gerade aktiv?
#[derive(PartialEq)]
enum Seite {
Links,
Rechts,
}
// Der Dialog zum Ändern der Rechte: für welche Datei, welche Rechte, welcher Schalter gewählt ist.
struct RechteDialog {
name: String,
pfad: PathBuf,
rechte: u32,
auswahl: usize, // 0 bis 8, welcher der neun Schalter gerade markiert ist
server: Option<Server>, // Some, wenn die Datei auf dem Server liegt
}
// Die Zugangsdaten für einen Server, so wie sie im Verbindungsdialog stehen.
#[derive(Debug, Clone)]
struct Zugang {
sftp: bool, // true: SFTP, false: FTP
name: String, // wie die Verbindung heißen soll
nr: Option<usize>, // welche gespeicherte Verbindung gerade angezeigt wird
host: String,
port: String,
benutzer: String,
passwort: String,
}
// Welches der vier Eingabefelder ist gemeint?
#[derive(Debug, Clone, Copy)]
enum Feld {
Name,
Host,
Port,
Benutzer,
Passwort,
}
// Eine offene Verbindung zu einem Server. Arc und Mutex erlauben, dass mehrere Aufgaben im
// Hintergrund sie benutzen, ohne sich gegenseitig in die Quere zu kommen.
#[derive(Clone)]
struct Server {
strom: Arc<Mutex<Strom>>,
name: String, // zum Beispiel "fahrgast@localhost"
fingerabdruck: Option<String>, // nur bei SFTP
}
// Nachrichten müssen sich mit {:?} ausgeben lassen. Die Verbindung selbst kann das
// nicht, also schreiben wir es für Server von Hand.
impl fmt::Debug for Server {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Server({})", self.name)
}
}
#[derive(Debug, Clone)]
enum Message {
Hoch,
Runter,
Oeffnen,
Wechseln,
Kopieren,
RechteZeigen,
PfeilLinks,
PfeilRechts,
Umschalten,
Abbrechen,
VerbindenZeigen,
Eingabe(Feld, String),
Verbinden,
ProtokollWechseln,
Tick,
Speichern,
Vorige,
Naechste,
Loeschen,
Verbunden(Result<(Server, PathBuf, Vec<Eintrag>), String>),
Gelesen(Result<(PathBuf, Vec<Eintrag>), String>),
Fertig(Result<String, String>),
Beenden,
}
impl Faehrmann {
// Startet im Ordner, aus dem das Programm aufgerufen wurde.
fn neu() -> Self {
let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
Faehrmann {
links: Panel::neu(ordner.clone()),
rechts: Panel::neu(ordner),
aktiv: Seite::Links,
meldung: String::from("Willkommen an Bord! Tab wechselt das Panel, F5 kopiert, F10 beendet."),
dialog: None,
verbindungsdialog: None,
laufend: None,
fehler: false,
gespeichert: verbindungen_laden(),
}
}
fn update(&mut self, message: Message) -> Task<Message> {
// Antworten vom Server kommen immer an, egal ob gerade ein Dialog offen ist
let message = match message {
Message::Verbunden(ergebnis) => return self.verbunden(ergebnis),
Message::Gelesen(ergebnis) => return self.gelesen(ergebnis),
Message::Fertig(ergebnis) => return self.fertig(ergebnis),
andere => andere,
};
// Jede Taste nimmt die rote Fehlermeldung wieder zurück
if !matches!(message, Message::Tick) {
self.fehler = false;
}
// Ist der Verbindungsdialog offen, kümmert er sich um die Tasten
if let Some(zugang) = &mut self.verbindungsdialog {
match message {
Message::Eingabe(Feld::Name, wert) => zugang.name = wert,
Message::Eingabe(Feld::Host, wert) => zugang.host = wert,
Message::Eingabe(Feld::Port, wert) => zugang.port = wert,
Message::Eingabe(Feld::Benutzer, wert) => zugang.benutzer = wert,
Message::Eingabe(Feld::Passwort, wert) => zugang.passwort = wert,
Message::ProtokollWechseln => {
zugang.sftp = !zugang.sftp;
if zugang.sftp {
zugang.port = String::from("22");
zugang.benutzer = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default();
zugang.passwort = String::new();
} else {
zugang.port = String::from("2121");
}
}
Message::Wechseln => return operation::focus_next(),
Message::Verbinden => return self.verbinden(),
Message::Speichern => self.verbindung_merken(),
Message::Vorige => return self.blaettern(-1),
Message::Naechste => return self.blaettern(1),
Message::Loeschen => self.verbindung_vergessen(),
Message::Abbrechen => self.verbindungsdialog = None,
Message::Beenden => return iced::exit(),
_ => {}
}
return Task::none();
}
// Ist der Rechte-Dialog offen, gehen alle Tasten an ihn
if let Some(dialog) = &mut self.dialog {
match message {
Message::Hoch => dialog.auswahl = (dialog.auswahl + 6) % 9,
Message::Runter => dialog.auswahl = (dialog.auswahl + 3) % 9,
Message::PfeilLinks => dialog.auswahl = (dialog.auswahl + 8) % 9,
Message::PfeilRechts => dialog.auswahl = (dialog.auswahl + 1) % 9,
Message::Umschalten => dialog.rechte ^= bit(dialog.auswahl),
Message::Oeffnen => return self.rechte_anwenden(),
Message::Abbrechen => self.dialog = None,
Message::Beenden => return iced::exit(),
_ => {}
}
return Task::none();
}
match message {
Message::Hoch => self.aktives_panel().hoch(),
Message::Runter => self.aktives_panel().runter(),
Message::Oeffnen => return self.oeffnen(),
Message::Wechseln => {
self.aktiv = match self.aktiv {
Seite::Links => Seite::Rechts,
Seite::Rechts => Seite::Links,
};
}
Message::Kopieren => return self.kopieren(),
Message::RechteZeigen => self.rechte_zeigen(),
Message::VerbindenZeigen => return self.verbindung_zeigen(),
Message::PfeilLinks
| Message::PfeilRechts
| Message::Umschalten
| Message::Abbrechen
| Message::Eingabe(..)
| Message::Verbinden
| Message::ProtokollWechseln
| Message::Speichern
| Message::Vorige
| Message::Naechste
| Message::Loeschen => {} // ohne Dialog ohne Bedeutung
Message::Verbunden(_) | Message::Gelesen(_) | Message::Fertig(_) => {} // oben erledigt
Message::Tick => {} // nur neu zeichnen
Message::Beenden => return iced::exit(),
}
Task::none()
}
// Abonniert die Tastatur: Jeder Tastendruck geht durch die Funktion taste.
fn subscription(&self) -> Subscription<Message> {
let tasten = keyboard::listen().filter_map(taste);
if self.laufend.is_some() {
// Während einer Übertragung fünfmal pro Sekunde neu zeichnen
let uhr = time::every(Duration::from_millis(200)).map(|_| Message::Tick);
Subscription::batch([tasten, uhr])
} else {
tasten
}
}
// Liefert das Panel, das gerade aktiv ist, zum Verändern.
fn aktives_panel(&mut self) -> &mut Panel {
match self.aktiv {
Seite::Links => &mut self.links,
Seite::Rechts => &mut self.rechts,
}
}
// Liefert das Panel, das gerade NICHT aktiv ist, zum Verändern.
fn inaktives_panel(&mut self) -> &mut Panel {
match self.aktiv {
Seite::Links => &mut self.rechts,
Seite::Rechts => &mut self.links,
}
}
// Liefert beide Panels zum Anschauen: zuerst das aktive, dann das andere.
fn quelle_und_ziel(&self) -> (&Panel, &Panel) {
match self.aktiv {
Seite::Links => (&self.links, &self.rechts),
Seite::Rechts => (&self.rechts, &self.links),
}
}
// Öffnet den Verbindungsdialog, schon ausgefüllt für unseren Testserver.
fn verbindung_zeigen(&mut self) -> Task<Message> {
// Zeigt rechts schon ein Server, trennt F2 die Verbindung wieder
if let Some(ftp) = &self.rechts.server {
self.meldung = format!("Verbindung zu {} getrennt.", ftp.name);
let ordner = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
self.rechts = Panel::neu(ordner);
return Task::none();
}
self.verbindungsdialog = Some(Zugang {
name: String::from("Testserver"),
nr: None,
sftp: false,
host: String::from("localhost"),
port: String::from("2121"),
benutzer: String::from("fahrgast"),
passwort: String::from("geheim"),
});
operation::focus("host")
}
// F4: die Verbindung im Dialog speichern, ohne Passwort.
fn verbindung_merken(&mut self) {
let Some(zugang) = &self.verbindungsdialog else {
return;
};
let name = if zugang.name.trim().is_empty() {
format!("{}@{}", zugang.benutzer, zugang.host)
} else {
zugang.name.trim().to_string()
};
let neu = Gespeichert {
name: name.clone(),
sftp: zugang.sftp,
host: zugang.host.trim().to_string(),
port: zugang.port.trim().to_string(),
benutzer: zugang.benutzer.clone(),
};
// Gibt es schon eine mit diesem Namen, wird sie ersetzt
match self.gespeichert.iter().position(|alt| alt.name == name) {
Some(nr) => self.gespeichert[nr] = neu,
None => self.gespeichert.push(neu),
}
self.meldung = match verbindungen_speichern(&self.gespeichert) {
Ok(()) => format!("Verbindung „{}“ gespeichert (ohne Passwort).", name),
Err(fehler) => format!("Speichern fehlgeschlagen: {}", fehler),
};
}
// Bild↑ und Bild↓: durch die gespeicherten Verbindungen blättern.
fn blaettern(&mut self, schritt: isize) -> Task<Message> {
let anzahl = self.gespeichert.len();
if anzahl == 0 {
self.meldung = String::from("Noch keine Verbindung gespeichert. F4 speichert die aktuelle.");
return Task::none();
}
let Some(zugang) = &mut self.verbindungsdialog else {
return Task::none();
};
let nr = match zugang.nr {
Some(nr) => (nr as isize + schritt).rem_euclid(anzahl as isize) as usize,
None => 0,
};
let gewaehlt = &self.gespeichert[nr];
zugang.nr = Some(nr);
zugang.name = gewaehlt.name.clone();
zugang.sftp = gewaehlt.sftp;
zugang.host = gewaehlt.host.clone();
zugang.port = gewaehlt.port.clone();
zugang.benutzer = gewaehlt.benutzer.clone();
zugang.passwort = String::new();
operation::focus("passwort") // gleich das Passwort eintippen
}
// F8: die gerade gezeigte gespeicherte Verbindung löschen.
fn verbindung_vergessen(&mut self) {
let Some(nr) = self.verbindungsdialog.as_ref().and_then(|zugang| zugang.nr) else {
self.meldung = String::from("Wähl erst mit Bild↑/Bild↓ eine gespeicherte Verbindung.");
return;
};
let weg = self.gespeichert.remove(nr);
if let Some(zugang) = &mut self.verbindungsdialog {
zugang.nr = None;
}
self.meldung = match verbindungen_speichern(&self.gespeichert) {
Ok(()) => format!("Verbindung „{}“ gelöscht.", weg.name),
Err(fehler) => format!("Löschen fehlgeschlagen: {}", fehler),
};
}
// Baut die Verbindung im Hintergrund auf.
fn verbinden(&mut self) -> Task<Message> {
let Some(zugang) = self.verbindungsdialog.take() else {
return Task::none();
};
self.meldung = format!("Verbinde mit {}:{} …", zugang.host, zugang.port);
if zugang.sftp {
Task::perform(sftp_verbinden(zugang), Message::Verbunden)
} else {
Task::perform(ftp_verbinden(zugang), Message::Verbunden)
}
}
// Die Antwort auf den Verbindungsaufbau ist da.
fn verbunden(&mut self, ergebnis: Result<(Server, PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
match ergebnis {
Ok((ftp, ordner, eintraege)) => {
self.meldung = match &ftp.fingerabdruck {
Some(abdruck) => format!("Verbunden mit {} per SFTP. Schlüssel: {}", ftp.name, abdruck),
None => format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name),
};
self.rechts = Panel::entfernt(ftp, ordner, eintraege);
self.aktiv = Seite::Rechts;
}
Err(fehler) => {
self.fehler_melden(format!("Verbindung fehlgeschlagen: {}", freundlich(&fehler)))
}
}
Task::none()
}
// Ein Ordner vom Server ist gelesen. Server-Ordner zeigt immer das rechte Panel.
fn gelesen(&mut self, ergebnis: Result<(PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
match ergebnis {
Ok((ordner, eintraege)) => self.rechts.eintraege_setzen(ordner, eintraege),
Err(fehler) => self.fehler_melden(format!("Der Server sagt: {}", freundlich(&fehler))),
}
Task::none()
}
// Zeigt eine Fehlermeldung, rot hinterlegt bis zur nächsten Taste.
fn fehler_melden(&mut self, text: String) {
self.meldung = text;
self.fehler = true;
}
// Eine Übertragung oder Änderung auf dem Server ist fertig.
fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> {
self.laufend = None;
match ergebnis {
Ok(text) => self.meldung = text,
Err(fehler) => self.fehler_melden(freundlich(&fehler)),
}
self.links.neu_lesen();
self.rechts_neu_lesen()
}
// Liest das rechte Panel neu ein, vom Server im Hintergrund, falls es verbunden ist.
fn rechts_neu_lesen(&mut self) -> Task<Message> {
match &self.rechts.server {
Some(ftp) => Task::perform(
server_lesen(ftp.clone(), self.rechts.ordner.clone()),
Message::Gelesen,
),
None => {
self.rechts.neu_lesen();
Task::none()
}
}
}
// Enter: in den markierten Ordner gehen. Auf dem Server passiert das im Hintergrund.
fn oeffnen(&mut self) -> Task<Message> {
let panel = self.aktives_panel();
match &panel.server {
Some(ftp) => match panel.ziel_ordner() {
Some(ziel) => Task::perform(server_lesen(ftp.clone(), ziel), Message::Gelesen),
None => Task::none(),
},
None => {
panel.oeffnen();
Task::none()
}
}
}
// Öffnet den Rechte-Dialog für den markierten Eintrag.
fn rechte_zeigen(&mut self) {
let panel = self.aktives_panel();
let Some(eintrag) = panel.markierter_eintrag() else {
return;
};
if eintrag.name == ".." {
return;
}
let dialog = RechteDialog {
name: eintrag.name.clone(),
pfad: panel.unterpfad(&eintrag.name),
rechte: eintrag.rechte,
auswahl: 0,
server: panel.server.clone(),
};
self.dialog = Some(dialog);
}
// Setzt die im Dialog gewählten Rechte und schließt den Dialog.
fn rechte_anwenden(&mut self) -> Task<Message> {
let Some(dialog) = self.dialog.take() else {
return Task::none();
};
if let Some(ftp) = dialog.server {
self.meldung = format!("Setze die Rechte von „{}“ …", dialog.name);
let aufgabe = server_rechte_setzen(ftp, dialog.pfad, dialog.rechte, dialog.name);
return Task::perform(aufgabe, Message::Fertig);
}
self.meldung = match rechte_setzen(&dialog.pfad, dialog.rechte) {
Ok(()) => format!(
"Rechte von „{}“ sind jetzt {} ({:03o}).",
dialog.name,
rechte_text(dialog.rechte),
dialog.rechte
),
Err(fehler) => format!("Rechte ändern fehlgeschlagen: {}", fehler),
};
self.links.neu_lesen();
self.rechts.neu_lesen();
Task::none()
}
// Kopiert die markierte Datei vom aktiven Panel in den Ordner des anderen Panels.
fn kopieren(&mut self) -> Task<Message> {
let (quelle, ziel) = self.quelle_und_ziel();
let Some(eintrag) = quelle.markierter_eintrag() else {
return Task::none();
};
if eintrag.name == ".." {
return Task::none();
}
// Alles, was wir brauchen, herausschreiben, solange wir die Panels ausleihen
let name = eintrag.name.clone();
let ist_ordner = eintrag.ist_ordner;
let groesse = eintrag.groesse;
let rechte = eintrag.rechte;
let von = quelle.unterpfad(&name);
let nach = ziel.unterpfad(&name);
let zielordner = ziel.ordner.display().to_string();
let schon_da = ziel.eintraege.iter().any(|anderer| anderer.name == name);
let server_ziel = ziel.server.clone();
let server_quelle = quelle.server.clone();
if schon_da {
self.meldung = format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner);
return Task::none();
}
if let Some(ftp) = server_ziel {
let zaehler = Arc::new(AtomicU64::new(0));
self.laufend = Some(Laufend {
titel: format!("Lade „{}“ hoch", name),
zaehler: zaehler.clone(),
gesamt: Some(lokale_groesse(&von)),
start: Instant::now(),
});
return Task::perform(hochladen(ftp, von, nach, name, zaehler), Message::Fertig);
}
if let Some(ftp) = server_quelle {
let zaehler = Arc::new(AtomicU64::new(0));
self.laufend = Some(Laufend {
titel: format!("Lade „{}“ herunter", name),
zaehler: zaehler.clone(),
gesamt: if ist_ordner { None } else { Some(groesse) },
start: Instant::now(),
});
let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name, zaehler);
return Task::perform(aufgabe, Message::Fertig);
}
// Beide Panels auf diesem Rechner: kopieren wie bisher
self.meldung = if nach.exists() {
format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner)
} else if ist_ordner && nach.starts_with(&von) {
format!("„{}“ kann nicht in sich selbst kopiert werden.", name)
} else if ist_ordner {
match ordner_kopieren(&von, &nach) {
Ok(anzahl) => format!(
"Ordner kopiert: {} mit {} Dateien → {}",
name, anzahl, zielordner
),
Err(fehler) => format!("Ordner kopieren fehlgeschlagen: {}", fehler),
}
} else {
match fs::copy(&von, &nach) {
Ok(_) => format!("Kopiert: {} → {}", name, zielordner),
Err(fehler) => format!("Kopieren fehlgeschlagen: {}", fehler),
}
};
self.inaktives_panel().neu_lesen();
Task::none()
}
fn view(&self) -> Element<'_, Message> {
let menuzeile = container(text(" Links Datei Befehle Optionen Rechts"))
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(TUERKIS)),
text_color: Some(SCHWARZ),
..container::Style::default()
});
let panels = row![
self.links.ansicht(self.aktiv == Seite::Links),
self.rechts.ansicht(self.aktiv == Seite::Rechts),
]
.spacing(6);
let arbeitsflaeche = container(panels)
.width(Fill)
.height(Fill)
.padding(6)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(BLAU)),
text_color: Some(WEISS),
..container::Style::default()
});
let meldung = match &self.laufend {
Some(laufend) => fortschritt_text(laufend),
None => self.meldung.clone(),
};
let hintergrund = if self.fehler { DUNKELROT } else { SCHWARZ };
let meldungszeile = container(text(meldung).color(WEISS).wrapping(text::Wrapping::None))
.width(Fill)
.padding([0, 6])
.style(move |_theme: &Theme| container::Style {
background: Some(Background::Color(hintergrund)),
..container::Style::default()
});
let tastenleiste = row![
f_taste(1, "Hilfe"),
f_taste(2, "Verbind."),
f_taste(3, "Ansicht"),
f_taste(4, "Bearb."),
f_taste(5, "Kopie"),
f_taste(6, "Versch."),
f_taste(7, "Ordner"),
f_taste(8, "Lösch."),
f_taste(9, "Rechte"),
f_taste(10, "Ende"),
]
.spacing(4);
let tastenleiste = container(tastenleiste)
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(SCHWARZ)),
..container::Style::default()
});
let hauptteil = column![menuzeile, arbeitsflaeche, meldungszeile, tastenleiste];
// Ist ein Dialog offen, liegt er mittig über allem anderen
if let Some(zugang) = &self.verbindungsdialog {
let dialog = verbindung_ansicht(zugang, &self.gespeichert);
stack![hauptteil, container(dialog).center(Fill)].into()
} else if let Some(dialog) = &self.dialog {
stack![hauptteil, container(dialog_ansicht(dialog)).center(Fill)].into()
} else {
hauptteil.into()
}
}
}
// Ein Panel: ein Ordner, seine Einträge und der Auswahlbalken.
struct Panel {
ordner: PathBuf,
eintraege: Vec<Eintrag>,
cursor: usize, // Nummer des markierten Eintrags
erste_zeile: usize, // Nummer des obersten sichtbaren Eintrags
server: Option<Server>, // Some, wenn das Panel einen Server zeigt
}
impl Panel {
// Legt ein Panel an, das den angegebenen Ordner zeigt.
fn neu(ordner: PathBuf) -> Self {
let eintraege = ordner_lesen(&ordner);
Panel {
ordner,
eintraege,
cursor: 0,
erste_zeile: 0,
server: None,
}
}
// Legt ein Panel an, das einen Ordner auf dem Server zeigt.
fn entfernt(ftp: Server, ordner: PathBuf, eintraege: Vec<Eintrag>) -> Self {
Panel {
ordner,
eintraege,
cursor: 0,
erste_zeile: 0,
server: Some(ftp),
}
}
// Balken eine Zeile nach oben
fn hoch(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
self.sichtbar_halten();
}
// Balken eine Zeile nach unten
fn runter(&mut self) {
if self.cursor + 1 < self.eintraege.len() {
self.cursor += 1;
}
self.sichtbar_halten();
}
// Liefert den Eintrag, auf dem der Balken steht, falls es einen gibt.
fn markierter_eintrag(&self) -> Option<&Eintrag> {
self.eintraege.get(self.cursor)
}
// Liest den Ordner neu ein, zum Beispiel nachdem eine Datei dazugekommen ist.
fn neu_lesen(&mut self) {
if self.server.is_some() {
return; // Server-Ordner liest der Fährmann im Hintergrund, nicht hier
}
self.eintraege = ordner_lesen(&self.ordner);
if self.cursor >= self.eintraege.len() {
self.cursor = self.eintraege.len().saturating_sub(1);
}
self.sichtbar_halten();
}
// Schiebt den sichtbaren Ausschnitt so, dass der Balken immer zu sehen ist.
fn sichtbar_halten(&mut self) {
if self.cursor < self.erste_zeile {
self.erste_zeile = self.cursor;
}
if self.cursor >= self.erste_zeile + ZEILEN {
self.erste_zeile = self.cursor + 1 - ZEILEN;
}
}
// Betritt den markierten Ordner, oder geht bei ".." eine Ebene hoch.
// (Nur für Ordner auf diesem Rechner. Server-Ordner liest der Fährmann im Hintergrund.)
fn oeffnen(&mut self) {
let Some(ziel) = self.ziel_ordner() else {
return;
};
let eintraege = ordner_lesen(&ziel);
self.eintraege_setzen(ziel, eintraege);
}
// Wohin führt Enter? Liefert den Ordner, falls der Balken auf einem Ordner steht.
fn ziel_ordner(&self) -> Option<PathBuf> {
let eintrag = self.markierter_eintrag()?;
if !eintrag.ist_ordner {
return None; // Dateien öffnen wir (noch) nicht
}
if eintrag.name == ".." {
self.ordner.parent().map(|oben| oben.to_path_buf())
} else {
Some(self.unterpfad(&eintrag.name))
}
}
// Der Pfad eines Eintrags in diesem Panel. Auf dem Server immer mit "/".
fn unterpfad(&self, name: &str) -> PathBuf {
if self.server.is_some() {
let ordner = pfad_text(&self.ordner);
PathBuf::from(format!("{}/{}", ordner.trim_end_matches('/'), name))
} else {
self.ordner.join(name)
}
}
// Zeigt neue Einträge an. Bleibt der Ordner derselbe, bleibt auch der Balken stehen.
fn eintraege_setzen(&mut self, ordner: PathBuf, eintraege: Vec<Eintrag>) {
if ordner != self.ordner {
self.cursor = 0;
self.erste_zeile = 0;
}
self.ordner = ordner;
self.eintraege = eintraege;
if self.cursor >= self.eintraege.len() {
self.cursor = self.eintraege.len().saturating_sub(1);
}
self.sichtbar_halten();
}
// Zeichnet das Panel. Nur das aktive Panel zeigt den Balken und einen hellen Kopf.
fn ansicht(&self, aktiv: bool) -> Element<'_, Message> {
let kopffarbe = if aktiv { SCHWARZ } else { GELB };
let titel = match &self.server {
Some(ftp) => format!("{}:{}", ftp.name, pfad_text(&self.ordner)),
None => self.ordner.display().to_string(),
};
let kopf = container(text(titel).color(kopffarbe))
.style(move |_theme: &Theme| container::Style {
background: if aktiv {
Some(Background::Color(TUERKIS))
} else {
None
},
..container::Style::default()
});
let mut liste = column![];
for (nr, eintrag) in self
.eintraege
.iter()
.enumerate()
.skip(self.erste_zeile)
.take(ZEILEN)
{
liste = liste.push(zeile(eintrag, aktiv && nr == self.cursor));
}
container(column![kopf, liste].spacing(6))
.width(Fill)
.height(Fill)
.padding(8)
.clip(true)
.style(|_theme: &Theme| container::Style {
border: Border {
color: TUERKIS,
width: 2.0,
radius: 0.0.into(),
},
..container::Style::default()
})
.into()
}
}
// Baut eine einzelne Taste der Funktionstastenleiste, z. B. "5 Kopie".
fn f_taste(nummer: u8, beschriftung: &'static str) -> Element<'static, Message> {
let zahl = text(nummer).color(WEISS);
let feld = container(text(beschriftung))
.width(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(TUERKIS)),
text_color: Some(SCHWARZ),
..container::Style::default()
});
row![zahl, feld].width(Fill).into()
}
// Liest einen Ordner und liefert seine Einträge: oben "..", dann Ordner, dann Dateien.
fn ordner_lesen(pfad: &Path) -> Vec<Eintrag> {
let mut eintraege = Vec::new();
let Ok(inhalt) = fs::read_dir(pfad) else {
return eintraege; // Ordner nicht lesbar: leere Liste
};
for eintrag in inhalt.flatten() {
let name = eintrag.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue; // versteckte Dateien überspringen
}
let Ok(info) = eintrag.metadata() else {
continue; // keine Infos lesbar: Eintrag überspringen
};
eintraege.push(Eintrag {
name,
ist_ordner: info.is_dir(),
groesse: info.len(),
rechte: rechte_lesen(&info),
});
}
liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
eintraege
}
// Sortiert eine Liste (Ordner zuerst, dann alphabetisch) und setzt ".." an den Anfang.
fn liste_fertigmachen(eintraege: &mut Vec<Eintrag>, hat_eltern: bool) {
eintraege.sort_by(|a, b| {
b.ist_ordner
.cmp(&a.ist_ordner)
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
if hat_eltern {
let hoch = Eintrag {
name: String::from(".."),
ist_ordner: true,
groesse: 0,
rechte: 0,
};
eintraege.insert(0, hoch);
}
}
// Fragt den Server nach dem Inhalt eines Ordners (ohne "..").
async fn ftp_eintraege(strom: &mut AsyncFtpStream, pfad: &str) -> Result<Vec<Eintrag>, String> {
let zeilen = strom.list(Some(pfad)).await.map_err(|e| e.to_string())?;
let mut eintraege = Vec::new();
for zeile in zeilen {
let Ok(datei) = File::try_from(zeile.as_str()) else {
continue; // eine Zeile, die wir nicht verstehen: überspringen
};
if datei.name().starts_with('.') {
continue;
}
eintraege.push(Eintrag {
name: datei.name().to_string(),
ist_ordner: datei.is_directory(),
groesse: datei.size() as u64,
rechte: ftp_rechte(&datei),
});
}
Ok(eintraege)
}
// Liest die Rechte aus einer Zeile der Serverliste.
fn ftp_rechte(datei: &File) -> u32 {
let mut rechte = 0;
let gruppen = [PosixPexQuery::Owner, PosixPexQuery::Group, PosixPexQuery::Others];
for (reihe, wer) in gruppen.into_iter().enumerate() {
if datei.can_read(wer) {
rechte |= bit(reihe * 3);
}
if datei.can_write(wer) {
rechte |= bit(reihe * 3 + 1);
}
if datei.can_execute(wer) {
rechte |= bit(reihe * 3 + 2);
}
}
rechte
}
// Macht aus einem Pfad einen Text mit "/" als Trenner, so wie Server ihn erwarten.
fn pfad_text(pfad: &Path) -> String {
pfad.to_string_lossy().replace('\\', "/")
}
// Verbindet sich mit dem Server, meldet sich an und liest den Startordner.
async fn ftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
let adresse = format!("{}:{}", zugang.host.trim(), zugang.port.trim());
let mut strom = AsyncFtpStream::connect(adresse.as_str())
.await
.map_err(|e| e.to_string())?;
strom
.login(&zugang.benutzer, &zugang.passwort)
.await
.map_err(|e| e.to_string())?;
strom
.transfer_type(FileType::Binary)
.await
.map_err(|e| e.to_string())?;
let start = strom.pwd().await.map_err(|e| e.to_string())?;
let mut eintraege = ftp_eintraege(&mut strom, &start).await?;
liste_fertigmachen(&mut eintraege, start != "/");
let ftp = Server {
strom: Arc::new(Mutex::new(Strom::Ftp(strom))),
name: format!("{}@{}", zugang.benutzer, zugang.host),
fingerabdruck: None,
};
Ok((ftp, PathBuf::from(start), eintraege))
}
// Verbindet sich per SSH, meldet sich an, öffnet SFTP und liest den Startordner.
async fn sftp_verbinden(zugang: Zugang) -> Result<(Server, PathBuf, Vec<Eintrag>), String> {
let port: u16 = zugang
.port
.trim()
.parse()
.map_err(|_| format!("„{}“ ist kein gültiger Port.", zugang.port))?;
let fingerabdruck = Arc::new(std::sync::Mutex::new(String::new()));
let pruefer = Pruefer {
fingerabdruck: fingerabdruck.clone(),
};
let einstellungen = Arc::new(russh::client::Config::default());
let mut sitzung = russh::client::connect(einstellungen, (zugang.host.trim(), port), pruefer)
.await
.map_err(als_text)?;
let anmeldung = sitzung
.authenticate_password(&zugang.benutzer, &zugang.passwort)
.await
.map_err(als_text)?;
if !anmeldung.success() {
return Err(String::from("Benutzername oder Passwort stimmt nicht."));
}
let kanal = sitzung.channel_open_session().await.map_err(als_text)?;
kanal.request_subsystem(true, "sftp").await.map_err(als_text)?;
let sftp = SftpSession::new(kanal.into_stream()).await.map_err(als_text)?;
let start = sftp.canonicalize(".").await.map_err(als_text)?;
let mut strom = Strom::Sftp {
sftp,
_sitzung: sitzung,
};
let mut eintraege = strom.eintraege(&start).await?;
liste_fertigmachen(&mut eintraege, start != "/");
let server = Server {
strom: Arc::new(Mutex::new(strom)),
name: format!("{}@{}", zugang.benutzer, zugang.host),
fingerabdruck: Some(fingerabdruck.lock().unwrap().clone()),
};
Ok((server, PathBuf::from(start), eintraege))
}
// Liest einen Ordner auf dem Server.
async fn server_lesen(ftp: Server, pfad: PathBuf) -> Result<(PathBuf, Vec<Eintrag>), String> {
let mut strom = ftp.strom.lock().await;
let mut eintraege = strom.eintraege(&pfad_text(&pfad)).await?;
liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
Ok((pfad, eintraege))
}
// Beantwortet beim SSH-Verbindungsaufbau die Frage: "Ist das wirklich der richtige Server?"
// Wir merken uns dabei den Fingerabdruck des Servers, um ihn anzuzeigen.
struct Pruefer {
fingerabdruck: Arc<std::sync::Mutex<String>>,
}
impl russh::client::Handler for Pruefer {
type Error = russh::Error;
async fn check_server_key(
&mut self,
schluessel: &PublicKeyOrCertificate,
) -> Result<bool, Self::Error> {
if let PublicKeyOrCertificate::PublicKey { key, .. } = schluessel {
*self.fingerabdruck.lock().unwrap() = key.fingerprint(HashAlg::Sha256).to_string();
}
Ok(true) // Wir vertrauen jedem Server. Mehr dazu im Kasten in Schritt 5!
}
}
// Eine Leitung zum Server. Jede Art von Server spricht ihre eigene Sprache.
enum Strom {
Ftp(AsyncFtpStream),
Sftp {
sftp: SftpSession,
_sitzung: russh::client::Handle<Pruefer>, // hält die SSH-Verbindung am Leben
},
}
// Was man mit jeder Leitung tun kann, egal welche Sprache der Server spricht.
impl Strom {
// Liest einen Ordner (ohne "..").
async fn eintraege(&mut self, pfad: &str) -> Result<Vec<Eintrag>, String> {
match self {
Strom::Ftp(ftp) => ftp_eintraege(ftp, pfad).await,
Strom::Sftp { sftp, .. } => {
let mut eintraege = Vec::new();
for eintrag in sftp.read_dir(pfad).await.map_err(als_text)? {
let name = eintrag.file_name();
if name.starts_with('.') {
continue;
}
let info = eintrag.metadata();
eintraege.push(Eintrag {
name,
ist_ordner: eintrag.file_type().is_dir(),
groesse: info.size.unwrap_or(0),
rechte: info.permissions.unwrap_or(0) & 0o777,
});
}
Ok(eintraege)
}
}
}
// Legt einen Ordner an.
async fn ordner_anlegen(&mut self, pfad: &str) -> Result<(), String> {
match self {
Strom::Ftp(ftp) => ftp.mkdir(pfad).await.map_err(als_text),
Strom::Sftp { sftp, .. } => sftp.create_dir(pfad).await.map_err(als_text),
}
}
// Schickt eine Datei von diesem Rechner zum Server.
async fn datei_hoch(
&mut self,
von: &Path,
nach: &str,
zaehler: &AtomicU64,
) -> Result<(), String> {
let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?;
match self {
Strom::Ftp(ftp) => {
let mut ziel = ftp.put_with_stream(nach).await.map_err(als_text)?;
zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;
ziel.finish().await.map_err(als_text)?;
}
Strom::Sftp { sftp, .. } => {
let mut ziel = sftp.create(nach).await.map_err(als_text)?;
zaehlend_kopieren(&mut datei, &mut ziel, zaehler).await?;
ziel.shutdown().await.map_err(als_text)?;
}
}
Ok(())
}
// Holt eine Datei vom Server auf diesen Rechner.
async fn datei_runter(
&mut self,
von: &str,
nach: &Path,
zaehler: &AtomicU64,
) -> Result<(), String> {
let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?;
match self {
Strom::Ftp(ftp) => {
let mut quelle = ftp.retr_as_stream(von).await.map_err(als_text)?;
zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;
quelle.finish().await.map_err(als_text)?;
}
Strom::Sftp { sftp, .. } => {
let mut quelle = sftp.open(von).await.map_err(als_text)?;
zaehlend_kopieren(&mut quelle, &mut datei, zaehler).await?;
}
}
Ok(())
}
// Setzt die Rechte. FTP braucht dafür SITE CHMOD, das nicht jeder Server kann.
async fn rechte_setzen(&mut self, pfad: &str, rechte: u32) -> Result<(), String> {
match self {
Strom::Ftp(ftp) => {
let befehl = format!("CHMOD {:03o} {}", rechte, pfad);
ftp.site(befehl).await.map(|_| ()).map_err(als_text)
}
Strom::Sftp { sftp, .. } => {
let mut attribute = FileAttributes::empty();
attribute.permissions = Some(rechte);
sftp.set_metadata(pfad, attribute).await.map_err(als_text)
}
}
}
}
// Was bei einer Übertragung zusammengekommen ist.
#[derive(Default)]
struct Bilanz {
dateien: u64,
ohne_rechte: u64, // Einträge, bei denen sich die Rechte nicht übernehmen ließen
zaehler: Arc<AtomicU64>, // übertragene Bytes, laufend mitgezählt
}
impl Bilanz {
// Macht aus der Bilanz eine Meldung für die Meldungszeile.
fn text(&self, name: &str, wie: &str) -> String {
let mut text = format!("„{}“ {}: {} Datei(en).", name, wie, self.dateien);
if self.ohne_rechte > 0 {
text += &format!(
" Bei {} Einträgen ließen sich die Rechte nicht übernehmen.",
self.ohne_rechte
);
}
text
}
}
// Macht aus jedem Fehler einen Text. So haben alle Fehler dieselbe Form.
fn als_text<E: fmt::Display>(fehler: E) -> String {
fehler.to_string()
}
// Kopiert von einer Quelle in ein Ziel und zählt dabei jedes Byte mit.
async fn zaehlend_kopieren<Q, Z>(quelle: &mut Q, ziel: &mut Z, zaehler: &AtomicU64) -> Result<(), String>
where
Q: AsyncRead + Unpin,
Z: AsyncWrite + Unpin,
{
let mut puffer = vec![0u8; 64 * 1024];
loop {
let gelesen = quelle.read(&mut puffer).await.map_err(als_text)?;
if gelesen == 0 {
return Ok(()); // Quelle leer: fertig
}
ziel.write_all(&puffer[..gelesen]).await.map_err(als_text)?;
zaehler.fetch_add(gelesen as u64, Ordering::Relaxed);
}
}
// Der Rückgabetyp einer Übertragung: eine Aufgabe, die später fertig wird.
type Uebertragung<'a> = Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
// Bringt eine Datei oder einen ganzen Ordner auf den Server, samt Rechten.
fn hoch<'a>(
strom: &'a mut Strom,
von: &'a Path,
nach: &'a str,
bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
Box::pin(async move {
let info = fs::metadata(von).map_err(als_text)?;
if info.is_dir() {
strom.ordner_anlegen(nach).await?;
for eintrag in fs::read_dir(von).map_err(als_text)? {
let eintrag = eintrag.map_err(als_text)?;
if eintrag.file_type().map_err(als_text)?.is_symlink() {
continue; // Verknüpfungen lassen wir aus
}
let name = eintrag.file_name().to_string_lossy().to_string();
hoch(strom, &eintrag.path(), &format!("{}/{}", nach, name), bilanz).await?;
}
} else {
strom.datei_hoch(von, nach, &bilanz.zaehler).await?;
bilanz.dateien += 1;
}
// Rechte mitnehmen. Nicht jeder Server kann das, darum nur zählen, nicht abbrechen.
if strom.rechte_setzen(nach, rechte_lesen(&info)).await.is_err() {
bilanz.ohne_rechte += 1;
}
Ok(())
})
}
// Lädt hoch und meldet das Ergebnis.
async fn hochladen(
ftp: Server,
von: PathBuf,
nach: PathBuf,
name: String,
zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = Bilanz {
zaehler,
..Bilanz::default()
};
hoch(&mut strom, &von, &pfad_text(&nach), &mut bilanz)
.await
.map_err(|fehler| format!("Hochladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
Ok(bilanz.text(&name, "hochgeladen"))
}
// Holt eine Datei oder einen ganzen Ordner vom Server, samt Rechten.
fn runter<'a>(
strom: &'a mut Strom,
von: &'a str,
nach: &'a Path,
ist_ordner: bool,
rechte: u32,
bilanz: &'a mut Bilanz,
) -> Uebertragung<'a> {
Box::pin(async move {
if ist_ordner {
fs::create_dir(nach).map_err(als_text)?;
for eintrag in strom.eintraege(von).await? {
let unter_von = format!("{}/{}", von, eintrag.name);
let unter_nach = nach.join(&eintrag.name);
runter(strom, &unter_von, &unter_nach, eintrag.ist_ordner, eintrag.rechte, bilanz)
.await?;
}
} else {
strom.datei_runter(von, nach, &bilanz.zaehler).await?;
bilanz.dateien += 1;
}
// Die Rechte vom Server übernehmen, bei Ordnern erst ganz zum Schluss
if rechte_setzen(nach, rechte).is_err() {
bilanz.ohne_rechte += 1;
}
Ok(())
})
}
// Lädt herunter und meldet das Ergebnis.
async fn herunterladen(
ftp: Server,
von: PathBuf,
nach: PathBuf,
ist_ordner: bool,
rechte: u32,
name: String,
zaehler: Arc<AtomicU64>,
) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = Bilanz {
zaehler,
..Bilanz::default()
};
runter(&mut strom, &pfad_text(&von), &nach, ist_ordner, rechte, &mut bilanz)
.await
.map_err(|fehler| format!("Herunterladen von „{}“ fehlgeschlagen: {}", name, fehler))?;
Ok(bilanz.text(&name, "heruntergeladen"))
}
// Ändert die Rechte einer Datei oder eines Ordners auf dem Server.
async fn server_rechte_setzen(ftp: Server, pfad: PathBuf, rechte: u32, name: String) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
match strom.rechte_setzen(&pfad_text(&pfad), rechte).await {
Ok(()) => Ok(format!(
"Rechte von „{}“ auf dem Server sind jetzt {} ({:03o}).",
name,
rechte_text(rechte),
rechte
)),
Err(fehler) => Err(format!("Der Server lehnt neue Rechte für „{}“ ab: {}", name, fehler)),
}
}
// Baut eine Zeile im Panel: links der Name, rechts die Größe.
fn zeile(eintrag: &Eintrag, markiert: bool) -> Element<'_, Message> {
let farbe = if markiert {
SCHWARZ
} else if eintrag.ist_ordner {
WEISS
} else {
HELLTUERKIS
};
let groesse = if eintrag.ist_ordner {
String::from("<ORDNER>")
} else {
groesse_text(eintrag.groesse)
};
let rechte = if eintrag.name == ".." {
String::new()
} else {
rechte_text(eintrag.rechte)
};
let inhalt = row![
text(eintrag.name.as_str())
.color(farbe)
.width(Fill)
.wrapping(text::Wrapping::None),
text(rechte).color(farbe),
text(groesse).color(farbe).width(90).align_x(Right),
]
.spacing(12);
// Der markierte Eintrag bekommt den türkisen Balken
container(inhalt)
.width(Fill)
.style(move |_theme: &Theme| container::Style {
background: if markiert {
Some(Background::Color(TUERKIS))
} else {
None
},
..container::Style::default()
})
.into()
}
// Macht aus einer Zahl von Bytes etwas Lesbares, zum Beispiel "12,3 KB".
fn groesse_text(bytes: u64) -> String {
let b = bytes as f64;
let text = if bytes < 1024 {
format!("{} B", bytes)
} else if b < 1024.0 * 1024.0 {
format!("{:.1} KB", b / 1024.0)
} else if b < 1024.0 * 1024.0 * 1024.0 {
format!("{:.1} MB", b / 1024.0 / 1024.0)
} else {
format!("{:.1} GB", b / 1024.0 / 1024.0 / 1024.0)
};
text.replace('.', ",")
}
// Baut die Fortschrittsanzeige, zum Beispiel: Lade „Fotos“ hoch [██████░░░░] 60 % 3,1 MB/s
fn fortschritt_text(laufend: &Laufend) -> String {
let bytes = laufend.zaehler.load(Ordering::Relaxed);
let sekunden = laufend.start.elapsed().as_secs_f64().max(0.1);
let tempo = groesse_text((bytes as f64 / sekunden) as u64);
match laufend.gesamt {
Some(gesamt) if gesamt > 0 => {
let anteil = (bytes as f64 / gesamt as f64).min(1.0);
let voll = (anteil * 20.0) as usize;
let balken = "█".repeat(voll) + &"░".repeat(20 - voll);
format!("{} [{}] {:3.0} % {}/s", laufend.titel, balken, anteil * 100.0, tempo)
}
_ => format!("{} … bisher {}, {}/s", laufend.titel, groesse_text(bytes), tempo),
}
}
// Wie viele Bytes stecken in einer Datei oder in einem ganzen Ordner?
fn lokale_groesse(pfad: &Path) -> u64 {
let Ok(info) = fs::symlink_metadata(pfad) else {
return 0;
};
if !info.is_dir() {
return info.len();
}
let Ok(inhalt) = fs::read_dir(pfad) else {
return 0;
};
inhalt.flatten().map(|eintrag| lokale_groesse(&eintrag.path())).sum()
}
// Übersetzt technische Fehlermeldungen in verständliche Sätze. Das Original steht in Klammern dahinter.
fn freundlich(fehler: &str) -> String {
let klein = fehler.to_lowercase();
let erklaerung = if klein.contains("connection refused") {
"Der Server ist nicht erreichbar. Läuft er, und stimmen Adresse und Port?"
} else if klein.contains("lookup") || klein.contains("not known") || klein.contains("no such host") {
"Einen Server mit diesem Namen gibt es nicht. Ist die Adresse richtig geschrieben?"
} else if klein.contains("connection reset") || klein.contains("broken pipe") {
"Die Verbindung ist mittendrin abgerissen. Ist der Server noch da, oder ist sein Speicher voll?"
} else if klein.contains("timed out") {
"Der Server antwortet nicht. Vielleicht blockiert eine Firewall die Verbindung."
} else if klein.contains("530") {
"Benutzername oder Passwort stimmt nicht."
} else if klein.contains("permission denied") || klein.contains("550") || klein.contains("privileges") {
"Dafür fehlen die Rechte, oder die Datei gibt es nicht."
} else if klein.contains("no such file") {
"Die Datei oder der Ordner existiert nicht (mehr)."
} else if klein.contains("no space") {
"Der Speicherplatz ist voll."
} else {
return fehler.to_string(); // unbekannt: lieber das Original als eine falsche Erklärung
};
format!("{} ({})", erklaerung, fehler)
}
// Eine gespeicherte Verbindung. Das Passwort speichern wir absichtlich nicht.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Gespeichert {
name: String,
sftp: bool,
host: String,
port: String,
benutzer: String,
}
// So sieht die ganze Datei aus: eine Liste von [[verbindung]]-Abschnitten.
#[derive(Serialize, Deserialize, Default)]
struct Verbindungsdatei {
#[serde(default)]
verbindung: Vec<Gespeichert>,
}
// Wo die Datei liegt, unter Linux zum Beispiel ~/.config/faehrmann/verbindungen.toml
fn verbindungsdatei() -> Option<PathBuf> {
Some(dirs::config_dir()?.join("faehrmann").join("verbindungen.toml"))
}
// Liest die gespeicherten Verbindungen. Gibt es noch keine Datei, ist die Liste leer.
fn verbindungen_laden() -> Vec<Gespeichert> {
let Some(pfad) = verbindungsdatei() else {
return Vec::new();
};
let Ok(inhalt) = fs::read_to_string(&pfad) else {
return Vec::new();
};
match toml::from_str::<Verbindungsdatei>(&inhalt) {
Ok(datei) => datei.verbindung,
Err(_) => Vec::new(), // kaputte Datei: lieber leer starten als abstürzen
}
}
// Schreibt alle Verbindungen in die Datei.
fn verbindungen_speichern(liste: &[Gespeichert]) -> Result<(), String> {
let pfad = verbindungsdatei().ok_or("Kein Ordner für Einstellungen gefunden.")?;
if let Some(ordner) = pfad.parent() {
fs::create_dir_all(ordner).map_err(als_text)?;
}
let datei = Verbindungsdatei {
verbindung: liste.to_vec(),
};
let inhalt = toml::to_string_pretty(&datei).map_err(als_text)?;
fs::write(&pfad, inhalt).map_err(als_text)
}
// Kopiert einen ganzen Ordner mit allem, was darin liegt, und liefert die Zahl der Dateien.
// Die Rechte jeder Datei und jedes Ordners werden dabei mitgenommen.
fn ordner_kopieren(von: &Path, nach: &Path) -> io::Result<u64> {
fs::create_dir(nach)?;
let mut dateien = 0;
for eintrag in fs::read_dir(von)? {
let eintrag = eintrag?;
let art = eintrag.file_type()?;
let ziel = nach.join(eintrag.file_name());
if art.is_symlink() {
continue; // Verknüpfungen lassen wir aus
} else if art.is_dir() {
dateien += ordner_kopieren(&eintrag.path(), &ziel)?;
} else {
fs::copy(eintrag.path(), &ziel)?; // nimmt die Rechte der Datei mit
dateien += 1;
}
}
// Die Rechte des Ordners erst zum Schluss setzen: Ein schreibgeschützter
// Ordner ließe sich sonst gar nicht mehr befüllen.
fs::set_permissions(nach, fs::metadata(von)?.permissions())?;
Ok(dateien)
}
// Liefert die Nummer eines Rechte-Bits: 0 ist "Besitzer darf lesen", 8 ist "Andere dürfen ausführen".
fn bit(nr: usize) -> u32 {
1 << (8 - nr)
}
// Macht aus den Rechte-Bits einen Text wie "rwxr-xr-x".
fn rechte_text(rechte: u32) -> String {
let zeichen = ['r', 'w', 'x'];
let mut text = String::new();
for nr in 0..9 {
if rechte & bit(nr) != 0 {
text.push(zeichen[nr % 3]);
} else {
text.push('-');
}
}
text
}
// Liest die Rechte aus den Infos einer Datei.
fn rechte_lesen(info: &fs::Metadata) -> u32 {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
info.permissions().mode() & 0o777
}
#[cfg(not(unix))]
{
// Windows kennt nur "schreibgeschützt" oder nicht
if info.permissions().readonly() { 0o444 } else { 0o666 }
}
}
// Setzt die Rechte einer Datei oder eines Ordners.
fn rechte_setzen(pfad: &Path, rechte: u32) -> io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(pfad, fs::Permissions::from_mode(rechte))
}
#[cfg(not(unix))]
{
let mut erlaubnis = fs::metadata(pfad)?.permissions();
erlaubnis.set_readonly(rechte & 0o200 == 0); // ohne Schreibrecht: schreibgeschützt
fs::set_permissions(pfad, erlaubnis)
}
}
// Das Aussehen aller Dialoge: grau mit schwarzem Rahmen, wie im Original.
fn dialog_stil(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(GRAU)),
text_color: Some(SCHWARZ),
border: Border {
color: SCHWARZ,
width: 2.0,
radius: 0.0.into(),
},
..container::Style::default()
}
}
// Zeichnet den Verbindungsdialog mit vier Eingabefeldern.
fn verbindung_ansicht<'a>(zugang: &'a Zugang, gespeichert: &[Gespeichert]) -> Element<'a, Message> {
let namen: Vec<String> = gespeichert
.iter()
.enumerate()
.map(|(nr, verbindung)| {
if zugang.nr == Some(nr) {
format!("[{}]", verbindung.name)
} else {
verbindung.name.clone()
}
})
.collect();
let liste = if namen.is_empty() {
String::from("Gespeichert: noch nichts")
} else {
format!("Gespeichert: {}", namen.join(" · "))
};
let inhalt = column![
text("Mit einem Server verbinden"),
text(liste).size(13),
text(if zugang.sftp {
"Protokoll: SFTP (verschlüsselt) · F3 wechselt"
} else {
"Protokoll: FTP (unverschlüsselt) · F3 wechselt"
}),
eingabezeile("Name", &zugang.name, Feld::Name, "name", false),
eingabezeile("Server", &zugang.host, Feld::Host, "host", false),
eingabezeile("Port", &zugang.port, Feld::Port, "port", false),
eingabezeile("Benutzer", &zugang.benutzer, Feld::Benutzer, "benutzer", false),
eingabezeile("Passwort", &zugang.passwort, Feld::Passwort, "passwort", true),
text("Tab: nächstes Feld · Enter: verbinden · Esc Esc: abbrechen").size(13),
text("Bild↑/Bild↓: gespeicherte Verbindung · F4: speichern · F8: löschen").size(13),
]
.spacing(12);
container(inhalt)
.padding(18)
.width(480)
.style(dialog_stil)
.into()
}
// Eine Zeile im Verbindungsdialog: links die Beschriftung, rechts das Eingabefeld.
fn eingabezeile<'a>(
beschriftung: &'a str,
wert: &'a str,
feld: Feld,
id: &'static str,
geheim: bool,
) -> Element<'a, Message> {
row![
text(beschriftung).width(110),
text_input("", wert)
.id(id)
.secure(geheim)
.on_input(move |neu| Message::Eingabe(feld, neu))
.on_submit(Message::Verbinden)
.padding(4),
]
.align_y(Center)
.into()
}
// Zeichnet den Rechte-Dialog als graue Box im Commander-Stil.
fn dialog_ansicht(dialog: &RechteDialog) -> Element<'_, Message> {
let mut gitter = column![row![
text("").width(110),
text("Lesen").width(110),
text("Schreiben").width(110),
text("Ausführen").width(110),
]]
.spacing(4);
for (reihe_nr, wer) in ["Besitzer", "Gruppe", "Andere"].iter().enumerate() {
let mut reihe = row![text(*wer).width(110)];
for spalte in 0..3 {
let nr = reihe_nr * 3 + spalte;
let an = dialog.rechte & bit(nr) != 0;
let gewaehlt = nr == dialog.auswahl;
let kaestchen = text(if an { "[x]" } else { "[ ]" })
.color(if gewaehlt { WEISS } else { SCHWARZ });
reihe = reihe.push(container(kaestchen).width(110).style(move |_theme: &Theme| {
container::Style {
background: if gewaehlt {
Some(Background::Color(SCHWARZ))
} else {
None
},
..container::Style::default()
}
}));
}
gitter = gitter.push(reihe);
}
let inhalt = column![
text(format!("Rechte für „{}“", dialog.name)),
gitter,
text(format!("Zahlencode: {:03o}", dialog.rechte)),
text("Pfeile: wählen · Leertaste: an/aus · Enter: übernehmen · Esc: abbrechen").size(13),
]
.spacing(14);
container(inhalt)
.padding(18)
.style(dialog_stil)
.into()
}
// Übersetzt einen Tastendruck in eine Nachricht, oder in gar nichts.
fn taste(ereignis: keyboard::Event) -> Option<Message> {
let keyboard::Event::KeyPressed { key, .. } = ereignis else {
return None; // Taste losgelassen oder Ähnliches: interessiert uns nicht
};
match key {
Key::Named(Named::ArrowUp) => Some(Message::Hoch),
Key::Named(Named::ArrowDown) => Some(Message::Runter),
Key::Named(Named::Enter) => Some(Message::Oeffnen),
Key::Named(Named::Tab) => Some(Message::Wechseln),
Key::Named(Named::F5) => Some(Message::Kopieren),
Key::Named(Named::F2) => Some(Message::VerbindenZeigen),
Key::Named(Named::F3) => Some(Message::ProtokollWechseln),
Key::Named(Named::F4) => Some(Message::Speichern),
Key::Named(Named::F8) => Some(Message::Loeschen),
Key::Named(Named::PageUp) => Some(Message::Vorige),
Key::Named(Named::PageDown) => Some(Message::Naechste),
Key::Named(Named::F9) => Some(Message::RechteZeigen),
Key::Named(Named::ArrowLeft) => Some(Message::PfeilLinks),
Key::Named(Named::ArrowRight) => Some(Message::PfeilRechts),
Key::Named(Named::Space) => Some(Message::Umschalten),
Key::Named(Named::Escape) => Some(Message::Abbrechen),
Key::Named(Named::F10) => Some(Message::Beenden),
_ => None,
}
}
#[derive(Serialize, Deserialize)] werden eigene Typen speicherbar, und
toml ist ein Dateiformat, das Menschen und Programme gleichermaßen gut lesen.
dirs findet den richtigen Ordner für Einstellungen auf jedem Betriebssystem.
Beim Laden von Einstellungen ist man nachsichtig, beim Speichern ehrlich. Passwörter gehören
nicht in Textdateien. Und Rust erlaubt, zwei verschiedene Felder gleichzeitig auszuleihen, auch
wenn eines davon verändert wird.Wie geht es weiter?
Der Fährmann ist fertig, aber ein Werkzeug wächst mit seinen Benutzern. Ein paar Ideen, für die du jetzt alles Nötige kennst:
- F8 löscht Dateien: mit einem Rückfrage-Dialog im Stil des
Rechte-Dialogs, lokal mit
fs::remove_fileundfs::remove_dir_all, auf dem Server mitrmundrmdirbzw.remove_fileundremove_dir. Am besten als neue Fähigkeit der Leitung. - F7 legt Ordner an: ein Dialog mit einem Eingabefeld, und dann
fs::create_diroderstrom.ordner_anlegen, das es schon gibt. - Mehrere Dateien markieren: Im Original markierte die Einfg-Taste Dateien gelb, und F5 kopierte alle auf einmal.
- Abbrechen: Eine laufende Übertragung mit Esc stoppen. Tipp:
ein zweiter atomarer Wert, ein
AtomicBool, den die Kopierschleife bei jedem Häppchen abfragt.
Zum Ausprobieren
Die Übungen sind freiwillig, aber sie helfen enorm. Mach nach jeder Übung einen Probelauf, und stell danach den alten Zustand wieder her, damit die nächste Etappe bei dir genauso weitergeht wie bei allen anderen.
- Verbinden auf Knopfdruck: Lass den Fährmann beim Drücken von F2 gleich die erste gespeicherte Verbindung anzeigen, falls es eine gibt.
- Die Datei von Hand: Öffne
verbindungen.tomlin einem Editor, ändere einen Namen und lösch bei einer Verbindung die Zeilesftp = …. Was passiert beim nächsten Start, und warum? - Der Schlüsselbund: Die Crate
keyringlegt Passwörter im Schlüsselbund des Betriebssystems ab. Überleg dir, an welchen zwei Stellen im Fährmann sie eingesetzt werden müsste, und probier es aus.
Lösungen anzeigen
1. Am Ende von verbindung_zeigen, statt operation::focus("host"):
if !self.gespeichert.is_empty() { return self.blaettern(0); } und danach
operation::focus("host"). blaettern(0) zeigt die Verbindung
Nummer 0, weil nr beim neuen Dialog noch None ist, und springt ins
Passwortfeld.
2. Der geänderte Name erscheint ganz normal. Fehlt aber sftp, kann serde den
Eintrag nicht vollständig lesen, und weil verbindungen_laden nachsichtig ist,
ist die ganze Liste leer. Abhilfe: #[serde(default)] über dem Feld
sftp, dann gilt false, wenn es fehlt. Achtung: Speicherst du mit
leerer Liste, ist die alte Datei überschrieben. Ein Profi-Programm würde die kaputte Datei
vorher umbenennen.
3. Erstens in verbindung_merken: keyring::Entry::new("faehrmann",
&name)?.set_password(&zugang.passwort). Zweitens in blaettern:
zugang.passwort = keyring::Entry::new("faehrmann", &gewaehlt.name)
.and_then(|e| e.get_password()).unwrap_or_default();. Unter Linux braucht
keyring einen laufenden Schlüsseldienst wie GNOME Keyring oder KWallet und je
nach Version passende Features. Ein Blick in seine Dokumentation lohnt sich.
Geschafft! Der Fährmann ist fertig ⛴️🏁
Aus einem blauen Fenster ist in dreizehn
Etappen ein echter Dateimanager geworden: zwei Panels, Tastatursteuerung, Ordner und Rechte,
FTP und verschlüsseltes SFTP, Fortschrittsanzeige, verständliche Fehler und gespeicherte
Verbindungen. Dabei hast du fast alles kennengelernt, was Rust ausmacht, von
Option und Result über Traits und Generics bis zu asynchronem Code.
Glückwunsch, Kapitän!
Hat dir der Fährmann gefallen? Ein Kaffee hilft, dass die nächste Werkstatt ein Stück schneller fertig wird.
☕ Kaffee spendieren