dwipe 1.0.5__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwipe/DeviceInfo.py +492 -0
- dwipe/DiskWipe.py +880 -0
- dwipe/PersistentState.py +195 -0
- dwipe/Utils.py +199 -0
- dwipe/WipeJob.py +1243 -0
- dwipe/WipeJobFuture.py +245 -0
- dwipe/main.py +19 -853
- dwipe-2.0.0.dist-info/METADATA +407 -0
- dwipe-2.0.0.dist-info/RECORD +13 -0
- dwipe/PowerWindow.py +0 -740
- dwipe-1.0.5.dist-info/METADATA +0 -72
- dwipe-1.0.5.dist-info/RECORD +0 -8
- {dwipe-1.0.5.dist-info → dwipe-2.0.0.dist-info}/WHEEL +0 -0
- {dwipe-1.0.5.dist-info → dwipe-2.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-1.0.5.dist-info → dwipe-2.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/DiskWipe.py
ADDED
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DiskWipe class - Main application controller/singleton
|
|
3
|
+
"""
|
|
4
|
+
# pylint: disable=invalid-name,broad-exception-caught,line-too-long
|
|
5
|
+
# pylint: disable=too-many-nested-blocks,too-many-instance-attributes
|
|
6
|
+
# pylint: disable=too-many-branches,too-many-statements,too-many-locals
|
|
7
|
+
# pylint: disable=protected-access,too-many-return-statements
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
import shutil
|
|
13
|
+
import curses as cs
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
from console_window import (ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
|
|
16
|
+
IncrementalSearchBar, InlineConfirmation, Theme,
|
|
17
|
+
Screen, ScreenStack, Context)
|
|
18
|
+
|
|
19
|
+
from .WipeJob import WipeJob
|
|
20
|
+
from .DeviceInfo import DeviceInfo
|
|
21
|
+
from .Utils import Utils
|
|
22
|
+
from .PersistentState import PersistentState
|
|
23
|
+
|
|
24
|
+
# Screen constants
|
|
25
|
+
MAIN_ST = 0
|
|
26
|
+
HELP_ST = 1
|
|
27
|
+
LOG_ST = 2
|
|
28
|
+
THEME_ST = 3
|
|
29
|
+
SCREEN_NAMES = ('MAIN', 'HELP', 'HISTORY', 'THEMES')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DiskWipe:
|
|
33
|
+
"""Main application controller and UI manager"""
|
|
34
|
+
singleton = None
|
|
35
|
+
|
|
36
|
+
def __init__(self, opts=None):
|
|
37
|
+
DiskWipe.singleton = self
|
|
38
|
+
self.opts = opts if opts else SimpleNamespace(debug=0, dry_run=False)
|
|
39
|
+
self.DB = bool(self.opts.debug)
|
|
40
|
+
self.mounts_lines = None
|
|
41
|
+
self.partitions = {} # a dict of namespaces keyed by name
|
|
42
|
+
self.wids = None
|
|
43
|
+
self.job_cnt = 0
|
|
44
|
+
self.exit_when_no_jobs = False
|
|
45
|
+
|
|
46
|
+
# Per-device throttle tracking (keyed by device_path)
|
|
47
|
+
# Values: {'mbps': int, 'auto': bool} or None
|
|
48
|
+
self.device_throttles = {}
|
|
49
|
+
|
|
50
|
+
self.prev_filter = '' # string
|
|
51
|
+
self.filter = None # compiled pattern
|
|
52
|
+
self.pick_is_running = False
|
|
53
|
+
self.dev_info = None
|
|
54
|
+
|
|
55
|
+
self.win, self.spin = None, None
|
|
56
|
+
self.screens, self.stack = [], None
|
|
57
|
+
|
|
58
|
+
# Inline confirmation handler
|
|
59
|
+
self.confirmation = InlineConfirmation()
|
|
60
|
+
|
|
61
|
+
# Incremental search bar for filtering
|
|
62
|
+
self.filter_bar = IncrementalSearchBar(
|
|
63
|
+
on_change=self._on_filter_change,
|
|
64
|
+
on_accept=self._on_filter_accept,
|
|
65
|
+
on_cancel=self._on_filter_cancel
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Initialize persistent state
|
|
69
|
+
self.persistent_state = PersistentState()
|
|
70
|
+
self.check_preqreqs()
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def check_preqreqs():
|
|
74
|
+
"""Check that needed programs are installed."""
|
|
75
|
+
ok = True
|
|
76
|
+
for prog in 'lsblk'.split():
|
|
77
|
+
if shutil.which(prog) is None:
|
|
78
|
+
ok = False
|
|
79
|
+
print(f'ERROR: cannot find {prog!r} on $PATH')
|
|
80
|
+
if not ok:
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
def _start_wipe(self):
|
|
84
|
+
"""Start the wipe job after confirmation"""
|
|
85
|
+
if self.confirmation.partition_name and self.confirmation.partition_name in self.partitions:
|
|
86
|
+
part = self.partitions[self.confirmation.partition_name]
|
|
87
|
+
# Clear any previous verify failure message when starting wipe
|
|
88
|
+
if hasattr(part, 'verify_failed_msg'):
|
|
89
|
+
delattr(part, 'verify_failed_msg')
|
|
90
|
+
part.job = WipeJob.start_job(f'/dev/{part.name}',
|
|
91
|
+
part.size_bytes, opts=self.opts)
|
|
92
|
+
self.job_cnt += 1
|
|
93
|
+
self.set_state(part, to='0%')
|
|
94
|
+
# Clear confirmation state
|
|
95
|
+
self.confirmation.cancel()
|
|
96
|
+
self.win.passthrough_mode = False # Disable passthrough
|
|
97
|
+
|
|
98
|
+
def _start_verify(self):
|
|
99
|
+
"""Start the verify job after confirmation"""
|
|
100
|
+
if self.confirmation.partition_name and self.confirmation.partition_name in self.partitions:
|
|
101
|
+
part = self.partitions[self.confirmation.partition_name]
|
|
102
|
+
# Clear any previous verify failure message when starting verify
|
|
103
|
+
if hasattr(part, 'verify_failed_msg'):
|
|
104
|
+
delattr(part, 'verify_failed_msg')
|
|
105
|
+
part.job = WipeJob.start_verify_job(f'/dev/{part.name}',
|
|
106
|
+
part.size_bytes, opts=self.opts)
|
|
107
|
+
self.job_cnt += 1
|
|
108
|
+
# Clear confirmation state
|
|
109
|
+
self.confirmation.cancel()
|
|
110
|
+
self.win.passthrough_mode = False # Disable passthrough
|
|
111
|
+
|
|
112
|
+
def test_state(self, ns, to=None):
|
|
113
|
+
"""Test if OK to set state of partition"""
|
|
114
|
+
return self.dev_info.set_one_state(self.partitions, ns, test_to=to)
|
|
115
|
+
|
|
116
|
+
def set_state(self, ns, to=None):
|
|
117
|
+
"""Set state of partition"""
|
|
118
|
+
result = self.dev_info.set_one_state(self.partitions, ns, to=to)
|
|
119
|
+
|
|
120
|
+
# Save lock state changes to persistent state
|
|
121
|
+
if result and to in ('Lock', 'Unlk'):
|
|
122
|
+
self.persistent_state.set_device_locked(ns, to == 'Lock')
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
def do_key(self, key):
|
|
127
|
+
"""Handle keyboard input"""
|
|
128
|
+
if self.exit_when_no_jobs:
|
|
129
|
+
# Check if all jobs are done and exit
|
|
130
|
+
jobs_running = sum(1 for part in self.partitions.values() if part.job)
|
|
131
|
+
if jobs_running == 0:
|
|
132
|
+
self.win.stop_curses()
|
|
133
|
+
os.system('clear; stty sane')
|
|
134
|
+
sys.exit(0)
|
|
135
|
+
return True # continue running
|
|
136
|
+
|
|
137
|
+
if not key:
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
# Handle filter bar input
|
|
141
|
+
if self.filter_bar.is_active:
|
|
142
|
+
if self.filter_bar.handle_key(key):
|
|
143
|
+
return None # Key was handled by filter bar
|
|
144
|
+
|
|
145
|
+
# Handle confirmation mode input (wipe or verify)
|
|
146
|
+
if self.confirmation.active:
|
|
147
|
+
result = self.confirmation.handle_key(key)
|
|
148
|
+
if result == 'confirmed':
|
|
149
|
+
if self.confirmation.confirm_type == 'wipe':
|
|
150
|
+
self._start_wipe()
|
|
151
|
+
elif self.confirmation.confirm_type == 'verify':
|
|
152
|
+
self._start_verify()
|
|
153
|
+
elif result == 'cancelled':
|
|
154
|
+
self.confirmation.cancel()
|
|
155
|
+
self.win.passthrough_mode = False
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
if key in (cs.KEY_ENTER, 10): # Handle ENTER
|
|
159
|
+
# ENTER pops screen (returns from help, etc.)
|
|
160
|
+
if hasattr(self.spin, 'stack') and self.spin.stack.curr.num != MAIN_ST:
|
|
161
|
+
self.spin.stack.pop()
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if key in self.spin.keys:
|
|
165
|
+
_ = self.spin.do_key(key, self.win)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def get_keys_line(self):
|
|
169
|
+
"""Generate the header line showing available keys"""
|
|
170
|
+
# Get actions for the currently picked context
|
|
171
|
+
_, pick_actions = self.get_actions(None)
|
|
172
|
+
|
|
173
|
+
line = ''
|
|
174
|
+
for key, verb in pick_actions.items():
|
|
175
|
+
if key[0].lower() == verb[0].lower():
|
|
176
|
+
# First letter matches - use [x]verb format
|
|
177
|
+
line += f' [{verb[0]}]{verb[1:]}'
|
|
178
|
+
else:
|
|
179
|
+
# First letter doesn't match - use key:verb format
|
|
180
|
+
line += f' {key}:{verb}'
|
|
181
|
+
line += ' [S]top' if self.job_cnt > 0 else ''
|
|
182
|
+
line = f'{line:<20} '
|
|
183
|
+
line += self.filter_bar.get_display_string(prefix=' /') or ' /'
|
|
184
|
+
# Show mode spinner with key
|
|
185
|
+
line += f' [m]ode={self.opts.wipe_mode}'
|
|
186
|
+
# Show passes spinner with key
|
|
187
|
+
line += f' [P]ass={self.opts.passes}'
|
|
188
|
+
# Show verification percentage spinner with key
|
|
189
|
+
line += f' [V]pct={self.opts.verify_pct}%'
|
|
190
|
+
line += ' '
|
|
191
|
+
if self.opts.dry_run:
|
|
192
|
+
line += ' DRY-RUN'
|
|
193
|
+
line += ' [h]ist [t]heme ?:help [q]uit'
|
|
194
|
+
return line[1:]
|
|
195
|
+
|
|
196
|
+
def get_actions(self, part):
|
|
197
|
+
"""Determine the type of the current line and available commands."""
|
|
198
|
+
name, actions = '', {}
|
|
199
|
+
ctx = self.win.get_picked_context()
|
|
200
|
+
if ctx and hasattr(ctx, 'partition'):
|
|
201
|
+
part = ctx.partition
|
|
202
|
+
name = part.name
|
|
203
|
+
self.pick_is_running = bool(part.job)
|
|
204
|
+
if self.test_state(part, to='STOP'):
|
|
205
|
+
actions['s'] = 'stop'
|
|
206
|
+
elif self.test_state(part, to='0%'):
|
|
207
|
+
actions['w'] = 'wipe'
|
|
208
|
+
# Can verify:
|
|
209
|
+
# 1. Anything with wipe markers (states 's' or 'W')
|
|
210
|
+
# 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
|
|
211
|
+
# 3. Unmarked partitions without filesystems (has parent, state '-' or '^', no fstype)
|
|
212
|
+
# 4. Only if verify_pct > 0
|
|
213
|
+
# This prevents verifying filesystems which is nonsensical
|
|
214
|
+
verify_pct = getattr(self.opts, 'verify_pct', 0)
|
|
215
|
+
if not part.job and verify_pct > 0:
|
|
216
|
+
if part.state in ('s', 'W'):
|
|
217
|
+
actions['v'] = 'verify'
|
|
218
|
+
elif part.state in ('-', '^'):
|
|
219
|
+
# For whole disks (no parent), only allow verify if no partitions have filesystems
|
|
220
|
+
# For partitions, only allow if no filesystem
|
|
221
|
+
if not part.parent:
|
|
222
|
+
# Whole disk - check if any child partitions have filesystems
|
|
223
|
+
has_typed_partitions = any(
|
|
224
|
+
p.parent == part.name and p.fstype
|
|
225
|
+
for p in self.partitions.values()
|
|
226
|
+
)
|
|
227
|
+
if not has_typed_partitions:
|
|
228
|
+
actions['v'] = 'verify'
|
|
229
|
+
elif not part.fstype:
|
|
230
|
+
# Partition without filesystem
|
|
231
|
+
actions['v'] = 'verify'
|
|
232
|
+
if self.test_state(part, to='Lock'):
|
|
233
|
+
actions['l'] = 'lock'
|
|
234
|
+
if self.test_state(part, to='Unlk'):
|
|
235
|
+
actions['l'] = 'unlk'
|
|
236
|
+
return name, actions
|
|
237
|
+
|
|
238
|
+
def _on_filter_change(self, text):
|
|
239
|
+
"""Callback when filter text changes - compile and apply filter in real-time"""
|
|
240
|
+
text = text.strip()
|
|
241
|
+
if not text:
|
|
242
|
+
self.filter = None
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
self.filter = re.compile(text, re.IGNORECASE)
|
|
247
|
+
except Exception:
|
|
248
|
+
# Invalid regex - keep previous filter active
|
|
249
|
+
pass
|
|
250
|
+
|
|
251
|
+
def _on_filter_accept(self, text):
|
|
252
|
+
"""Callback when filter is accepted (ENTER pressed)"""
|
|
253
|
+
self.prev_filter = text.strip()
|
|
254
|
+
self.win.passthrough_mode = False
|
|
255
|
+
# Move to top when filter is applied
|
|
256
|
+
if text.strip():
|
|
257
|
+
self.win.pick_pos = 0
|
|
258
|
+
|
|
259
|
+
def _on_filter_cancel(self, original_text):
|
|
260
|
+
"""Callback when filter is cancelled (ESC pressed)"""
|
|
261
|
+
# Restore original filter
|
|
262
|
+
if original_text:
|
|
263
|
+
self.filter = re.compile(original_text, re.IGNORECASE)
|
|
264
|
+
self.prev_filter = original_text
|
|
265
|
+
else:
|
|
266
|
+
self.filter = None
|
|
267
|
+
self.prev_filter = ''
|
|
268
|
+
self.win.passthrough_mode = False
|
|
269
|
+
|
|
270
|
+
def main_loop(self):
|
|
271
|
+
"""Main event loop"""
|
|
272
|
+
|
|
273
|
+
# Create screen instances
|
|
274
|
+
self.screens = {
|
|
275
|
+
MAIN_ST: MainScreen(self),
|
|
276
|
+
HELP_ST: HelpScreen(self),
|
|
277
|
+
LOG_ST: HistoryScreen(self),
|
|
278
|
+
THEME_ST: ThemeScreen(self),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# Create console window with custom pick highlighting
|
|
282
|
+
win_opts = ConsoleWindowOpts(
|
|
283
|
+
head_line=True,
|
|
284
|
+
body_rows=200,
|
|
285
|
+
head_rows=4,
|
|
286
|
+
# keys=self.spin.keys ^ other_keys,
|
|
287
|
+
pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
|
|
288
|
+
ctrl_c_terminates=False,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
self.win = ConsoleWindow(opts=win_opts)
|
|
292
|
+
# Initialize screen stack
|
|
293
|
+
self.stack = ScreenStack(self.win, None, SCREEN_NAMES, self.screens)
|
|
294
|
+
|
|
295
|
+
spin = self.spin = OptionSpinner(stack=self.stack)
|
|
296
|
+
spin.default_obj = self.opts
|
|
297
|
+
spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
|
|
298
|
+
spin.add_key('slowdown_stop', 'L - stop if disk slows Nx', vals=[16, 64, 256, 0, 4])
|
|
299
|
+
spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
|
|
300
|
+
spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
|
|
301
|
+
spin.add_key('passes', 'P - wipe pass count', vals=[1, 2, 4])
|
|
302
|
+
spin.add_key('wipe_mode', 'm - wipe mode', vals=['Zero', 'Zero+V', 'Rand', 'Rand+V'])
|
|
303
|
+
spin.add_key('confirmation', 'c - confirmation mode', vals=['YES', 'yes', 'device', 'Y', 'y'])
|
|
304
|
+
|
|
305
|
+
spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
|
|
306
|
+
spin.add_key('screen_escape', 'ESC- back one screen',
|
|
307
|
+
keys=[10,27,cs.KEY_ENTER], genre='action')
|
|
308
|
+
spin.add_key('main_escape', 'ESC - clear filter',
|
|
309
|
+
keys=27, genre='action', scope=MAIN_ST)
|
|
310
|
+
spin.add_key('wipe', 'w - wipe device', genre='action')
|
|
311
|
+
spin.add_key('verify', 'v - verify device', genre='action')
|
|
312
|
+
spin.add_key('stop', 's - stop wipe', genre='action')
|
|
313
|
+
spin.add_key('lock', 'l - lock/unlock disk', genre='action')
|
|
314
|
+
spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
|
|
315
|
+
spin.add_key('help', '? - show help screen', genre='action')
|
|
316
|
+
spin.add_key('history', 'h - show wipe history', genre='action')
|
|
317
|
+
spin.add_key('filter', '/ - filter devices by regex', genre='action')
|
|
318
|
+
spin.add_key('theme_screen', 't - theme picker', genre='action', scope=MAIN_ST)
|
|
319
|
+
spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
|
|
320
|
+
spin.add_key('header_mode', '_ - header style', vals=['Underline', 'Reverse', 'Off'])
|
|
321
|
+
self.opts.theme = ''
|
|
322
|
+
self.persistent_state.restore_updated_opts(self.opts)
|
|
323
|
+
Theme.set(self.opts.theme)
|
|
324
|
+
self.win.set_handled_keys(self.spin.keys)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# self.opts.name = "[hit 'n' to enter name]"
|
|
328
|
+
|
|
329
|
+
# Initialize device info and pick range before first draw
|
|
330
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
|
|
331
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
332
|
+
self.dev_info = info
|
|
333
|
+
pick_range = info.get_pick_range()
|
|
334
|
+
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
335
|
+
|
|
336
|
+
check_devices_mono = time.monotonic()
|
|
337
|
+
while True:
|
|
338
|
+
# Draw current screen
|
|
339
|
+
current_screen = self.screens[self.stack.curr.num]
|
|
340
|
+
current_screen.draw_screen()
|
|
341
|
+
self.win.render()
|
|
342
|
+
|
|
343
|
+
seconds = 3.0
|
|
344
|
+
_ = self.do_key(self.win.prompt(seconds=seconds))
|
|
345
|
+
|
|
346
|
+
# Handle actions using perform_actions
|
|
347
|
+
self.stack.perform_actions(spin)
|
|
348
|
+
|
|
349
|
+
if time.monotonic() - check_devices_mono > (seconds * 0.95):
|
|
350
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
|
|
351
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
352
|
+
self.dev_info = info
|
|
353
|
+
# Update pick range to highlight NAME through SIZE fields
|
|
354
|
+
pick_range = info.get_pick_range()
|
|
355
|
+
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
356
|
+
check_devices_mono = time.monotonic()
|
|
357
|
+
|
|
358
|
+
# Save any persistent state changes
|
|
359
|
+
self.persistent_state.save_updated_opts(self.opts)
|
|
360
|
+
self.persistent_state.sync()
|
|
361
|
+
|
|
362
|
+
self.win.clear()
|
|
363
|
+
|
|
364
|
+
class DiskWipeScreen(Screen):
|
|
365
|
+
""" TBD """
|
|
366
|
+
app: DiskWipe
|
|
367
|
+
|
|
368
|
+
def screen_escape_ACTION(self):
|
|
369
|
+
""" return to main screen """
|
|
370
|
+
self.app.stack.pop()
|
|
371
|
+
|
|
372
|
+
class MainScreen(DiskWipeScreen):
|
|
373
|
+
"""Main device list screen"""
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def draw_screen(self):
|
|
377
|
+
"""Draw the main device list"""
|
|
378
|
+
app = self.app
|
|
379
|
+
|
|
380
|
+
def wanted(name):
|
|
381
|
+
return not app.filter or app.filter.search(name)
|
|
382
|
+
|
|
383
|
+
app.win.set_pick_mode(True)
|
|
384
|
+
|
|
385
|
+
# First pass: process jobs and collect visible partitions
|
|
386
|
+
visible_partitions = []
|
|
387
|
+
for name, partition in app.partitions.items():
|
|
388
|
+
partition.line = None
|
|
389
|
+
if partition.job:
|
|
390
|
+
if partition.job.done:
|
|
391
|
+
# Join with timeout to avoid UI freeze if thread is stuck in blocking I/O
|
|
392
|
+
partition.job.thread.join(timeout=5.0)
|
|
393
|
+
if partition.job.thread.is_alive():
|
|
394
|
+
# Thread didn't exit cleanly - continue anyway to avoid UI freeze
|
|
395
|
+
# Leave job attached so we can try again next refresh
|
|
396
|
+
partition.mounts = ['⚠ Thread stuck, retrying...']
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
# Check if this was a standalone verify job or a wipe job
|
|
400
|
+
is_verify_only = getattr(partition.job, 'is_verify_only', False)
|
|
401
|
+
|
|
402
|
+
if is_verify_only:
|
|
403
|
+
# Standalone verification completed or stopped
|
|
404
|
+
if partition.job.do_abort:
|
|
405
|
+
# Verification was stopped - read marker to get previous status
|
|
406
|
+
marker = WipeJob.read_marker_buffer(partition.name)
|
|
407
|
+
prev_status = getattr(marker, 'verify_status', None) if marker else None
|
|
408
|
+
if prev_status == 'pass':
|
|
409
|
+
was = '✓'
|
|
410
|
+
elif prev_status == 'fail':
|
|
411
|
+
was = '✗'
|
|
412
|
+
else:
|
|
413
|
+
was = '-'
|
|
414
|
+
partition.mounts = [f'Stopped verification, was {was}']
|
|
415
|
+
else:
|
|
416
|
+
# Verification completed successfully
|
|
417
|
+
verify_result = partition.job.verify_result or "unknown"
|
|
418
|
+
partition.mounts = [f'Verified: {verify_result}']
|
|
419
|
+
|
|
420
|
+
# Check if this was an unmarked disk/partition (no existing marker)
|
|
421
|
+
# Whole disks (no parent) or partitions without filesystems
|
|
422
|
+
was_unmarked = partition.dflt == '-' and (not partition.parent or not partition.fstype)
|
|
423
|
+
|
|
424
|
+
# Check if verification passed (may include debug info)
|
|
425
|
+
verify_passed = verify_result in ('zeroed', 'random') or verify_result.startswith(('zeroed', 'random'))
|
|
426
|
+
|
|
427
|
+
# If this was an unmarked disk that passed verification,
|
|
428
|
+
# update state to 'W' as if it had been wiped
|
|
429
|
+
if was_unmarked and verify_passed:
|
|
430
|
+
partition.state = 'W'
|
|
431
|
+
partition.dflt = 'W'
|
|
432
|
+
partition.wiped_this_session = True # Show green
|
|
433
|
+
# Clear any previous verify failure
|
|
434
|
+
if hasattr(partition, 'verify_failed_msg'):
|
|
435
|
+
delattr(partition, 'verify_failed_msg')
|
|
436
|
+
# If unmarked partition failed verification, set persistent error
|
|
437
|
+
# NOTE: Only for unmarked disks - marked disks just show ✗ in marker
|
|
438
|
+
elif was_unmarked and not verify_passed:
|
|
439
|
+
error_msg = '⚠ VERIFY FAILED: Not wiped w/ Zero or Rand'
|
|
440
|
+
partition.mounts = [error_msg]
|
|
441
|
+
partition.verify_failed_msg = error_msg
|
|
442
|
+
else:
|
|
443
|
+
# Marked disk or other case - clear verify failure
|
|
444
|
+
if hasattr(partition, 'verify_failed_msg'):
|
|
445
|
+
delattr(partition, 'verify_failed_msg')
|
|
446
|
+
|
|
447
|
+
# Log the verify operation
|
|
448
|
+
if partition.job.verify_start_mono:
|
|
449
|
+
elapsed = time.monotonic() - partition.job.verify_start_mono
|
|
450
|
+
|
|
451
|
+
# Determine if verification passed or failed
|
|
452
|
+
if verify_result in ('zeroed', 'random') or verify_result.startswith('random ('):
|
|
453
|
+
result = 'OK'
|
|
454
|
+
verify_detail = None
|
|
455
|
+
elif verify_result == 'error':
|
|
456
|
+
result = 'FAIL'
|
|
457
|
+
verify_detail = 'error'
|
|
458
|
+
elif verify_result == 'skipped':
|
|
459
|
+
result = 'skip'
|
|
460
|
+
verify_detail = None
|
|
461
|
+
else:
|
|
462
|
+
# Failed verification - extract reason
|
|
463
|
+
result = 'FAIL'
|
|
464
|
+
# verify_result like "not-wiped (non-zero at 22K)" or "not-wiped (max=5.2%)"
|
|
465
|
+
if '(' in verify_result:
|
|
466
|
+
verify_detail = verify_result.split('(')[1].rstrip(')')
|
|
467
|
+
else:
|
|
468
|
+
verify_detail = verify_result
|
|
469
|
+
|
|
470
|
+
Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, elapsed,
|
|
471
|
+
uuid=partition.uuid, verify_result=verify_detail)
|
|
472
|
+
app.job_cnt -= 1
|
|
473
|
+
# Reset state back to default (was showing percentage during verify)
|
|
474
|
+
# Unless we just updated it above for unmarked verified disk
|
|
475
|
+
if partition.state.endswith('%'):
|
|
476
|
+
partition.state = partition.dflt
|
|
477
|
+
partition.job = None
|
|
478
|
+
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
479
|
+
else:
|
|
480
|
+
# Wipe job completed (with or without auto-verify)
|
|
481
|
+
# Check if stopped during verify phase (after successful write)
|
|
482
|
+
if partition.job.do_abort and partition.job.verify_phase:
|
|
483
|
+
# Wipe completed but verification was stopped
|
|
484
|
+
to = 'W'
|
|
485
|
+
app.set_state(partition, to=to)
|
|
486
|
+
partition.dflt = to
|
|
487
|
+
partition.wiped_this_session = True
|
|
488
|
+
# Read marker to get previous verification status
|
|
489
|
+
marker = WipeJob.read_marker_buffer(partition.name)
|
|
490
|
+
prev_status = getattr(marker, 'verify_status', None) if marker else None
|
|
491
|
+
if prev_status == 'pass':
|
|
492
|
+
was = '✓'
|
|
493
|
+
elif prev_status == 'fail':
|
|
494
|
+
was = '✗'
|
|
495
|
+
else:
|
|
496
|
+
was = '-'
|
|
497
|
+
partition.mounts = [f'Stopped verification, was {was}']
|
|
498
|
+
else:
|
|
499
|
+
# Normal wipe completion or stopped during write
|
|
500
|
+
to = 's' if partition.job.do_abort else 'W'
|
|
501
|
+
app.set_state(partition, to=to)
|
|
502
|
+
partition.dflt = to
|
|
503
|
+
# Mark as wiped in this session (for green highlighting)
|
|
504
|
+
if to == 'W':
|
|
505
|
+
partition.wiped_this_session = True
|
|
506
|
+
partition.mounts = []
|
|
507
|
+
app.job_cnt -= 1
|
|
508
|
+
# Log the wipe operation
|
|
509
|
+
elapsed = time.monotonic() - partition.job.start_mono
|
|
510
|
+
result = 'stopped' if partition.job.do_abort else 'completed'
|
|
511
|
+
# Extract base mode (remove '+V' suffix if present)
|
|
512
|
+
mode = app.opts.wipe_mode.replace('+V', '')
|
|
513
|
+
# Calculate percentage if stopped
|
|
514
|
+
pct = None
|
|
515
|
+
if partition.job.do_abort and partition.job.total_size > 0:
|
|
516
|
+
pct = int((partition.job.total_written / partition.job.total_size) * 100)
|
|
517
|
+
# Only pass label/fstype for stopped wipes (not completed)
|
|
518
|
+
if result == 'stopped':
|
|
519
|
+
Utils.log_wipe(partition.name, partition.size_bytes, mode, result, elapsed,
|
|
520
|
+
uuid=partition.uuid, label=partition.label, fstype=partition.fstype, pct=pct)
|
|
521
|
+
else:
|
|
522
|
+
Utils.log_wipe(partition.name, partition.size_bytes, mode, result, elapsed,
|
|
523
|
+
uuid=partition.uuid, pct=pct)
|
|
524
|
+
|
|
525
|
+
# Log auto-verify if it happened (verify_result will be set)
|
|
526
|
+
if partition.job.verify_result and partition.job.verify_start_mono:
|
|
527
|
+
verify_elapsed = time.monotonic() - partition.job.verify_start_mono
|
|
528
|
+
verify_result = partition.job.verify_result
|
|
529
|
+
|
|
530
|
+
# Determine if verification passed or failed
|
|
531
|
+
if verify_result in ('zeroed', 'random') or verify_result.startswith('random ('):
|
|
532
|
+
result = 'OK'
|
|
533
|
+
verify_detail = None
|
|
534
|
+
elif verify_result == 'error':
|
|
535
|
+
result = 'FAIL'
|
|
536
|
+
verify_detail = 'error'
|
|
537
|
+
elif verify_result == 'skipped':
|
|
538
|
+
result = 'skip'
|
|
539
|
+
verify_detail = None
|
|
540
|
+
else:
|
|
541
|
+
# Failed verification - extract reason
|
|
542
|
+
result = 'FAIL'
|
|
543
|
+
# verify_result like "not-wiped (non-zero at 22K)" or "not-wiped (max=5.2%)"
|
|
544
|
+
if '(' in verify_result:
|
|
545
|
+
verify_detail = verify_result.split('(')[1].rstrip(')')
|
|
546
|
+
else:
|
|
547
|
+
verify_detail = verify_result
|
|
548
|
+
|
|
549
|
+
Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, verify_elapsed,
|
|
550
|
+
uuid=partition.uuid, verify_result=verify_detail)
|
|
551
|
+
|
|
552
|
+
if partition.job.exception:
|
|
553
|
+
app.win.stop_curses()
|
|
554
|
+
print('\n\n\n========== ALERT =========\n')
|
|
555
|
+
print(f' FAILED: wipe {repr(partition.name)}')
|
|
556
|
+
print(partition.job.exception)
|
|
557
|
+
input('\n\n===== Press ENTER to continue ====> ')
|
|
558
|
+
app.win._start_curses()
|
|
559
|
+
|
|
560
|
+
partition.job = None
|
|
561
|
+
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
562
|
+
if partition.job:
|
|
563
|
+
elapsed, pct, rate, until = partition.job.get_status()
|
|
564
|
+
|
|
565
|
+
# FLUSH goes in mounts column, not state
|
|
566
|
+
if pct.startswith('FLUSH'):
|
|
567
|
+
partition.state = partition.dflt # Keep default state (s, W, etc)
|
|
568
|
+
if rate and until:
|
|
569
|
+
partition.mounts = [f'{pct} {elapsed} -{until} {rate}']
|
|
570
|
+
else:
|
|
571
|
+
partition.mounts = [f'{pct} {elapsed}']
|
|
572
|
+
else:
|
|
573
|
+
partition.state = pct
|
|
574
|
+
slowdown = partition.job.max_slowdown_ratio # temp?
|
|
575
|
+
stall = partition.job.max_stall_secs # temp
|
|
576
|
+
partition.mounts = [f'{elapsed} -{until} {rate} ÷{slowdown} 𝚫{Utils.ago_str(stall)}']
|
|
577
|
+
|
|
578
|
+
if partition.parent and partition.parent in app.partitions and (
|
|
579
|
+
app.partitions[partition.parent].state == 'Lock'):
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
if wanted(name) or partition.job:
|
|
583
|
+
visible_partitions.append(partition)
|
|
584
|
+
|
|
585
|
+
# Re-infer parent states (like 'Busy') after updating child job states
|
|
586
|
+
DeviceInfo.set_all_states(app.partitions)
|
|
587
|
+
|
|
588
|
+
# Build mapping of parent -> last visible child
|
|
589
|
+
parent_last_child = {}
|
|
590
|
+
for partition in visible_partitions:
|
|
591
|
+
if partition.parent:
|
|
592
|
+
parent_last_child[partition.parent] = partition.name
|
|
593
|
+
|
|
594
|
+
# Second pass: display visible partitions with tree characters and Context
|
|
595
|
+
prev_disk = None
|
|
596
|
+
for partition in visible_partitions:
|
|
597
|
+
# Add separator line between disk groups (unless in dense mode)
|
|
598
|
+
if not app.opts.dense and partition.parent is None and prev_disk is not None:
|
|
599
|
+
# Add dimmed separator line between disks
|
|
600
|
+
separator = '─' * app.win.get_pad_width()
|
|
601
|
+
app.win.add_body(separator, attr=cs.A_DIM, context=Context(genre='DECOR'))
|
|
602
|
+
|
|
603
|
+
if partition.parent is None:
|
|
604
|
+
prev_disk = partition.name
|
|
605
|
+
|
|
606
|
+
is_last_child = False
|
|
607
|
+
if partition.parent and partition.parent in parent_last_child:
|
|
608
|
+
is_last_child = bool(parent_last_child[partition.parent] == partition.name)
|
|
609
|
+
|
|
610
|
+
partition.line, attr = app.dev_info.part_str(partition, is_last_child=is_last_child)
|
|
611
|
+
# Create context with partition reference
|
|
612
|
+
ctx = Context(genre='disk' if partition.parent is None else 'partition',
|
|
613
|
+
partition=partition)
|
|
614
|
+
app.win.add_body(partition.line, attr=attr, context=ctx)
|
|
615
|
+
|
|
616
|
+
# Show inline confirmation prompt if this is the partition being confirmed
|
|
617
|
+
if app.confirmation.active and app.confirmation.partition_name == partition.name:
|
|
618
|
+
# Build confirmation message
|
|
619
|
+
if app.confirmation.confirm_type == 'wipe':
|
|
620
|
+
msg = f'⚠️ WIPE {partition.name} ({Utils.human(partition.size_bytes)})'
|
|
621
|
+
else: # verify
|
|
622
|
+
msg = f'⚠️ VERIFY {partition.name} ({Utils.human(partition.size_bytes)}) - writes marker'
|
|
623
|
+
|
|
624
|
+
# Add mode-specific prompt
|
|
625
|
+
if app.confirmation.mode == 'Y':
|
|
626
|
+
msg += " - Press 'Y' or ESC"
|
|
627
|
+
elif app.confirmation.mode == 'y':
|
|
628
|
+
msg += " - Press 'y' or ESC"
|
|
629
|
+
elif app.confirmation.mode == 'YES':
|
|
630
|
+
msg += f" - Type 'YES': {app.confirmation.input_buffer}_"
|
|
631
|
+
elif app.confirmation.mode == 'yes':
|
|
632
|
+
msg += f" - Type 'yes': {app.confirmation.input_buffer}_"
|
|
633
|
+
elif app.confirmation.mode == 'device':
|
|
634
|
+
msg += f" - Type '{partition.name}': {app.confirmation.input_buffer}_"
|
|
635
|
+
|
|
636
|
+
# Position message at fixed column (reduced from 28 to 20)
|
|
637
|
+
msg = ' ' * 20 + msg
|
|
638
|
+
|
|
639
|
+
# Add confirmation message as DECOR (non-pickable)
|
|
640
|
+
app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
|
|
641
|
+
context=Context(genre='DECOR'))
|
|
642
|
+
|
|
643
|
+
app.win.add_fancy_header(app.get_keys_line(), mode=app.opts.header_mode)
|
|
644
|
+
|
|
645
|
+
app.win.add_header(app.dev_info.head_str, attr=cs.A_DIM)
|
|
646
|
+
_, col = app.win.head.pad.getyx()
|
|
647
|
+
pad = ' ' * (app.win.get_pad_width() - col)
|
|
648
|
+
app.win.add_header(pad, resume=True, attr=cs.A_DIM)
|
|
649
|
+
|
|
650
|
+
######################################### ACTIONS #####################
|
|
651
|
+
@staticmethod
|
|
652
|
+
def clear_hotswap_marker(part):
|
|
653
|
+
"""Clear the hot-swap marker (^) when user performs a hard action"""
|
|
654
|
+
if part.state == '^':
|
|
655
|
+
part.state = '-'
|
|
656
|
+
# Also update dflt so verify/wipe operations restore to '-' not '^'
|
|
657
|
+
part.dflt = '-'
|
|
658
|
+
# Also clear the newly_inserted flag
|
|
659
|
+
if hasattr(part, 'newly_inserted'):
|
|
660
|
+
delattr(part, 'newly_inserted')
|
|
661
|
+
|
|
662
|
+
def main_escape_ACTION(self):
|
|
663
|
+
""" Handle ESC clearing filter and move to top"""
|
|
664
|
+
app = self.app
|
|
665
|
+
app.prev_filter = ''
|
|
666
|
+
app.filter = None
|
|
667
|
+
app.filter_bar._text = '' # Also clear filter bar text
|
|
668
|
+
app.win.pick_pos = 0
|
|
669
|
+
|
|
670
|
+
def theme_screen_ACTION(self):
|
|
671
|
+
""" handle 't' from Main Screen """
|
|
672
|
+
self.app.stack.push(THEME_ST, self.app.win.pick_pos)
|
|
673
|
+
|
|
674
|
+
def quit_ACTION(self):
|
|
675
|
+
"""Handle quit action (q or x key pressed)"""
|
|
676
|
+
app = self.app
|
|
677
|
+
|
|
678
|
+
def stop_if_idle(part):
|
|
679
|
+
if part.state[-1] == '%':
|
|
680
|
+
if part.job and not part.job.done:
|
|
681
|
+
part.job.do_abort = True
|
|
682
|
+
return 1 if part.job else 0
|
|
683
|
+
|
|
684
|
+
def stop_all():
|
|
685
|
+
rv = 0
|
|
686
|
+
for part in app.partitions.values():
|
|
687
|
+
rv += stop_if_idle(part)
|
|
688
|
+
return rv
|
|
689
|
+
|
|
690
|
+
def exit_if_no_jobs():
|
|
691
|
+
if stop_all() == 0:
|
|
692
|
+
app.win.stop_curses()
|
|
693
|
+
os.system('clear; stty sane')
|
|
694
|
+
sys.exit(0)
|
|
695
|
+
|
|
696
|
+
app.exit_when_no_jobs = True
|
|
697
|
+
app.filter = re.compile('STOPPING', re.IGNORECASE)
|
|
698
|
+
app.prev_filter = 'STOPPING'
|
|
699
|
+
app.filter_bar._text = 'STOPPING' # Update filter bar display
|
|
700
|
+
exit_if_no_jobs()
|
|
701
|
+
|
|
702
|
+
def wipe_ACTION(self):
|
|
703
|
+
"""Handle 'w' key"""
|
|
704
|
+
app = self.app
|
|
705
|
+
if not app.pick_is_running:
|
|
706
|
+
ctx = app.win.get_picked_context()
|
|
707
|
+
if ctx and hasattr(ctx, 'partition'):
|
|
708
|
+
part = ctx.partition
|
|
709
|
+
if app.test_state(part, to='0%'):
|
|
710
|
+
self.clear_hotswap_marker(part)
|
|
711
|
+
app.confirmation.start('wipe', part.name, app.opts.confirmation)
|
|
712
|
+
app.win.passthrough_mode = True
|
|
713
|
+
|
|
714
|
+
def verify_ACTION(self):
|
|
715
|
+
"""Handle 'v' key"""
|
|
716
|
+
app = self.app
|
|
717
|
+
ctx = app.win.get_picked_context()
|
|
718
|
+
if ctx and hasattr(ctx, 'partition'):
|
|
719
|
+
part = ctx.partition
|
|
720
|
+
# Use get_actions() to ensure we use the same logic as the header display
|
|
721
|
+
_, actions = app.get_actions(part)
|
|
722
|
+
if 'v' in actions:
|
|
723
|
+
self.clear_hotswap_marker(part)
|
|
724
|
+
# Check if this is an unmarked disk/partition (potential data loss risk)
|
|
725
|
+
# Whole disks (no parent) or partitions without filesystems need confirmation
|
|
726
|
+
is_unmarked = part.state == '-' and (not part.parent or not part.fstype)
|
|
727
|
+
if is_unmarked:
|
|
728
|
+
# Require confirmation for unmarked partitions
|
|
729
|
+
app.confirmation.start('verify', part.name, app.opts.confirmation)
|
|
730
|
+
app.win.passthrough_mode = True
|
|
731
|
+
else:
|
|
732
|
+
# Marked partition - proceed directly
|
|
733
|
+
# Clear any previous verify failure message when starting new verify
|
|
734
|
+
if hasattr(part, 'verify_failed_msg'):
|
|
735
|
+
delattr(part, 'verify_failed_msg')
|
|
736
|
+
part.job = WipeJob.start_verify_job(f'/dev/{part.name}',
|
|
737
|
+
part.size_bytes, opts=app.opts)
|
|
738
|
+
app.job_cnt += 1
|
|
739
|
+
|
|
740
|
+
def stop_ACTION(self):
|
|
741
|
+
"""Handle 's' key"""
|
|
742
|
+
app = self.app
|
|
743
|
+
if app.pick_is_running:
|
|
744
|
+
ctx = app.win.get_picked_context()
|
|
745
|
+
if ctx and hasattr(ctx, 'partition'):
|
|
746
|
+
part = ctx.partition
|
|
747
|
+
if part.state[-1] == '%':
|
|
748
|
+
if part.job and not part.job.done:
|
|
749
|
+
part.job.do_abort = True
|
|
750
|
+
|
|
751
|
+
def stop_all_ACTION(self):
|
|
752
|
+
"""Handle 'S' key"""
|
|
753
|
+
app = self.app
|
|
754
|
+
for part in app.partitions.values():
|
|
755
|
+
if part.state[-1] == '%':
|
|
756
|
+
if part.job and not part.job.done:
|
|
757
|
+
part.job.do_abort = True
|
|
758
|
+
|
|
759
|
+
def lock_ACTION(self):
|
|
760
|
+
"""Handle 'l' key"""
|
|
761
|
+
app = self.app
|
|
762
|
+
ctx = app.win.get_picked_context()
|
|
763
|
+
if ctx and hasattr(ctx, 'partition'):
|
|
764
|
+
part = ctx.partition
|
|
765
|
+
self.clear_hotswap_marker(part)
|
|
766
|
+
app.set_state(part, 'Unlk' if part.state == 'Lock' else 'Lock')
|
|
767
|
+
|
|
768
|
+
def help_ACTION(self):
|
|
769
|
+
"""Handle '?' key - push help screen"""
|
|
770
|
+
app = self.app
|
|
771
|
+
if hasattr(app, 'spin') and hasattr(app.spin, 'stack'):
|
|
772
|
+
app.spin.stack.push(HELP_ST, app.win.pick_pos)
|
|
773
|
+
|
|
774
|
+
def history_ACTION(self):
|
|
775
|
+
"""Handle 'h' key - push history screen"""
|
|
776
|
+
app = self.app
|
|
777
|
+
if hasattr(app, 'spin') and hasattr(app.spin, 'stack'):
|
|
778
|
+
app.spin.stack.push(LOG_ST, app.win.pick_pos)
|
|
779
|
+
|
|
780
|
+
def filter_ACTION(self):
|
|
781
|
+
"""Handle '/' key - start incremental filter search"""
|
|
782
|
+
app = self.app
|
|
783
|
+
app.filter_bar.start(app.prev_filter)
|
|
784
|
+
app.win.passthrough_mode = True
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
class HelpScreen(DiskWipeScreen):
|
|
788
|
+
"""Help screen"""
|
|
789
|
+
|
|
790
|
+
def draw_screen(self):
|
|
791
|
+
"""Draw the help screen"""
|
|
792
|
+
app = self.app
|
|
793
|
+
spinner = self.get_spinner()
|
|
794
|
+
|
|
795
|
+
app.win.set_pick_mode(False)
|
|
796
|
+
if spinner:
|
|
797
|
+
spinner.show_help_nav_keys(app.win)
|
|
798
|
+
spinner.show_help_body(app.win)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
class HistoryScreen(DiskWipeScreen):
|
|
803
|
+
"""History/log screen showing wipe history"""
|
|
804
|
+
|
|
805
|
+
def draw_screen(self):
|
|
806
|
+
"""Draw the history screen"""
|
|
807
|
+
app = self.app
|
|
808
|
+
# spinner = self.get_spinner()
|
|
809
|
+
|
|
810
|
+
app.win.set_pick_mode(False)
|
|
811
|
+
|
|
812
|
+
# Add header
|
|
813
|
+
app.win.add_header('WIPE HISTORY (newest first)', attr=cs.A_BOLD)
|
|
814
|
+
app.win.add_header(' Press ESC to return', resume=True)
|
|
815
|
+
|
|
816
|
+
# Read and display log file in reverse order
|
|
817
|
+
log_path = Utils.get_log_path()
|
|
818
|
+
if log_path.exists():
|
|
819
|
+
try:
|
|
820
|
+
with open(log_path, 'r', encoding='utf-8') as f:
|
|
821
|
+
lines = f.readlines()
|
|
822
|
+
|
|
823
|
+
# Show in reverse order (newest first)
|
|
824
|
+
for line in reversed(lines):
|
|
825
|
+
app.win.put_body(line.rstrip())
|
|
826
|
+
except Exception as e:
|
|
827
|
+
app.win.put_body(f'Error reading log: {e}')
|
|
828
|
+
else:
|
|
829
|
+
app.win.put_body('No wipe history found.')
|
|
830
|
+
app.win.put_body('')
|
|
831
|
+
app.win.put_body(f'Log file will be created at: {log_path}')
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
class ThemeScreen(DiskWipeScreen):
|
|
835
|
+
"""Theme preview screen showing all available themes with color examples"""
|
|
836
|
+
prev_theme = ""
|
|
837
|
+
|
|
838
|
+
def draw_screen(self):
|
|
839
|
+
"""Draw the theme screen with color examples for all themes"""
|
|
840
|
+
app = self.app
|
|
841
|
+
|
|
842
|
+
app.win.set_pick_mode(False)
|
|
843
|
+
|
|
844
|
+
# Add header showing current theme
|
|
845
|
+
|
|
846
|
+
app.win.add_header(f'COLOR THEME: {app.opts.theme:^18}', attr=cs.A_BOLD)
|
|
847
|
+
app.win.add_header(' Press [t] to cycle themes, ESC to return', resume=True)
|
|
848
|
+
|
|
849
|
+
# Color purpose labels
|
|
850
|
+
color_labels = [
|
|
851
|
+
(Theme.DANGER, 'DANGER', 'Destructive operations (wipe prompts)'),
|
|
852
|
+
(Theme.SUCCESS, 'SUCCESS', 'Completed operations'),
|
|
853
|
+
(Theme.OLD_SUCCESS, 'OLD_SUCCESS', 'Older Completed operations'),
|
|
854
|
+
(Theme.WARNING, 'WARNING', 'Caution/stopped states'),
|
|
855
|
+
(Theme.INFO, 'INFO', 'Informational states'),
|
|
856
|
+
(Theme.EMPHASIS, 'EMPHASIS', 'Emphasized text'),
|
|
857
|
+
(Theme.ERROR, 'ERROR', 'Errors'),
|
|
858
|
+
(Theme.PROGRESS, 'PROGRESS', 'Progress indicators'),
|
|
859
|
+
(Theme.HOTSWAP, 'HOTSWAP', 'Newly inserted devices'),
|
|
860
|
+
]
|
|
861
|
+
|
|
862
|
+
# Display color examples for current theme
|
|
863
|
+
theme_info = Theme.THEMES[app.opts.theme]
|
|
864
|
+
_ = theme_info.get('name', app.opts.theme)
|
|
865
|
+
|
|
866
|
+
# Show color examples for this theme
|
|
867
|
+
for color_id, label, description in color_labels:
|
|
868
|
+
# Create line with colored block and description
|
|
869
|
+
line = f'{label:12} ████████ {description}'
|
|
870
|
+
attr = cs.color_pair(color_id)
|
|
871
|
+
app.win.add_body(line, attr=attr)
|
|
872
|
+
|
|
873
|
+
def spin_theme_ACTION(self):
|
|
874
|
+
""" TBD """
|
|
875
|
+
vals = Theme.list_all()
|
|
876
|
+
value = Theme.get_current()
|
|
877
|
+
idx = vals.index(value) if value in vals else -1
|
|
878
|
+
value = vals[(idx+1) % len(vals)] # choose next
|
|
879
|
+
Theme.set(value)
|
|
880
|
+
self.app.opts.theme = value
|