Ein einziges Dateisystem, mit Absicht: exFAT koennen Windows und Linux beide
von Haus aus, es kennt keine Besitzrechte auf dem Datentraeger und hat anders
als FAT32 keine 4-GB-Grenze je Datei. Die Auswahl faellt damit weg - eine
Entscheidung weniger, wenn es schnell gehen muss.
Neu in der Oberflaeche:
Taste v Uebersicht der Laufwerke mit Zustand
Enter darauf haengt am Host ein und startet den Commander - links das
Laufwerk, rechts der eigene Rechner
n / u / l anlegen, aushaengen, loeschen
Optionsmaske Mehrfachauswahl statt Groessenangabe
--transfer NAME dasselbe auf der Kommandozeile, mehrfach moeglich
Am laufenden System durchgespielt: Laufwerk angelegt, vom Host ein Skript
hineingelegt, Wiederherstellung damit gestartet, im Gast gelesen, ausgefuehrt
und geschrieben, Wiederherstellung verworfen - das Laufwerk lebte samt
Ergebnis weiter.
Zwei Rechtefallen dabei gefunden und geschlossen:
Der Gast konnte lesen, aber nicht schreiben. exFAT hat keine Besitzrechte
auf der Platte, die entstehen beim Einhaengen - mit der Vorgabe gehoert
alles root, und der root eines unprivilegierten Containers ist auf dem Host
uid 100000. Wird jetzt mit umask=0000 eingehaengt.
Ein durchgereichtes Laufwerk galt als "am Host eingehaengt", weil beim
Container genau das der Fall ist. Wer das sah, haette es aushaengen und dem
laufenden Gast den Boden wegziehen koennen. Die Belegung wird jetzt auch aus
Container-Konfigurationen gelesen, der Gast hat Vorrang in der Anzeige, und
Aushaengen wird abgelehnt, solange jemand darauf arbeitet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""Bildschirm fuer die Transfer-Laufwerke.
|
|
|
|
Bewusst knapp gehalten: im Notfall soll niemand Parameter im Kopf haben.
|
|
Anlegen, oeffnen, aushaengen, loeschen - vier Tasten.
|
|
|
|
Wird aus pvesnap-recovery (Taste v) und aus pvesnap-explorer (F2) heraus
|
|
benutzt, damit man von beiden Seiten an dieselben Laufwerke kommt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import curses
|
|
import os
|
|
from datetime import datetime
|
|
|
|
from .curses_util import (C_DIM, C_FOOTER, C_HEADER, C_MARK, C_OK, C_SEL, C_WARN,
|
|
confirm, fill, is_enter, is_escape, keybar, message,
|
|
prompt, put, read_key)
|
|
from .transfer import (DEFAULT_SIZE, TransferError, create, formatter_missing,
|
|
host_mount, host_umount, human_bytes, load, remove)
|
|
from .util import truncate
|
|
|
|
KEYS = [
|
|
("Enter", "Oeffnen", "Oeffn."),
|
|
("n", "Neu", "Neu"),
|
|
("u", "Aushaengen", "Aush."),
|
|
("l", "Loeschen", "Losch."),
|
|
("r", "Aktualisieren", "Aktual."),
|
|
("q", "Zurueck", "Zurueck"),
|
|
]
|
|
|
|
|
|
def _wrap(text, width):
|
|
words, lines, current = str(text).split(), [], ""
|
|
for word in words:
|
|
if len(current) + len(word) + 1 > width:
|
|
lines.append(current)
|
|
current = word
|
|
else:
|
|
current = (current + " " + word).strip()
|
|
if current:
|
|
lines.append(current)
|
|
return lines
|
|
|
|
|
|
def _pages(win, lines, title, footer="beliebige Taste = weiter"):
|
|
height, width = win.getmaxyx()
|
|
win.erase()
|
|
fill(win, 0, " " + title, curses.color_pair(C_HEADER) | curses.A_BOLD)
|
|
for row, (text, attr) in enumerate(lines[: height - 4]):
|
|
put(win, row + 2, 2, truncate(text, width - 4), attr)
|
|
fill(win, height - 1, " " + footer, curses.color_pair(C_FOOTER))
|
|
win.refresh()
|
|
return read_key(win)
|
|
|
|
|
|
def _new(win):
|
|
"""Ein Laufwerk anlegen. Nur zwei Fragen - mehr braucht es nicht."""
|
|
fehlt = formatter_missing()
|
|
if fehlt:
|
|
_pages(win, [(line, curses.color_pair(C_WARN)) for line in fehlt.split("\n")],
|
|
"Geht hier nicht")
|
|
return
|
|
|
|
name = prompt(win, "Name (kurz, klein, z.B. dumps)")
|
|
if not name:
|
|
return
|
|
size = prompt(win, "Groesse", DEFAULT_SIZE)
|
|
if size is None:
|
|
return
|
|
note = prompt(win, "Wofuer ist das? (optional)", "")
|
|
if note is None:
|
|
note = ""
|
|
|
|
height, _ = win.getmaxyx()
|
|
lines = []
|
|
|
|
def progress(text):
|
|
lines.append(text)
|
|
fill(win, height - 2, " " + text, curses.color_pair(C_SEL))
|
|
win.refresh()
|
|
|
|
try:
|
|
volume = create(name, size, note=note, progress=progress)
|
|
except TransferError as exc:
|
|
_pages(win, [(line, curses.color_pair(C_WARN))
|
|
for line in _wrap(str(exc), 74)], "Fehlgeschlagen")
|
|
return
|
|
message(win, "%r angelegt - im Gast als Datentraeger %s"
|
|
% (volume.name, volume.label))
|
|
|
|
|
|
def _open(win, volume, browse):
|
|
"""Am Host einhaengen und den Commander darauf loslassen."""
|
|
try:
|
|
path = host_mount(volume.name)
|
|
except TransferError as exc:
|
|
_pages(win, [(line, curses.color_pair(C_WARN))
|
|
for line in _wrap(str(exc), 74)], "Nicht moeglich")
|
|
return
|
|
if browse is None:
|
|
_pages(win, [("Eingehaengt unter:", 0), ("", 0),
|
|
(" " + path, curses.color_pair(C_MARK))],
|
|
"Transfer-Laufwerk %s" % volume.name)
|
|
return
|
|
try:
|
|
browse(win, path, volume)
|
|
finally:
|
|
host_umount(volume.name)
|
|
|
|
|
|
def screen(win, browse=None):
|
|
"""Die Uebersicht. `browse(win, pfad, laufwerk)` oeffnet den Commander."""
|
|
index = 0
|
|
volumes = []
|
|
reload_ = True
|
|
|
|
while True:
|
|
if reload_:
|
|
volumes = load()
|
|
reload_ = False
|
|
index = max(0, min(index, len(volumes) - 1))
|
|
|
|
height, width = win.getmaxyx()
|
|
win.erase()
|
|
fill(win, 0, " Transfer-Laufwerke - Austausch mit der Wiederherstellung",
|
|
curses.color_pair(C_HEADER) | curses.A_BOLD)
|
|
|
|
if not volumes:
|
|
put(win, 3, 3, "Noch kein Transfer-Laufwerk angelegt.", curses.A_BOLD)
|
|
for row, text in enumerate([
|
|
"",
|
|
"Ein Transfer-Laufwerk ist eine Abbilddatei, die du hier in Ruhe",
|
|
"befuellst und im Notfall nur noch auswaehlst. Sie gehoert zu",
|
|
"keiner Maschine und ueberlebt jede Wiederherstellung.",
|
|
"",
|
|
"Mit n eines anlegen.",
|
|
]):
|
|
put(win, 4 + row, 3, text, curses.color_pair(C_DIM))
|
|
fehlt = formatter_missing()
|
|
if fehlt:
|
|
for row, line in enumerate(fehlt.split("\n")):
|
|
put(win, 11 + row, 3, line, curses.color_pair(C_WARN))
|
|
else:
|
|
put(win, 2, 2, "%-14s %8s %8s %-24s %s"
|
|
% ("Name", "Groesse", "belegt", "Zustand", "Wofuer"),
|
|
curses.A_BOLD | curses.A_UNDERLINE)
|
|
rows = max(1, height - 6)
|
|
top = max(0, min(index - rows + 1, len(volumes) - rows))
|
|
for row in range(min(rows, len(volumes) - top)):
|
|
volume = volumes[top + row]
|
|
attr = curses.color_pair(C_SEL) if top + row == index else 0
|
|
if not attr and volume.state == "guest":
|
|
attr = curses.color_pair(C_MARK)
|
|
elif not attr and volume.state == "host":
|
|
attr = curses.color_pair(C_OK)
|
|
fill(win, row + 3, " %-14s %8s %8s %-24s %s"
|
|
% (truncate(volume.name, 14), human_bytes(volume.size),
|
|
human_bytes(volume.used_bytes),
|
|
volume.state_text, truncate(volume.note, 28)), attr)
|
|
|
|
for offset, line in enumerate(keybar(width, KEYS)):
|
|
fill(win, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
|
|
win.refresh()
|
|
|
|
key = read_key(win)
|
|
current = volumes[index] if volumes else None
|
|
|
|
if key in ("q", "Q") or is_escape(key):
|
|
return
|
|
if key in ("n", "N"):
|
|
_new(win)
|
|
reload_ = True
|
|
elif key in ("r", "R"):
|
|
reload_ = True
|
|
elif key == curses.KEY_UP and volumes:
|
|
index = (index - 1) % len(volumes)
|
|
elif key == curses.KEY_DOWN and volumes:
|
|
index = (index + 1) % len(volumes)
|
|
elif current is None:
|
|
continue
|
|
elif is_enter(key):
|
|
_open(win, current, browse)
|
|
reload_ = True
|
|
elif key in ("u", "U"):
|
|
try:
|
|
host_umount(current.name)
|
|
except TransferError as exc:
|
|
message(win, str(exc), error=True)
|
|
reload_ = True
|
|
elif key in ("l", "L"):
|
|
if confirm(win, "%r endgueltig loeschen? Der Inhalt ist dann weg."
|
|
% current.name):
|
|
try:
|
|
remove(current.name)
|
|
except TransferError as exc:
|
|
message(win, str(exc), error=True)
|
|
reload_ = True
|
|
|
|
|
|
def choose(win, preselected=()):
|
|
"""Mehrfachauswahl fuer die Optionsmaske. Liefert eine Liste von Namen."""
|
|
volumes = [v for v in load()]
|
|
if not volumes:
|
|
message(win, "Es gibt noch keine Transfer-Laufwerke. In der Uebersicht "
|
|
"mit v anlegen.", error=True)
|
|
return list(preselected)
|
|
|
|
chosen = set(preselected)
|
|
index = 0
|
|
while True:
|
|
height, width = win.getmaxyx()
|
|
win.erase()
|
|
fill(win, 0, " Welche Transfer-Laufwerke anhaengen?",
|
|
curses.color_pair(C_HEADER) | curses.A_BOLD)
|
|
put(win, 2, 2, "%-3s %-14s %8s %-24s %s"
|
|
% ("", "Name", "Groesse", "Zustand", "Wofuer"),
|
|
curses.A_BOLD | curses.A_UNDERLINE)
|
|
|
|
for row, volume in enumerate(volumes[: height - 7]):
|
|
marker = "[x]" if volume.name in chosen else "[ ]"
|
|
attr = curses.color_pair(C_SEL) if row == index else 0
|
|
if not volume.free and not attr:
|
|
attr = curses.color_pair(C_WARN)
|
|
fill(win, row + 3, " %-3s %-14s %8s %-24s %s"
|
|
% (marker, truncate(volume.name, 14), human_bytes(volume.size),
|
|
volume.state_text, truncate(volume.note, 26)), attr)
|
|
|
|
put(win, height - 4, 3,
|
|
"Belegte Laufwerke lassen sich nicht anhaengen - erst dort aushaengen.",
|
|
curses.color_pair(C_DIM))
|
|
for offset, line in enumerate(keybar(width, [
|
|
("Leertaste", "Aus/Abwaehlen", "Waehlen"),
|
|
("Enter", "Uebernehmen", "OK"),
|
|
("q", "Abbrechen", "Abbr.")])):
|
|
fill(win, height - 2 + offset, " " + line, curses.color_pair(C_FOOTER))
|
|
win.refresh()
|
|
|
|
key = read_key(win)
|
|
if key == curses.KEY_UP:
|
|
index = (index - 1) % len(volumes)
|
|
elif key == curses.KEY_DOWN:
|
|
index = (index + 1) % len(volumes)
|
|
elif key == " ":
|
|
volume = volumes[index]
|
|
if volume.name in chosen:
|
|
chosen.discard(volume.name)
|
|
elif not volume.free:
|
|
message(win, "%r ist gerade %s." % (volume.name, volume.state_text),
|
|
error=True)
|
|
else:
|
|
chosen.add(volume.name)
|
|
elif is_enter(key):
|
|
return [v.name for v in volumes if v.name in chosen]
|
|
elif is_escape(key) or key in ("q", "Q"):
|
|
return list(preselected)
|
|
|
|
|
|
def summary(names):
|
|
"""Kurzfassung fuer die Optionsmaske."""
|
|
if not names:
|
|
return "keine"
|
|
return ", ".join(names)
|
|
|
|
|
|
def describe(volume):
|
|
"""Mehrzeilige Beschreibung fuer Detailansichten."""
|
|
lines = ["Transfer-Laufwerk %s" % volume.name,
|
|
" Abbild : %s" % volume.image,
|
|
" Groesse : %s (belegt: %s)" % (human_bytes(volume.size),
|
|
human_bytes(volume.used_bytes)),
|
|
" Im Gast : Datentraeger %s" % volume.label,
|
|
" Zustand : %s" % volume.state_text]
|
|
if volume.note:
|
|
lines.append(" Wofuer : %s" % volume.note)
|
|
if volume.created:
|
|
lines.append(" Angelegt : %s"
|
|
% datetime.fromtimestamp(volume.created).strftime("%d.%m.%Y %H:%M"))
|
|
return lines
|