Fracht übers Netz – hin und zurück
Die Fähre ist am anderen Ufer angekommen, jetzt soll sie auch Fracht mitnehmen. Heute lernt der Fährmann hochladen und herunterladen: einzelne Dateien und ganze Ordner, beliebig tief verschachtelt, und die Zugriffsrechte reisen mit. Auch der Rechte-Dialog aus Etappe 7 funktioniert danach auf dem Server. Am Ende dieser Etappe ist der Fährmann ein echter FTP-Client.
Bevor wir loslegen
Wir machen da weiter, wo Etappe 9 aufgehört hat. Den kompletten Stand findest du dort unter Zwischenstand. Dein Testserver aus Etappe 8 sollte laufen.
Hochladen und Herunterladen sind Spiegelbilder voneinander. Beide gehen nach demselben
Rezept wie das Ordnerkopieren aus Etappe 6: Ist es eine Datei, übertrag sie. Ist es ein
Ordner, leg ihn am Ziel an und mach mit allem darin dasselbe. Und zum Schluss die Rechte. Neu
ist nur, dass auf der einen Seite jetzt ein Server steht, der jede Antwort über das Netz
schickt. Also wird alles asynchron, mit .await.
Bei den Rechten gibt es einen Unterschied zwischen den Richtungen. Herunterladen ist einfach:
Die Rechte stehen in der Liste des Servers, und lokal setzen wir sie wie in Etappe 7.
Hochladen dagegen braucht den Befehl SITE CHMOD, und den kennt nicht jeder
FTP-Server. Unser Testserver kann ihn. Kann ein Server es nicht, soll der Fährmann trotzdem
weitermachen und am Ende ehrlich sagen, bei wie vielen Einträgen es nicht geklappt hat.
Hochladen
Für das Hochladen brauchen wir zwei Werkzeuge aus der Standardbibliothek, deren Zweck gleich klar wird:
use std::fmt; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc;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;
Dann ein ganzer Block neuer Bausteine hinter ftp_lesen. Lass dich von der Länge
nicht abschrecken, wir gehen ihn in Ruhe durch:
liste_fertigmachen(&mut eintraege, pfad.parent().is_some()); Ok((pfad, eintraege)) }// 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 } 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() } // 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 AsyncFtpStream, 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.mkdir(nach).await.map_err(als_text)?; 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 { let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?; strom.put_file(nach, &mut datei).await.map_err(als_text)?; bilanz.dateien += 1; } // Rechte mitnehmen. Nicht jeder Server kann das, darum nur zählen, nicht abbrechen. let befehl = format!("CHMOD {:03o} {}", rechte_lesen(&info), nach); if strom.site(befehl).await.is_err() { bilanz.ohne_rechte += 1; } Ok(()) }) } // Lädt hoch und meldet das Ergebnis. async fn hochladen(ftp: Ftp, von: PathBuf, nach: PathBuf, name: String) -> Result<String, String> { let mut strom = ftp.strom.lock().await; let mut bilanz = 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")) }
Bilanzzählt mit, wie viele Dateien übertragen wurden und wo die Rechte nicht geklappt haben.#[derive(Default)]gibt unsBilanz::default(), eine Bilanz mit lauter Nullen.textmacht daraus die Meldung, und+=hängt bei Bedarf einen zweiten Satz an.als_textersetzt die vielen.map_err(|e| e.to_string())durch das kürzere.map_err(als_text). Die Funktion ist generisch:<E: fmt::Display>heißt, sie nimmt jeden Fehlertyp, der sich als Text darstellen lässt. Egal ob er von der Festplatte oder vom Server kommt.hochist das Herzstück, und es ist rekursiv: Bei einem Ordner legt es auf dem Server mitmkdireinen Ordner an und ruft sich für jeden Eintrag darin selbst auf. Bei einer Datei öffnet es sie mittokio::fs::File(der asynchronen Variante) und schickt sie mitput_filezum Server. Zum Schluss setztSITE CHMOD 644 /pfaddie Rechte. Klappt das nicht, zählen wir es nur und machen weiter.hochladenholt sich das Mikrofon der Verbindung, startethochund macht aus dem Ergebnis eine Meldung.
async fn hoch
schreiben. Aber eine async fn, die sich selbst aufruft, ist für Rust ein
Problem: Der Future einer solchen Funktion enthält den Future des inneren Aufrufs, der enthält
wieder einen, und so weiter. Wie groß ist das Ganze? Das kann der Compiler nicht wissen.
Die Lösung ist eine Box: Der innere Future liegt nicht in dem
äußeren, sondern irgendwo anders im Speicher, und der äußere hat nur einen Zettel mit der
Adresse. Box::pin(async move { … }) baut genau so einen eingepackten Future.
Den langen Typ dafür haben wir Uebertragung getauft, damit er nicht zweimal
dasteht. + Send verspricht, dass die Aufgabe im Hintergrund laufen darf, und
'a sagt: Sie lebt höchstens so lange wie die Dinge, die sie sich ausleiht. Das
ist der anspruchsvollste Typ im ganzen Projekt. Wenn du ihn nicht auf Anhieb durchschaust:
Das geht allen so.cargo run
Rust warnt bei fast allen neuen Bausteinen, dass sie nie benutzt werden. Kein
Wunder: Sie hängen alle an hochladen, und das ruft noch niemand auf.Herunterladen
Herunterladen ist das Spiegelbild. Es kommt direkt hinter hochladen:
Ok(bilanz.text(&name, "hochgeladen")) }// Holt eine Datei oder einen ganzen Ordner vom Server, samt Rechten. fn runter<'a>( strom: &'a mut AsyncFtpStream, 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 ftp_eintraege(strom, 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 { let mut quelle = strom.retr_as_stream(von).await.map_err(als_text)?; let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?; tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?; quelle.finish().await.map_err(als_text)?; 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: Ftp, von: PathBuf, nach: PathBuf, ist_ordner: bool, rechte: u32, name: String, ) -> Result<String, String> { let mut strom = ftp.strom.lock().await; let mut bilanz = 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")) }
- Bei einem Ordner legen wir ihn lokal an und fragen den Server mit
ftp_eintraegeaus Etappe 9 nach seinem Inhalt. Für jeden Eintrag ruft sichrunterselbst auf. Ob ein Eintrag ein Ordner ist und welche Rechte er hat, steht dabei schon in der Liste des Servers. - Bei einer Datei öffnet
retr_as_streameinen Datenstrom vom Server, eine Art Rohrleitung, aus der die Datei herausfließt.tokio::io::copyleitet sie in eine neu angelegte lokale Datei.finishschließt die Leitung und holt die Bestätigung des Servers ab. - Zum Schluss setzen wir die Rechte mit
rechte_setzenaus Etappe 7. Weil die Funktion bei Ordnern erst nach dem ganzen Inhalt dort ankommt, kann ein schreibgeschützter Ordner uns nicht mehr in die Quere kommen.
retr_as_stream und finish ist die Verbindung
belegt. Würden wir vergessen, finish aufzurufen, würde der nächste Befehl an den
Server mit einem Fehler scheitern, weil die alte Leitung noch offen ist.cargo run
Die Liste der Warnungen ist noch länger geworden, jetzt auch mit
runter und herunterladen. Im nächsten Schritt kommen alle an Bord,
und dann verschwinden die Warnungen auf einen Schlag.F5 übers Wasser
Jetzt verbinden wir die Übertragungen mit der Taste F5. Wenn eine Übertragung fertig ist, soll ein Brief mit der Meldung zurückkommen:
FtpGelesen(Result<(PathBuf, Vec<Eintrag>), String>),Fertig(Result<String, String>),Beenden,
Wie die anderen Server-Antworten wird er ganz oben in update abgefangen:
Message::FtpGelesen(ergebnis) => return self.ftp_gelesen(ergebnis),Message::Fertig(ergebnis) => return self.fertig(ergebnis),andere => andere,
Im normalen match gibt kopieren ab jetzt eine Aufgabe zurück, und
Fertig kommt zu den schon erledigten Briefen:
Message::Kopieren => self.kopieren(),Message::Kopieren => return self.kopieren(),⋮Message::Verbunden(_) | Message::FtpGelesen(_) => {} // oben schon erledigtMessage::Verbunden(_) | Message::FtpGelesen(_) | Message::Fertig(_) => {} // oben erledigt
Wenn eine Übertragung fertig ist, zeigen wir die Meldung und lesen beide Panels neu ein. Das
linke ist immer lokal. Das rechte kann ein Server sein, und dann muss das Neueinlesen im
Hintergrund passieren. Zwei neue Funktionen hinter ftp_gelesen:
Err(fehler) => self.meldung = format!("Der Server sagt: {}", fehler), } Task::none() }// Eine Übertragung oder Änderung auf dem Server ist fertig. fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> { self.meldung = match ergebnis { Ok(text) => text, Err(fehler) => 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.ftp { Some(ftp) => Task::perform( ftp_lesen(ftp.clone(), self.rechts.ordner.clone()), Message::FtpGelesen, ), None => { self.rechts.neu_lesen(); Task::none() } } }
Und nun der Umbau von kopieren. Die Funktion liefert jetzt eine
Task, die Sperre aus Etappe 9 fällt weg, und vor dem bisherigen Kopieren
entscheidet sie, in welche Richtung die Fracht geht:
// Kopiert die markierte Datei vom aktiven Panel in den Ordner des anderen Panels.fn kopieren(&mut self) { if self.rechts.ftp.is_some() { self.meldung = String::from("Fracht übers Netz lernt der Fährmann in Etappe 10."); return; } let (quelle, ziel) = self.quelle_und_ziel(); let Some(eintrag) = quelle.markierter_eintrag() else { return; }; if eintrag.name == ".." { return; }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 von = quelle.ordner.join(&name); let nach = ziel.ordner.join(&name); let zielordner = ziel.ordner.display().to_string();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.ftp.clone(); let server_quelle = quelle.ftp.clone(); if schon_da { self.meldung = format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner); return Task::none(); } if let Some(ftp) = server_ziel { self.meldung = format!("Lade „{}“ hoch …", name); return Task::perform(hochladen(ftp, von, nach, name), Message::Fertig); } if let Some(ftp) = server_quelle { self.meldung = format!("Lade „{}“ herunter …", name); let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name); return Task::perform(aufgabe, Message::Fertig); } // Beide Panels auf diesem Rechner: kopieren wie bisher⋮self.inaktives_panel().neu_lesen(); }self.inaktives_panel().neu_lesen(); Task::none() }
- Die Pfade baut jetzt
unterpfad, damit sie auf dem Server mit/geschrieben werden. - Schon da? Statt auf der Festplatte nachzuschauen, fragen wir die Liste
des Zielpanels. Das funktioniert für beide Ufer gleich.
.any(…)fragt: „Gibt es irgendeinen Eintrag mit diesem Namen?“ - Ziel ist der Server? Dann hochladen, im Hintergrund.
- Quelle ist der Server? Dann herunterladen.
- Sonst sind beide Panels lokal, und der alte Code darunter kopiert wie bisher.
Wir klonen die Verbindungen mit ftp.clone(), solange wir die Panels noch
ausleihen. Danach dürfen wir wieder self.meldung ändern: das bekannte „erst
herauslesen, dann ändern“ aus Etappe 5.
cargo run
Verbinde dich mit F2 und Enter. Wechsle mit Tab
nach links, setz den Balken auf einen Ordner mit Unterordnern und drück F5: Unten
steht kurz „Lade … hoch“, dann die Bilanz, und rechts erscheint der Ordner auf dem Server.
Schau ins Terminal des Servers: Dort siehst du jede einzelne Datei ankommen, und auch die
SITE CHMOD-Befehle. Dann der Rückweg: Geh nach rechts, markier etwas auf dem
Server und drück F5. Links taucht es auf. Mit ls -l kannst du
nachprüfen, dass die Rechte mitgereist sind.
Rechte auf dem Server ändern
Zum Schluss soll der Rechte-Dialog auch auf dem Server funktionieren. Der Dialog muss sich dafür merken, ob die Datei dort liegt:
auswahl: usize, // 0 bis 8, welcher der neun Schalter gerade markiert istftp: Option<Ftp>, // Some, wenn die Datei auf dem Server liegt}
In rechte_zeigen fällt die Sperre aus Etappe 9 weg, der Pfad entsteht mit
unterpfad, und die Verbindung wandert in den Dialog:
fn rechte_zeigen(&mut self) {if self.rechts.ftp.is_some() { self.meldung = String::from("Rechte auf dem Server ändern lernt der Fährmann in Etappe 10."); return; }let panel = self.aktives_panel();⋮name: eintrag.name.clone(),pfad: panel.ordner.join(&eintrag.name),pfad: panel.unterpfad(&eintrag.name),⋮auswahl: 0,ftp: panel.ftp.clone(),};
Beim Übernehmen entscheidet der Dialog: Liegt die Datei auf dem Server, schickt er die neuen
Rechte im Hintergrund dorthin. Sonst geht es weiter wie bisher. Dafür liefert auch
rechte_anwenden jetzt eine Aufgabe:
fn rechte_anwenden(&mut self) { let Some(dialog) = self.dialog.take() else { return; };fn rechte_anwenden(&mut self) -> Task<Message> { let Some(dialog) = self.dialog.take() else { return Task::none(); }; if let Some(ftp) = dialog.ftp { self.meldung = format!("Setze die Rechte von „{}“ …", dialog.name); let aufgabe = ftp_rechte_setzen(ftp, dialog.pfad, dialog.rechte, dialog.name); return Task::perform(aufgabe, Message::Fertig); }⋮self.links.neu_lesen();self.rechts.neu_lesen(); }self.rechts.neu_lesen(); Task::none() }
Mit if let Some(ftp) = dialog.ftp nehmen wir die Verbindung aus dem Dialog
heraus. Den Rest des Dialogs (Pfad, Rechte, Name) dürfen wir danach trotzdem noch benutzen,
denn Rust erlaubt, einzelne Felder aus einem struct herauszunehmen, das uns
gehört. Die Aufgabe muss in update auch zurückgegeben werden:
Message::Oeffnen => self.rechte_anwenden(),Message::Oeffnen => return self.rechte_anwenden(),
Fehlt nur noch die Funktion, die mit dem Server spricht, hinter
herunterladen:
Ok(bilanz.text(&name, "heruntergeladen")) }// Ändert die Rechte einer Datei oder eines Ordners auf dem Server. async fn ftp_rechte_setzen(ftp: Ftp, pfad: PathBuf, rechte: u32, name: String) -> Result<String, String> { let mut strom = ftp.strom.lock().await; let befehl = format!("CHMOD {:03o} {}", rechte, pfad_text(&pfad)); match strom.site(befehl).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)), } }
Wieder SITE CHMOD, nur diesmal mit ehrlicher Antwort: Lehnt der Server ab, wird
das ein Err, und die Meldungszeile sagt es. Weil fertig danach das
rechte Panel neu einliest, siehst du die neuen Rechte sofort in der Liste.
cargo run
Verbinde dich, setz rechts den Balken auf eine Datei auf dem Server, drück
F9, schalte mit der Leertaste ein paar Rechte um und drück Enter. Die
Meldungszeile bestätigt, und in der Liste stehen die neuen Rechte. Im Server-Terminal siehst
du den SITE CHMOD-Befehl. Der Fährmann kann jetzt alles, was ein FTP-Client
können muss.Der komplette Code nach Etappe 10
Hier ist der vollständige Stand nach Etappe 10. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.
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"] }
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 suppaftp::list::{File, PosixPexQuery};
use suppaftp::tokio::AsyncFtpStream;
use suppaftp::types::FileType;
use tokio::sync::Mutex;
use iced::widget::{column, container, operation, row, stack, text, text_input};
use iced::keyboard::{self, Key, key::Named};
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);
// 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()
}
// 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
}
// 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
ftp: Option<Ftp>, // 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 {
host: String,
port: String,
benutzer: String,
passwort: String,
}
// Welches der vier Eingabefelder ist gemeint?
#[derive(Debug, Clone, Copy)]
enum Feld {
Host,
Port,
Benutzer,
Passwort,
}
// Eine offene FTP-Verbindung. Arc und Mutex erlauben, dass mehrere Aufgaben im
// Hintergrund sie benutzen, ohne sich gegenseitig in die Quere zu kommen.
#[derive(Clone)]
struct Ftp {
strom: Arc<Mutex<AsyncFtpStream>>,
name: String, // zum Beispiel "fahrgast@localhost"
}
// Nachrichten müssen sich mit {:?} ausgeben lassen. Die Verbindung selbst kann das
// nicht, also schreiben wir es für Ftp von Hand.
impl fmt::Debug for Ftp {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Ftp({})", self.name)
}
}
#[derive(Debug, Clone)]
enum Message {
Hoch,
Runter,
Oeffnen,
Wechseln,
Kopieren,
RechteZeigen,
PfeilLinks,
PfeilRechts,
Umschalten,
Abbrechen,
VerbindenZeigen,
Eingabe(Feld, String),
Verbinden,
Verbunden(Result<(Ftp, PathBuf, Vec<Eintrag>), String>),
FtpGelesen(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,
}
}
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::FtpGelesen(ergebnis) => return self.ftp_gelesen(ergebnis),
Message::Fertig(ergebnis) => return self.fertig(ergebnis),
andere => andere,
};
// Ist der Verbindungsdialog offen, kümmert er sich um die Tasten
if let Some(zugang) = &mut self.verbindungsdialog {
match message {
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::Wechseln => return operation::focus_next(),
Message::Verbinden => return self.verbinden(),
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 => {} // ohne Dialog ohne Bedeutung
Message::Verbunden(_) | Message::FtpGelesen(_) | Message::Fertig(_) => {} // oben erledigt
Message::Beenden => return iced::exit(),
}
Task::none()
}
// Abonniert die Tastatur: Jeder Tastendruck geht durch die Funktion taste.
fn subscription(&self) -> Subscription<Message> {
keyboard::listen().filter_map(taste)
}
// 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.ftp {
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 {
host: String::from("localhost"),
port: String::from("2121"),
benutzer: String::from("fahrgast"),
passwort: String::from("geheim"),
});
operation::focus("host")
}
// 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);
Task::perform(ftp_verbinden(zugang), Message::Verbunden)
}
// Die Antwort auf den Verbindungsaufbau ist da.
fn verbunden(&mut self, ergebnis: Result<(Ftp, PathBuf, Vec<Eintrag>), String>) -> Task<Message> {
match ergebnis {
Ok((ftp, ordner, eintraege)) => {
self.meldung = format!("Verbunden mit {}. F2 trennt die Verbindung.", ftp.name);
self.rechts = Panel::entfernt(ftp, ordner, eintraege);
self.aktiv = Seite::Rechts;
}
Err(fehler) => self.meldung = format!("Verbindung fehlgeschlagen: {}", fehler),
}
Task::none()
}
// Ein Ordner vom Server ist gelesen. Server-Ordner zeigt immer das rechte Panel.
fn ftp_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.meldung = format!("Der Server sagt: {}", fehler),
}
Task::none()
}
// Eine Übertragung oder Änderung auf dem Server ist fertig.
fn fertig(&mut self, ergebnis: Result<String, String>) -> Task<Message> {
self.meldung = match ergebnis {
Ok(text) => text,
Err(fehler) => 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.ftp {
Some(ftp) => Task::perform(
ftp_lesen(ftp.clone(), self.rechts.ordner.clone()),
Message::FtpGelesen,
),
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.ftp {
Some(ftp) => match panel.ziel_ordner() {
Some(ziel) => Task::perform(ftp_lesen(ftp.clone(), ziel), Message::FtpGelesen),
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,
ftp: panel.ftp.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.ftp {
self.meldung = format!("Setze die Rechte von „{}“ …", dialog.name);
let aufgabe = ftp_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 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.ftp.clone();
let server_quelle = quelle.ftp.clone();
if schon_da {
self.meldung = format!("„{}“ gibt es in {} schon. Nichts überschrieben.", name, zielordner);
return Task::none();
}
if let Some(ftp) = server_ziel {
self.meldung = format!("Lade „{}“ hoch …", name);
return Task::perform(hochladen(ftp, von, nach, name), Message::Fertig);
}
if let Some(ftp) = server_quelle {
self.meldung = format!("Lade „{}“ herunter …", name);
let aufgabe = herunterladen(ftp, von, nach, ist_ordner, rechte, name);
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 meldungszeile = container(text(self.meldung.as_str()).color(WEISS))
.width(Fill)
.padding([0, 6])
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(SCHWARZ)),
..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 {
stack![hauptteil, container(verbindung_ansicht(zugang)).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
ftp: Option<Ftp>, // 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,
ftp: None,
}
}
// Legt ein Panel an, das einen Ordner auf dem Server zeigt.
fn entfernt(ftp: Ftp, ordner: PathBuf, eintraege: Vec<Eintrag>) -> Self {
Panel {
ordner,
eintraege,
cursor: 0,
erste_zeile: 0,
ftp: 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.ftp.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.ftp.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.ftp {
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<(Ftp, 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 = Ftp {
strom: Arc::new(Mutex::new(strom)),
name: format!("{}@{}", zugang.benutzer, zugang.host),
};
Ok((ftp, PathBuf::from(start), eintraege))
}
// Liest einen Ordner auf dem Server.
async fn ftp_lesen(ftp: Ftp, pfad: PathBuf) -> Result<(PathBuf, Vec<Eintrag>), String> {
let mut strom = ftp.strom.lock().await;
let mut eintraege = ftp_eintraege(&mut strom, &pfad_text(&pfad)).await?;
liste_fertigmachen(&mut eintraege, pfad.parent().is_some());
Ok((pfad, eintraege))
}
// 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
}
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()
}
// 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 AsyncFtpStream,
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.mkdir(nach).await.map_err(als_text)?;
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 {
let mut datei = tokio::fs::File::open(von).await.map_err(als_text)?;
strom.put_file(nach, &mut datei).await.map_err(als_text)?;
bilanz.dateien += 1;
}
// Rechte mitnehmen. Nicht jeder Server kann das, darum nur zählen, nicht abbrechen.
let befehl = format!("CHMOD {:03o} {}", rechte_lesen(&info), nach);
if strom.site(befehl).await.is_err() {
bilanz.ohne_rechte += 1;
}
Ok(())
})
}
// Lädt hoch und meldet das Ergebnis.
async fn hochladen(ftp: Ftp, von: PathBuf, nach: PathBuf, name: String) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = 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 AsyncFtpStream,
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 ftp_eintraege(strom, 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 {
let mut quelle = strom.retr_as_stream(von).await.map_err(als_text)?;
let mut datei = tokio::fs::File::create(nach).await.map_err(als_text)?;
tokio::io::copy(&mut quelle, &mut datei).await.map_err(als_text)?;
quelle.finish().await.map_err(als_text)?;
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: Ftp,
von: PathBuf,
nach: PathBuf,
ist_ordner: bool,
rechte: u32,
name: String,
) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let mut bilanz = 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 ftp_rechte_setzen(ftp: Ftp, pfad: PathBuf, rechte: u32, name: String) -> Result<String, String> {
let mut strom = ftp.strom.lock().await;
let befehl = format!("CHMOD {:03o} {}", rechte, pfad_text(&pfad));
match strom.site(befehl).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('.', ",")
}
// 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(zugang: &Zugang) -> Element<'_, Message> {
let inhalt = column![
text("Mit einem Server verbinden"),
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),
]
.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::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,
}
}
Pin<Box<dyn Future<…> + Send + 'a>>, und
ein type-Name macht den langen Typ handlich. Generische Funktionen wie
als_text<E: Display> arbeiten mit vielen Typen. Rechte überträgt FTP mit
SITE CHMOD, das nicht jeder Server kann, darum zählen wir Fehlschläge, statt
abzubrechen. Und mit .any(…) fragt man eine Liste, ob irgendein Eintrag
passt.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.
- Server ohne CHMOD: Starte deinen Testserver einmal ohne
-w. Was passiert jetzt beim Hochladen? Und beim Ändern von Rechten? - Herunterladen ins Leere: Was passiert, wenn du einen Ordner herunterlädst und links ein schreibgeschützter Ordner offen ist? Probier es mit dem Rechte-Dialog aus.
- Wie groß war die Fracht? Erweitere die
Bilanzum die Zahl der übertragenen Bytes und zeig sie mitgroesse_textan. Tipp:put_fileundtokio::io::copyliefern beide die Bytes.
Lösungen anzeigen
1. Ohne -w darf der Fahrgast gar nichts schreiben. Schon das Anlegen des Ordners
oder das Hochladen der ersten Datei scheitert, und der Fährmann meldet „Hochladen von …
fehlgeschlagen“ mit der Begründung des Servers (meist 550 Not enough privileges).
Auch neue Rechte lehnt der Server ab. Denk daran, den Server danach wieder mit
-w zu starten.
2. Schon das Anlegen des Ordners scheitert mit Permission denied, und die
Meldungszeile sagt es. Weil wir bei Fehlern sofort mit ? abbrechen, bleibt
nichts halb Fertiges zurück. Gib dem Ordner danach seine Schreibrechte zurück.
3. Ein neues Feld bytes: u64 in Bilanz. In hoch:
bilanz.bytes += strom.put_file(nach, &mut datei).await.map_err(als_text)?;.
In runter: bilanz.bytes += tokio::io::copy(…).await.map_err(als_text)?;.
In text dann groesse_text(self.bytes) mit in die Meldung.
Etappe 10 geschafft 🚢
Die Fähre fährt mit Fracht: Dateien und ganze Ordner reisen in beide Richtungen, und die Rechte reisen mit, solange der Server mitspielt. Der Fährmann ist jetzt ein vollwertiger FTP-Client. In der nächsten Etappe bekommt die Fähre ein Schloss: Mit SFTP geht alles verschlüsselt über die Leitung, auch das Passwort.
Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Etappe ein Stück schneller fertig wird.
☕ Kaffee spendieren