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/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