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