dwipe 2.0.2__py3-none-any.whl → 3.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 CHANGED
@@ -15,16 +15,18 @@ import threading
15
15
  import json
16
16
  import curses as cs
17
17
  from types import SimpleNamespace
18
+ from datetime import datetime
18
19
  from console_window import (ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
19
20
  IncrementalSearchBar, InlineConfirmation, Theme,
20
21
  Screen, ScreenStack, Context)
21
22
 
22
23
  from .WipeJob import WipeJob
23
24
  from .DeviceInfo import DeviceInfo
25
+ from .DeviceWorker import ProbeState
24
26
  from .Utils import Utils
25
27
  from .PersistentState import PersistentState
26
28
  from .StructuredLogger import StructuredLogger
27
- from .LsblkMonitor import LsblkMonitor
29
+ from .DeviceChangeMonitor import DeviceChangeMonitor
28
30
 
29
31
  # Screen constants
30
32
  MAIN_ST = 0
@@ -38,9 +40,26 @@ class DiskWipe:
38
40
  """Main application controller and UI manager"""
39
41
  singleton = None
40
42
 
41
- def __init__(self, opts=None):
43
+ def __init__(self, opts=None, persistent_state=None):
42
44
  DiskWipe.singleton = self
43
45
  self.opts = opts if opts else SimpleNamespace(debug=0)
46
+ # Use provided persistent_state or create a new one
47
+ self.persistent_state = persistent_state if persistent_state else PersistentState()
48
+ # Set defaults for command-line options (only if not provided)
49
+ if not hasattr(self.opts, 'wipe_mode'):
50
+ self.opts.wipe_mode = '+V'
51
+ if not hasattr(self.opts, 'passes'):
52
+ self.opts.passes = 1
53
+ if not hasattr(self.opts, 'verify_pct'):
54
+ self.opts.verify_pct = 1
55
+ if not hasattr(self.opts, 'port_serial'):
56
+ self.opts.port_serial = 'Auto'
57
+ if not hasattr(self.opts, 'slowdown_stop'):
58
+ self.opts.slowdown_stop = 64
59
+ if not hasattr(self.opts, 'stall_timeout'):
60
+ self.opts.stall_timeout = 60
61
+ if not hasattr(self.opts, 'dense'):
62
+ self.opts.dense = False
44
63
  self.mounts_lines = None
45
64
  self.partitions = {} # a dict of namespaces keyed by name
46
65
  self.wids = None
@@ -69,9 +88,6 @@ class DiskWipe:
69
88
  on_cancel=self._on_filter_cancel
70
89
  )
71
90
 
72
- # Initialize persistent state
73
- self.persistent_state = PersistentState()
74
-
75
91
  def _start_wipe(self):
76
92
  """Start the wipe job after confirmation"""
77
93
  if self.confirmation.identity and self.confirmation.identity in self.partitions:
@@ -81,7 +97,8 @@ class DiskWipe:
81
97
  delattr(part, 'verify_failed_msg')
82
98
 
83
99
  # Get the wipe type from user's choice
84
- wipe_type = self.confirmation.input_buffer.strip()
100
+ # ConsoleWindow already canonicalizes case, just strip the '*' recommendation marker
101
+ wipe_type = self.confirmation.input_buffer.strip().rstrip('*')
85
102
 
86
103
  # Check if it's a firmware wipe
87
104
  if wipe_type not in ('Zero', 'Rand'):
@@ -92,11 +109,14 @@ class DiskWipe:
92
109
  self.win.passthrough_mode = False
93
110
  return
94
111
 
95
- # Get command args from hw_caps
96
- command_args = part.hw_caps[wipe_type]
112
+ # Get command args for this wipe type
113
+ from .DrivePreChecker import DrivePreChecker
114
+ command_args = DrivePreChecker.get_wipe_command_args(wipe_type)
97
115
 
98
116
  # Import firmware task classes
99
- from .FirmwareWipeTask import NvmeWipeTask, SataWipeTask
117
+ from .FirmwareWipeTask import (NvmeWipeTask, SataWipeTask,
118
+ StandardPrecheckTask,
119
+ FirmwarePreVerifyTask, FirmwarePostVerifyTask)
100
120
 
101
121
  # Determine task type based on device name
102
122
  if part.name.startswith('nvme'):
@@ -105,7 +125,7 @@ class DiskWipe:
105
125
  task_class = SataWipeTask
106
126
 
107
127
  # Create firmware task
108
- task = task_class(
128
+ fw_task = task_class(
109
129
  device_path=f'/dev/{part.name}',
110
130
  total_size=part.size_bytes,
111
131
  opts=self.opts,
@@ -116,13 +136,39 @@ class DiskWipe:
116
136
  # Store wipe type for logging
117
137
  part.wipe_type = wipe_type
118
138
 
119
- # Create WipeJob with single firmware task
139
+ # Build task list with precheck, pre/post firmware verification
140
+ # (Software verify is not supported for firmware wipes)
141
+ precheck = StandardPrecheckTask(
142
+ device_path=f'/dev/{part.name}',
143
+ total_size=part.size_bytes,
144
+ opts=self.opts,
145
+ selected_wipe_type=wipe_type,
146
+ command_method=command_args
147
+ )
148
+
149
+ pre_verify = FirmwarePreVerifyTask(
150
+ device_path=f'/dev/{part.name}',
151
+ total_size=part.size_bytes,
152
+ opts=self.opts
153
+ )
154
+
155
+ post_verify = FirmwarePostVerifyTask(
156
+ device_path=f'/dev/{part.name}',
157
+ total_size=part.size_bytes,
158
+ opts=self.opts
159
+ )
160
+
161
+ tasks = [precheck, pre_verify, fw_task, post_verify]
162
+
163
+ # Create WipeJob with firmware task (and optional verify task)
120
164
  part.job = WipeJob(
121
165
  device_path=f'/dev/{part.name}',
122
166
  total_size=part.size_bytes,
123
167
  opts=self.opts,
124
- tasks=[task]
168
+ tasks=tasks
125
169
  )
170
+ # Firmware wipes (SATA secure erase, NVMe sanitize) write zeros
171
+ part.job.expected_pattern = "zeroed"
126
172
  part.job.thread = threading.Thread(target=part.job.run_tasks)
127
173
  part.job.thread.start()
128
174
 
@@ -152,6 +198,7 @@ class DiskWipe:
152
198
  part.size_bytes, opts=self.opts)
153
199
  self.job_cnt += 1
154
200
  self.set_state(part, to='0%')
201
+ part.hw_nopes, part.hw_caps = {}, {}
155
202
  finally:
156
203
  # Restore original wipe_mode
157
204
  self.opts.wipe_mode = old_wipe_mode
@@ -164,6 +211,14 @@ class DiskWipe:
164
211
  """Start the verify job after confirmation"""
165
212
  if self.confirmation.identity and self.confirmation.identity in self.partitions:
166
213
  part = self.partitions[self.confirmation.identity]
214
+
215
+ # Safety check: Firmware wipes have built-in verification - prevent manual verify
216
+ if self._is_firmware_wipe_marker(part):
217
+ part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
218
+ self.confirmation.cancel()
219
+ self.win.passthrough_mode = False
220
+ return
221
+
167
222
  # Clear any previous verify failure message when starting verify
168
223
  if hasattr(part, 'verify_failed_msg'):
169
224
  delattr(part, 'verify_failed_msg')
@@ -188,6 +243,38 @@ class DiskWipe:
188
243
 
189
244
  return result
190
245
 
246
+ def _is_firmware_wipe(self, part):
247
+ """Check if partition has an active firmware wipe job (unstoppable)."""
248
+ if not part.job or part.job.done:
249
+ return False
250
+ from .FirmwareWipeTask import FirmwareWipeTask
251
+ current_task = getattr(part.job, 'current_task', None)
252
+ return current_task and isinstance(current_task, FirmwareWipeTask)
253
+
254
+ def _is_firmware_wipe_marker(self, part):
255
+ """Check if partition's wipe marker indicates a firmware wipe.
256
+
257
+ Firmware wipes have modes like 'Crypto', 'Enhanced', 'Sanitize-Crypto', etc.
258
+ Software wipes have modes like 'Zero' or 'Rand'.
259
+ """
260
+ if part.state not in ('s', 'W'):
261
+ return False
262
+
263
+ marker = WipeJob.read_marker_buffer(part.name)
264
+ if not marker:
265
+ return False
266
+
267
+ # Check if mode is a firmware wipe (not 'Zero' or 'Rand')
268
+ mode = getattr(marker, 'mode', None)
269
+ return mode and mode not in ('Zero', 'Rand')
270
+
271
+ def _has_any_firmware_wipes(self):
272
+ """Check if any firmware wipes are currently running."""
273
+ for part in self.partitions.values():
274
+ if self._is_firmware_wipe(part):
275
+ return True
276
+ return False
277
+
191
278
  def do_key(self, key):
192
279
  """Handle keyboard input"""
193
280
  if self.exit_when_no_jobs:
@@ -253,15 +340,7 @@ class DiskWipe:
253
340
  line += ' [S]top' if self.job_cnt > 0 else ''
254
341
  line = f'{line:<20} '
255
342
  line += self.filter_bar.get_display_string(prefix=' /') or ' /'
256
- # Show mode spinner with key
257
- line += f' [m]ode={self.opts.wipe_mode}'
258
- # Show passes spinner with key
259
- line += f' [P]ass={self.opts.passes}'
260
- # Show verification percentage spinner with key
261
- line += f' [V]pct={self.opts.verify_pct}%'
262
- line += f' [p]ort={self.opts.port_serial}'
263
- # line += ' !:scan [h]ist [t]heme ?:help [q]uit'
264
- line += ' [h]ist [t]heme ?:help [q]uit'
343
+ line += ' [r]escan [h]ist [t]heme ?:help [q]uit'
265
344
  return line[1:]
266
345
 
267
346
  def get_actions(self, part):
@@ -272,22 +351,27 @@ class DiskWipe:
272
351
  part = ctx.partition
273
352
  name = part.name
274
353
  self.pick_is_running = bool(part.job)
275
- if self.test_state(part, to='STOP'):
354
+ # Show stop action only for non-firmware wipes
355
+ if self.test_state(part, to='STOP') and not self._is_firmware_wipe(part):
276
356
  actions['s'] = 'stop'
277
357
  elif self.test_state(part, to='0%'):
278
358
  actions['w'] = 'wipe'
279
- if part.parent is None:
359
+ # DEL only for whole disks that are SATA or NVMe
360
+ if part.parent is None and part.name[:2] in ('sd', 'hd', 'nv'):
280
361
  actions['DEL'] = 'DEL'
281
362
  # Can verify:
282
- # 1. Anything with wipe markers (states 's' or 'W')
363
+ # 1. Anything with wipe markers (states 's' or 'W') - EXCEPT firmware wipes
283
364
  # 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
284
365
  # 3. Unmarked partitions without filesystems (has parent, state '-' or '^', no fstype)
285
366
  # 4. Only if verify_pct > 0
286
367
  # This prevents verifying filesystems which is nonsensical
368
+ # NOTE: Firmware wipes have built-in pre/post verification, so manual verify is disallowed
287
369
  verify_pct = getattr(self.opts, 'verify_pct', 0)
288
370
  if not part.job and verify_pct > 0:
289
371
  if part.state in ('s', 'W'):
290
- actions['v'] = 'verify'
372
+ # Do NOT allow verify on firmware wipes - they have built-in verification
373
+ if not self._is_firmware_wipe_marker(part):
374
+ actions['v'] = 'verify'
291
375
  elif part.state in ('-', '^'):
292
376
  # For whole disks (no parent), only allow verify if no partitions have filesystems
293
377
  # For partitions, only allow if no filesystem
@@ -344,19 +428,48 @@ class DiskWipe:
344
428
  """ Look for wipeable disks w/o hardware info """
345
429
  if not self.dev_info:
346
430
  return
431
+ from .DeviceWorker import ProbeState
347
432
  for ns in self.partitions.values():
348
433
  if ns.parent:
349
434
  continue
350
- if ns.port.startswith('USB'):
351
- continue
435
+ # if ns.port.startswith('USB'):
436
+ # continue
352
437
  if ns.name[:2] not in ('nv', 'sd', 'hd'):
353
438
  continue
354
- if ns.hw_nopes or ns.hw_caps: # already done
439
+ # Skip if already successfully probed (READY state) with actual results
440
+ # But re-probe if results are empty (unknown after wipe) or if probe FAILED
441
+ if ns.hw_caps_state == ProbeState.READY and (ns.hw_caps or ns.hw_nopes):
442
+ continue # Already have results
443
+ # Device must not be actively wiping (to avoid blocking)
444
+ if ns.job:
355
445
  continue
356
- if self.test_state(ns, to='0%'):
446
+ # Don't probe mounted/blocked devices
447
+ if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
448
+ continue
449
+ # Reset state to PENDING to force a re-probe if needed
450
+ # (state stays READY even after wipe clears results)
451
+ if not (ns.hw_caps or ns.hw_nopes):
452
+ ns.hw_caps_state = ProbeState.PENDING
453
+ # Probe any device that might be ready (s, W, -, ^, or wipeable state)
454
+ self.dev_info.get_hw_capabilities(ns)
455
+
456
+ def _poll_hw_caps_updates(self):
457
+ """Poll for hw_caps probe completion without full device refresh.
458
+
459
+ Called every 0.25 seconds in main loop to quickly show hw_caps
460
+ results as soon as worker threads complete probes.
461
+ """
462
+ if not self.dev_info:
463
+ return
464
+ from .DeviceWorker import ProbeState
465
+ # Only update devices that are still probing
466
+ for ns in self.partitions.values():
467
+ if ns.parent:
468
+ continue
469
+ # Update any device that's still PENDING or PROBING
470
+ if ns.hw_caps_state in (ProbeState.PENDING, ProbeState.PROBING):
357
471
  self.dev_info.get_hw_capabilities(ns)
358
472
 
359
-
360
473
  def main_loop(self):
361
474
  """Main event loop"""
362
475
 
@@ -379,22 +492,24 @@ class DiskWipe:
379
492
  pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
380
493
  ctrl_c_terminates=False,
381
494
  )
382
- lsblk_monitor = LsblkMonitor(check_interval=0.2)
383
- lsblk_monitor.start()
384
- print("Starting first lsblk...")
495
+ device_monitor = DeviceChangeMonitor(check_interval=1.0)
496
+ device_monitor.start()
497
+ print("Discovering devices...")
498
+ # Create persistent worker manager for hw_caps probing
499
+ # This is reused across device refreshes to allow probes to complete
500
+ from .DeviceWorker import DeviceWorkerManager
501
+ from .DrivePreChecker import DrivePreChecker
502
+ worker_manager = DeviceWorkerManager(DrivePreChecker())
385
503
  # Initialize device info and pick range before first draw
386
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
387
- lsblk_output = None
388
- while not lsblk_output:
389
- lsblk_output = lsblk_monitor.get_and_clear()
390
- self.partitions = info.assemble_partitions(self.partitions, lsblk_output)
391
- if lsblk_output:
392
- # print(lsblk_output, '\n\n')
393
- print('got ... got lsblk result')
394
- if self.opts.dump_lsblk:
395
- DeviceInfo.dump(self.partitions, title="after assemble_partitions")
396
- exit(1)
397
- time.sleep(0.2)
504
+ info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
505
+ worker_manager=worker_manager)
506
+ self.partitions = info.assemble_partitions(self.partitions)
507
+ # Start probing hw_caps immediately instead of waiting for first 3s refresh
508
+ self.dev_info = info
509
+ self.get_hw_caps_when_needed()
510
+ if self.opts.dump_lsblk:
511
+ DeviceInfo.dump(self.partitions, title="after assemble_partitions")
512
+ exit(1)
398
513
 
399
514
  self.win = ConsoleWindow(opts=win_opts)
400
515
  # Initialize screen stack
@@ -403,12 +518,8 @@ class DiskWipe:
403
518
  spin = self.spin = OptionSpinner(stack=self.stack)
404
519
  spin.default_obj = self.opts
405
520
  spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
406
- spin.add_key('port_serial', 'p - disk port info', vals=['Auto', 'On', 'Off'])
407
- spin.add_key('slowdown_stop', 'W - stop if disk slows Nx', vals=[64, 256, 0, 4, 16])
408
- spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
409
- spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
410
- spin.add_key('passes', 'P - wipe pass count', vals=[1, 2, 4])
411
- spin.add_key('wipe_mode', 'm - wipe mode', vals=['-V', '+V'])
521
+ spin.add_key('hist_time_format', 'a - time format',
522
+ vals=['ago+time', 'ago', 'time'], scope=LOG_ST)
412
523
 
413
524
  spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
414
525
  spin.add_key('screen_escape', 'ESC- back one screen',
@@ -419,9 +530,10 @@ class DiskWipe:
419
530
  spin.add_key('verify', 'v - verify device', genre='action')
420
531
  spin.add_key('stop', 's - stop wipe', genre='action')
421
532
  spin.add_key('block', 'b - block/unblock disk', genre='action')
422
- spin.add_key('delete_device', 'DEL - remove disk from lsblk',
533
+ spin.add_key('delete_device', 'DEL - remove/unbind disk from system',
423
534
  genre='action', keys=(cs.KEY_DC))
424
- spin.add_key('scan_all_devices', '! - rescan all devices', genre='action')
535
+ spin.add_key('scan_all_devices', 'r - rescan devices and recheck capabilities',
536
+ genre='action', scope=MAIN_ST)
425
537
  spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
426
538
  spin.add_key('help', '? - show help screen', genre='action')
427
539
  spin.add_key('history', 'h - show wipe history', genre='action')
@@ -430,21 +542,23 @@ class DiskWipe:
430
542
  spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
431
543
  spin.add_key('header_mode', '_ - header style', vals=['Underline', 'Reverse', 'Off'])
432
544
  spin.add_key('expand', 'e - expand history entry', genre='action', scope=LOG_ST)
545
+ spin.add_key('copy', 'c - copy entry to clipboard', genre='action', scope=LOG_ST)
433
546
  spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
434
- self.opts.theme = ''
435
- self.persistent_state.restore_updated_opts(self.opts)
547
+ # Load theme from persistent state
548
+ self.opts.theme = self.persistent_state.state.get('theme', '')
436
549
  Theme.set(self.opts.theme)
437
550
  self.win.set_handled_keys(self.spin.keys)
438
551
 
439
- # Start background lsblk monitor
552
+ # Background device change monitor started above
440
553
 
441
- self.get_hw_caps_when_needed()
442
- self.dev_info = info
554
+ # self.dev_info already set during startup probe above
555
+ self.worker_manager = worker_manager
443
556
  pick_range = info.get_pick_range()
444
557
  self.win.set_pick_range(pick_range[0], pick_range[1])
445
558
 
446
559
 
447
560
  check_devices_mono = time.monotonic()
561
+ cached_worker_state = {} # Track marker/hw_caps state to detect updates
448
562
 
449
563
  try:
450
564
  while True:
@@ -459,20 +573,44 @@ class DiskWipe:
459
573
  # Handle actions using perform_actions
460
574
  self.stack.perform_actions(spin)
461
575
 
462
- # Check for new lsblk data from background monitor
463
- lsblk_output = lsblk_monitor.get_and_clear()
576
+ # Poll for hw_caps completion without full device refresh (every 0.25s)
577
+ # This lets us show results quickly even though commands take 1-3 seconds
578
+ self._poll_hw_caps_updates()
579
+
580
+ # Check for device changes from background monitor
581
+ devices_changed = device_monitor.get_and_clear()
464
582
  time_since_refresh = time.monotonic() - check_devices_mono
465
583
 
466
- if lsblk_output or time_since_refresh > 3.0:
467
- # Refresh if: device changes detected OR periodic refresh (3s default)
468
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
469
- self.partitions = info.assemble_partitions(self.partitions, lsblk_output=lsblk_output)
470
- self.get_hw_caps_when_needed()
584
+ # Build current worker state for comparison
585
+ current_worker_state = {}
586
+ if self.worker_manager:
587
+ for device_name, part in self.partitions.items():
588
+ current_worker_state[device_name] = {
589
+ 'marker': part.marker,
590
+ 'hw_caps_state': part.hw_caps_state
591
+ }
592
+ # Check if worker has any updates (markers or hw_caps changes)
593
+ worker_has_updates = (
594
+ self.worker_manager.has_updates(cached_worker_state)
595
+ )
596
+ else:
597
+ worker_has_updates = False
598
+
599
+ if (devices_changed or worker_has_updates or
600
+ time_since_refresh > 3.0):
601
+ # Refresh if: device changes, worker updates, OR periodic (3s default)
602
+ info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
603
+ worker_manager=self.worker_manager)
604
+ self.partitions = info.assemble_partitions(self.partitions)
471
605
  self.dev_info = info
472
606
  # Update pick range to highlight NAME through SIZE fields
473
607
  pick_range = info.get_pick_range()
474
608
  self.win.set_pick_range(pick_range[0], pick_range[1])
609
+ # Probe hw_caps for devices that need it (only once per refresh, not every draw)
610
+ self.get_hw_caps_when_needed()
475
611
  check_devices_mono = time.monotonic()
612
+ # Update cached state after refresh
613
+ cached_worker_state = current_worker_state.copy()
476
614
 
477
615
  # Save any persistent state changes
478
616
  self.persistent_state.save_updated_opts(self.opts)
@@ -481,7 +619,10 @@ class DiskWipe:
481
619
  self.win.clear()
482
620
  finally:
483
621
  # Clean up monitor thread on exit
484
- lsblk_monitor.stop()
622
+ device_monitor.stop()
623
+ # Clean up persistent worker manager
624
+ if hasattr(self, 'worker_manager') and self.worker_manager:
625
+ self.worker_manager.stop_all()
485
626
 
486
627
  class DiskWipeScreen(Screen):
487
628
  """ TBD """
@@ -505,35 +646,30 @@ class MainScreen(DiskWipeScreen):
505
646
  self.persist_port_serial = set()
506
647
 
507
648
 
508
- def _port_serial_line(self, partition):
649
+ def _port_serial_line(self, partition, has_children=True):
509
650
  wids = self.app.wids
510
- wid = wids.state if wids else 5
511
- sep, key_str = ' ', ''
512
- port, serial = partition.port, partition.serial
513
- if partition.hw_caps or partition.hw_nopes:
514
- lead = 'CAPS' if partition.hw_caps else 'ERRS'
515
- infos = partition.hw_caps if partition.hw_caps else partition.hw_nopes
516
- key_str = f' Fw{lead}: ' + ','.join(list(infos.keys()))
517
- return f'{"":>{wid}}{sep}│ └────── {port:<12} {serial}{key_str}'
651
+ sep = ' '
652
+ # Sanitize port/serial - some USB bridges return strings with embedded nulls
653
+ port = partition.port.replace('\x00', '') if partition.port else ''
654
+ serial = partition.serial.replace('\x00', '') if partition.serial else ''
518
655
 
519
- def draw_screen(self):
520
- """Draw the main device list"""
521
- app = self.app
656
+ # Get column widths (with defaults if wids not yet initialized)
657
+ wid_state = wids.state if wids else 5
522
658
 
523
- def wanted(name):
524
- return not app.filter or app.filter.search(name)
659
+ # Use corner └ if no children below, or vertical │ if there are children to connect to
660
+ connector = '│' if has_children else ' '
525
661
 
526
- app.win.set_pick_mode(True)
527
- if app.opts.port_serial != 'Auto':
528
- self.persist_port_serial = set() # name of disks
529
- else: # if the disk goes away, clear persistence
530
- for name in list(self.persist_port_serial):
531
- if name not in app.partitions:
532
- self.persist_port_serial.discard(name)
662
+ # Build base line: state padding + connector + port/serial
663
+ base = f'{"":>{wid_state}}{sep}{connector} └────── {port:<12} {serial}'
533
664
 
534
- # First pass: process jobs and collect visible partitions
535
- visible_partitions = []
536
- for name, partition in app.partitions.items():
665
+ return base
666
+
667
+ def do_job_maintenance(self):
668
+ """ Check all the jobs in progress and advance their state
669
+ appropriately.
670
+ """
671
+ app = self.app
672
+ for _, partition in app.partitions.items():
537
673
  partition.line = None
538
674
  if partition.job:
539
675
  if partition.job.done:
@@ -629,6 +765,7 @@ class MainScreen(DiskWipeScreen):
629
765
  partition.job = None
630
766
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
631
767
  partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
768
+ partition.monitor_marker = False # Stop monitoring verify job
632
769
  else:
633
770
  # Wipe job completed (with or without auto-verify)
634
771
  # Check if stopped during verify phase (after successful write)
@@ -716,8 +853,9 @@ class MainScreen(DiskWipeScreen):
716
853
  partition.job = None
717
854
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
718
855
  partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
856
+ partition.monitor_marker = True # Start monitoring the new marker
719
857
  if partition.job:
720
- elapsed, pct, rate, until = partition.job.get_status()
858
+ elapsed, pct, rate, until, more_state = partition.job.get_status()
721
859
 
722
860
  # Get task display name (Zero, Rand, Crypto, Verify, etc.)
723
861
  task_name = ""
@@ -731,26 +869,76 @@ class MainScreen(DiskWipeScreen):
731
869
  partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
732
870
  else:
733
871
  partition.mounts = [f'{task_name} {pct} {elapsed}']
872
+ if more_state:
873
+ partition.mounts[0] += f' {more_state}'
734
874
  else:
735
875
  partition.state = pct
736
876
  # Build progress line with task name
737
877
  progress_parts = [task_name, elapsed, f'-{until}', rate]
738
878
 
739
- # Only show slowdown/stall if job tracks these metrics
740
- # (WriteTask does, VerifyTask and FirmwareWipeTask don't)
741
- if hasattr(partition.job, 'max_slowdown_ratio') and hasattr(partition.job, 'max_stall_secs'):
879
+ # Only show slowdown/stall for WriteTask (not VerifyTask or FirmwareWipeTask)
880
+ from .FirmwareWipeTask import FirmwareWipeTask
881
+ from .WriteTask import WriteTask
882
+ current_task = partition.job.current_task
883
+ if current_task and isinstance(current_task, WriteTask) and not isinstance(current_task, FirmwareWipeTask):
742
884
  slowdown = partition.job.max_slowdown_ratio
743
885
  stall = partition.job.max_stall_secs
744
886
  progress_parts.extend([f'÷{slowdown}', f'𝚫{Utils.ago_str(stall)}'])
745
887
 
888
+ if more_state:
889
+ progress_parts.append(more_state)
890
+
746
891
  partition.mounts = [' '.join(progress_parts)]
747
892
 
748
893
  if partition.parent and partition.parent in app.partitions and (
749
- app.partitions[partition.parent].state == 'Blk'):
894
+ app.partitions[partition.parent].state in ('Blk', 'iBlk')):
750
895
  continue
751
896
 
752
- if wanted(name) or partition.job:
753
- visible_partitions.append(partition)
897
+
898
+ def draw_screen(self):
899
+ """Draw the main device list"""
900
+ app = self.app
901
+
902
+ def wanted(name):
903
+ return not app.filter or app.filter.search(name)
904
+
905
+ self.do_job_maintenance()
906
+
907
+ app.win.set_pick_mode(True)
908
+ if app.opts.port_serial != 'Auto':
909
+ self.persist_port_serial = set() # name of disks
910
+ else: # if the disk goes away, clear persistence
911
+ for name in list(self.persist_port_serial):
912
+ if name not in app.partitions:
913
+ self.persist_port_serial.discard(name)
914
+
915
+ # process jobs and collect visible partitions, sorted by disk then partition
916
+ visible_partitions = []
917
+ # Get disks sorted alphabetically
918
+ disks = sorted([p for p in app.partitions.values() if p.parent is None],
919
+ key=lambda p: p.name)
920
+ for disk in disks:
921
+ if wanted(disk.name) or disk.job:
922
+ visible_partitions.append(disk)
923
+ # Add partitions for this disk, sorted alphabetically
924
+ parts = sorted([p for p in app.partitions.values() if p.parent == disk.name],
925
+ key=lambda p: p.name)
926
+ # If disk is directly blocked, hide children and aggregate their mounts
927
+ if disk.state == 'Blk':
928
+ # Collect all mounts from children, sort with "/" first (by name/length)
929
+ all_mounts = []
930
+ for part in parts:
931
+ all_mounts.extend(part.mounts)
932
+ # Sort: "/" first, then by name (implicitly by length since "/" is shortest)
933
+ all_mounts.sort(key=lambda m: (m != '/', m))
934
+ disk.aggregated_mounts = all_mounts
935
+ # Skip adding children to visible list
936
+ continue
937
+ else:
938
+ disk.aggregated_mounts = None
939
+ for part in parts:
940
+ if wanted(part.name) or part.job:
941
+ visible_partitions.append(part)
754
942
 
755
943
  # Re-infer parent states (like 'Busy') after updating child job states
756
944
  DeviceInfo.set_all_states(app.partitions)
@@ -781,7 +969,16 @@ class MainScreen(DiskWipeScreen):
781
969
  # Create context with partition reference
782
970
  ctx = Context(genre='disk' if partition.parent is None else 'partition',
783
971
  partition=partition)
784
- app.win.add_body(partition.line, attr=attr, context=ctx)
972
+ # For disks, underline just the alphanumeric part of fw capability text
973
+ fw_ul = getattr(partition, '_fw_underline', None)
974
+ if fw_ul:
975
+ ul_start, ul_end = fw_ul
976
+ app.win.add_body(partition.line[:ul_start], attr=attr, context=ctx)
977
+ ul_attr = (attr or cs.A_NORMAL) | cs.A_UNDERLINE
978
+ app.win.add_body(partition.line[ul_start:ul_end], attr=ul_attr, resume=True)
979
+ app.win.add_body(partition.line[ul_end:], attr=attr, resume=True)
980
+ else:
981
+ app.win.add_body(partition.line, attr=attr, context=ctx)
785
982
  if partition.parent is None and app.opts.port_serial != 'Off':
786
983
  doit = bool(app.opts.port_serial == 'On')
787
984
  if not doit:
@@ -790,16 +987,19 @@ class MainScreen(DiskWipeScreen):
790
987
  doit = True
791
988
  self.persist_port_serial.add(partition.name)
792
989
  if doit:
793
- line = self._port_serial_line(partition)
794
- app.win.add_body(line, attr=attr, context=Context(genre='DECOR'))
990
+ # Check if this disk has any visible child partitions
991
+ has_children = partition.name in parent_last_child
992
+ line = self._port_serial_line(partition, has_children)
993
+ port_attr = (attr or cs.A_NORMAL) & ~cs.A_BOLD
994
+ app.win.add_body(line, attr=port_attr, context=Context(genre='DECOR'))
795
995
 
796
996
  # Show inline confirmation prompt if this is the partition being confirmed
797
997
  if app.confirmation.active and app.confirmation.identity == partition.name:
798
998
  # Build confirmation message
799
999
  if app.confirmation.action_type == 'wipe':
800
- msg = f'⚠️ WIPE {partition.name}'
1000
+ msg = f'⚠️ WIPE'
801
1001
  else: # verify
802
- msg = f'⚠️ VERIFY {partition.name} [writes marker]'
1002
+ msg = f'⚠️ VERIFY [writes marker]'
803
1003
 
804
1004
  # Add mode-specific prompt (base message without input)
805
1005
  if app.confirmation.mode == 'yes':
@@ -807,11 +1007,11 @@ class MainScreen(DiskWipeScreen):
807
1007
  elif app.confirmation.mode == 'identity':
808
1008
  msg += f" - Type '{partition.name}': "
809
1009
  elif app.confirmation.mode == 'choices':
810
- choices_str = ', '.join(app.confirmation.choices)
811
- msg += f" - Choose ({choices_str}): "
1010
+ choices_str = ','.join(app.confirmation.choices)
1011
+ msg += f" Choice ({choices_str}): "
812
1012
 
813
1013
  # Position message at fixed column (reduced from 28 to 20)
814
- msg = ' ' * 20 + msg
1014
+ msg = ' ' * 5 + msg
815
1015
 
816
1016
  # Add confirmation message base as DECOR (non-pickable)
817
1017
  app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
@@ -863,9 +1063,18 @@ class MainScreen(DiskWipeScreen):
863
1063
  """Handle quit action (q or x key pressed)"""
864
1064
  app = self.app
865
1065
 
1066
+ # Check for firmware wipes - cannot quit while they're running
1067
+ if app._has_any_firmware_wipes():
1068
+ # Show alert - cannot quit during firmware wipe
1069
+ app.filter_bar._text = 'Cannot quit: firmware wipe running'
1070
+ return
1071
+
866
1072
  def stop_if_idle(part):
867
1073
  if part.state[-1] == '%':
868
1074
  if part.job and not part.job.done:
1075
+ # Skip firmware wipes - they cannot be stopped
1076
+ if app._is_firmware_wipe(part):
1077
+ return 1 # Count as running but don't stop
869
1078
  part.job.do_abort = True
870
1079
  return 1 if part.job else 0
871
1080
 
@@ -897,9 +1106,23 @@ class MainScreen(DiskWipeScreen):
897
1106
  if app.test_state(part, to='0%'):
898
1107
  self.clear_hotswap_marker(part)
899
1108
  # Build choices: Zero, Rand, and any firmware wipe types
1109
+ # Sort all by rank (worst to best) with '*' on recommended (last) one
1110
+ from .DrivePreChecker import DrivePreChecker
900
1111
  choices = ['Zero', 'Rand']
1112
+ fw_modes = []
901
1113
  if part.hw_caps:
902
- choices.extend(list(part.hw_caps.keys()))
1114
+ # Strip '*' from hw_caps modes (already has it from display string)
1115
+ fw_modes = [m.strip().rstrip('*') for m in part.hw_caps.split(',')]
1116
+ choices.extend(fw_modes)
1117
+ # Use HDD rankings (prefer software wipes) ONLY if:
1118
+ # 1. Device reports as rotational, AND
1119
+ # 2. Device has no crypto-capable firmware wipes
1120
+ # Note: 'Enhanced' is available on HDDs too (slow overwrite, not crypto)
1121
+ # Only SCrypto/Crypto/FCrypto definitively indicate SSD with crypto
1122
+ is_rotational = getattr(part, 'is_rotational', False)
1123
+ has_crypto_fw = any(m in fw_modes for m in ('SCrypto', 'Crypto', 'FCrypto'))
1124
+ use_hdd_ranking = is_rotational and not has_crypto_fw
1125
+ choices = DrivePreChecker.sort_modes_by_rank(choices, is_rotational=use_hdd_ranking)
903
1126
  app.confirmation.start(action_type='wipe',
904
1127
  identity=part.name, mode='choices', choices=choices)
905
1128
  app.win.passthrough_mode = True
@@ -913,6 +1136,12 @@ class MainScreen(DiskWipeScreen):
913
1136
  # Use get_actions() to ensure we use the same logic as the header display
914
1137
  _, actions = app.get_actions(part)
915
1138
  if 'v' in actions:
1139
+ # Safety check: Prevent verification on firmware wipes
1140
+ # (Firmware wipes have built-in pre/post verification)
1141
+ if self._is_firmware_wipe_marker(part):
1142
+ part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
1143
+ return
1144
+
916
1145
  self.clear_hotswap_marker(part)
917
1146
  # Check if this is an unmarked disk/partition (potential data loss risk)
918
1147
  # Whole disks (no parent) or partitions without filesystems need confirmation
@@ -934,38 +1163,119 @@ class MainScreen(DiskWipeScreen):
934
1163
  def scan_all_devices_ACTION(self):
935
1164
  """ Trigger a re-scan of all devices to make the appear
936
1165
  quicker in the list"""
1166
+ # Show temporary feedback
1167
+ self.app.win.flash('Scanning devices and rechecking firmware capabilities...', duration=0.75)
1168
+
1169
+ # SCSI host rescan (for SATA devices)
937
1170
  base_path = '/sys/class/scsi_host'
938
- if not os.path.exists(base_path):
939
- return
940
- for host in os.listdir(base_path):
941
- scan_file = os.path.join(base_path, host, 'scan')
942
- if os.path.exists(scan_file):
943
- try:
944
- with open(scan_file, 'w', encoding='utf-8') as f:
945
- f.write("- - -")
946
- except Exception:
947
- pass
1171
+ if os.path.exists(base_path):
1172
+ for host in os.listdir(base_path):
1173
+ scan_file = os.path.join(base_path, host, 'scan')
1174
+ if os.path.exists(scan_file):
1175
+ try:
1176
+ with open(scan_file, 'w', encoding='utf-8') as f:
1177
+ f.write("- - -")
1178
+ except Exception:
1179
+ pass
1180
+
1181
+ # Rebind any unbound NVMe devices
1182
+ self._rebind_nvme_devices()
1183
+
1184
+ # Reset hw_caps stickiness for all devices so they'll be re-probed
1185
+ # This allows detecting hardware state changes after sleep/wake cycles
1186
+ if self.app.worker_manager:
1187
+ for partition in self.app.partitions.values():
1188
+ # Queue worker to re-probe this device's capabilities
1189
+ if partition.parent: # only need to do whole disks
1190
+ continue
1191
+ self.app.worker_manager.request_hw_caps(partition.name)
1192
+ # Clear cached values so UI refreshes with new probing state
1193
+ partition.hw_caps = ''
1194
+ partition.hw_nopes = ''
1195
+ partition.hw_caps_state = ProbeState.PENDING
948
1196
 
949
1197
  def delete_device_ACTION(self):
950
- """ DEL key -- Cause the OS to drop a sata device so it
951
- can be replaced sooner """
1198
+ """ DEL key -- Cause the OS to drop a SATA device or unbind an NVMe device
1199
+ so it can be replaced sooner """
952
1200
  app = self.app
953
1201
  ctx = app.win.get_picked_context()
954
1202
  if ctx and hasattr(ctx, 'partition'):
955
1203
  part = ctx.partition
956
1204
  if not part or part.parent or not app.test_state(part, to='0%'):
957
1205
  return
958
- path = f"/sys/block/{part.name}/device/delete"
959
- if os.path.exists(path):
960
- try:
961
- with open(path, 'w', encoding='utf-8') as f:
962
- f.write("1")
963
- return True
964
- except Exception:
965
- pass
1206
+ # NVMe unbind - write PCI address to driver unbind file
1207
+ if part.name.startswith('nvme'):
1208
+ pci_addr = self._get_nvme_pci_address(part.name)
1209
+ if pci_addr:
1210
+ unbind_path = "/sys/bus/pci/drivers/nvme/unbind"
1211
+ if os.path.exists(unbind_path):
1212
+ try:
1213
+ with open(unbind_path, 'w', encoding='utf-8') as f:
1214
+ f.write(pci_addr)
1215
+ return True
1216
+ except Exception:
1217
+ pass
1218
+ # SATA/IDE delete - write 1 to device delete file
1219
+ else:
1220
+ path = f"/sys/block/{part.name}/device/delete"
1221
+ if os.path.exists(path):
1222
+ try:
1223
+ with open(path, 'w', encoding='utf-8') as f:
1224
+ f.write("1")
1225
+ return True
1226
+ except Exception:
1227
+ pass
1228
+
1229
+ def _get_nvme_pci_address(self, device_name):
1230
+ """Get the full PCI address for an NVMe device (e.g., '0000:01:00.0')
1231
+
1232
+ The sysfs path may contain multiple PCI addresses (bridges), so we need
1233
+ the last one before /nvme/ which is the actual NVMe controller.
1234
+ """
1235
+ try:
1236
+ sysfs_path = f'/sys/class/block/{device_name}'
1237
+ if os.path.exists(sysfs_path):
1238
+ real_path = os.path.realpath(sysfs_path)
1239
+ # Find all PCI addresses and take the last one (the NVMe controller)
1240
+ pci_matches = re.findall(r'(0000:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])', real_path, re.I)
1241
+ if pci_matches:
1242
+ return pci_matches[-1]
1243
+ except Exception:
1244
+ pass
1245
+ return None
1246
+
1247
+ def _rebind_nvme_devices(self):
1248
+ """Find and rebind any unbound NVMe devices.
1249
+
1250
+ Scans PCI devices for NVMe controllers (class 0x010802) that have no
1251
+ driver bound, and attempts to bind them to the nvme driver.
1252
+ """
1253
+ pci_devices = '/sys/bus/pci/devices'
1254
+ nvme_bind = '/sys/bus/pci/drivers/nvme/bind'
1255
+ if not os.path.exists(pci_devices) or not os.path.exists(nvme_bind):
1256
+ return
1257
+
1258
+ for pci_addr in os.listdir(pci_devices):
1259
+ device_path = os.path.join(pci_devices, pci_addr)
1260
+ # Check if this is an NVMe controller (class 0x010802)
1261
+ class_file = os.path.join(device_path, 'class')
1262
+ try:
1263
+ with open(class_file, 'r', encoding='utf-8') as f:
1264
+ device_class = f.read().strip()
1265
+ if device_class != '0x010802':
1266
+ continue
1267
+ # Check if driver is already bound
1268
+ driver_link = os.path.join(device_path, 'driver')
1269
+ if os.path.exists(driver_link):
1270
+ continue
1271
+ # Try to bind to nvme driver
1272
+ with open(nvme_bind, 'w', encoding='utf-8') as f:
1273
+ f.write(pci_addr)
1274
+ except Exception:
1275
+ pass
966
1276
 
967
1277
  def stop_ACTION(self):
968
- """Handle 's' key"""
1278
+ """Handle 's' key - stop current wipe (but not firmware wipes)"""
969
1279
  app = self.app
970
1280
  if app.pick_is_running:
971
1281
  ctx = app.win.get_picked_context()
@@ -973,15 +1283,21 @@ class MainScreen(DiskWipeScreen):
973
1283
  part = ctx.partition
974
1284
  if part.state[-1] == '%':
975
1285
  if part.job and not part.job.done:
1286
+ # Skip firmware wipes - they cannot be safely stopped
1287
+ if app._is_firmware_wipe(part):
1288
+ return
976
1289
  part.job.do_abort = True
977
1290
 
978
1291
 
979
1292
  def stop_all_ACTION(self):
980
- """Handle 'S' key"""
1293
+ """Handle 'S' key - stop all wipes (but not firmware wipes)"""
981
1294
  app = self.app
982
1295
  for part in app.partitions.values():
983
1296
  if part.state[-1] == '%':
984
1297
  if part.job and not part.job.done:
1298
+ # Skip firmware wipes - they cannot be safely stopped
1299
+ if app._is_firmware_wipe(part):
1300
+ continue
985
1301
  part.job.do_abort = True
986
1302
 
987
1303
  def block_ACTION(self):
@@ -1025,6 +1341,20 @@ class HelpScreen(DiskWipeScreen):
1025
1341
  spinner.show_help_nav_keys(app.win)
1026
1342
  spinner.show_help_body(app.win)
1027
1343
 
1344
+ # Add CLI Options section
1345
+ app.win.add_body('Command Line Arguments:', attr=cs.A_UNDERLINE)
1346
+ opts, wid = app.opts, 8
1347
+ cli_options = [
1348
+ f'--mode: . . . . {opts.mode:<{wid}} Wipe mode',
1349
+ f'--passes: . . . {opts.passes:<{wid}} Passes for software wipes',
1350
+ f'--verify-pct: . {opts.verify_pct:<{wid}} Verification %',
1351
+ f'--port-serial: {opts.port_serial:<{wid}} Show port/serial/FwCAPS',
1352
+ f'--slowdown-stop: {opts.slowdown_stop:<{wid}} Stop if disk slows',
1353
+ f'--stall-timeout: {opts.stall_timeout:<{wid}} Stall timeout in sec',
1354
+ ]
1355
+ for opt in cli_options:
1356
+ app.win.add_body(opt, attr=cs.A_DIM)
1357
+
1028
1358
 
1029
1359
 
1030
1360
  class HistoryScreen(DiskWipeScreen):
@@ -1087,6 +1417,15 @@ class HistoryScreen(DiskWipeScreen):
1087
1417
 
1088
1418
  def draw_screen(self):
1089
1419
  """Draw the history screen with structured log entries"""
1420
+
1421
+ def format_ago(timestamp):
1422
+ nonlocal now_dt
1423
+ ts = datetime.fromisoformat(timestamp)
1424
+ delta = now_dt - ts
1425
+ return Utils.ago_str(int(round(delta.total_seconds())))
1426
+
1427
+
1428
+ now_dt = datetime.now()
1090
1429
  app = self.app
1091
1430
  win = app.win
1092
1431
  win.set_pick_mode(True)
@@ -1126,7 +1465,7 @@ class HistoryScreen(DiskWipeScreen):
1126
1465
 
1127
1466
  # Header
1128
1467
  # header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} ({level_summary}) '
1129
- header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} '
1468
+ header_line = f'ESC:back [e]xpand [c]opy [/]search {len(self.filtered_entries)}/{len(self.entries)} '
1130
1469
  if search_display:
1131
1470
  header_line += f'/ {search_display}'
1132
1471
  else:
@@ -1141,8 +1480,17 @@ class HistoryScreen(DiskWipeScreen):
1141
1480
  # Get display summary from entry
1142
1481
  summary = entry.display_summary
1143
1482
 
1144
- # Format timestamp (just date and time)
1145
- timestamp_display = timestamp[:19] # YYYY-MM-DD HH:MM:SS
1483
+ # Format timestamp based on spinner setting
1484
+ time_format = app.opts.hist_time_format
1485
+
1486
+ if time_format == 'ago':
1487
+ timestamp_display = f"{format_ago(timestamp):>6}"
1488
+ elif time_format == 'ago+time':
1489
+ ago = format_ago(timestamp)
1490
+ time_str = timestamp[:19]
1491
+ timestamp_display = f"{ago:>6} {time_str}"
1492
+ else: # 'time'
1493
+ timestamp_display = timestamp[:19] # Just the date and time part (YYYY-MM-DD HH:MM:SS)
1146
1494
 
1147
1495
  level = entry.level
1148
1496
 
@@ -1193,13 +1541,89 @@ class HistoryScreen(DiskWipeScreen):
1193
1541
  timestamp = ctx.timestamp
1194
1542
  # Toggle between collapsed and expanded
1195
1543
  current = self.expands.get(timestamp, False)
1196
- if current:
1197
- del self.expands[timestamp] # Collapse
1544
+ if current: # Collapsing
1545
+ del self.expands[timestamp]
1546
+ # Search backwards to find first context with matching timestamp
1547
+ # This should be the header line of this entry
1548
+ test_pos = win.pick_pos - 1
1549
+ while test_pos >= 0:
1550
+ test_ctx = win.body.contexts[test_pos]
1551
+ if test_ctx and hasattr(test_ctx, 'timestamp') and test_ctx.timestamp == timestamp:
1552
+ win.pick_pos = test_pos
1553
+ test_pos -= 1
1554
+ else:
1555
+ return
1198
1556
  else:
1199
- self.expands[timestamp] = True # Expand
1557
+ # Expanding - just toggle, cursor stays where it is
1558
+ self.expands[timestamp] = True
1200
1559
 
1201
1560
  def filter_ACTION(self):
1202
1561
  """'/' key - Start incremental search"""
1203
1562
  app = self.app
1204
1563
  self.search_bar.start(self.prev_filter)
1205
1564
  app.win.passthrough_mode = True
1565
+
1566
+ def copy_ACTION(self):
1567
+ """'c' key - Copy current entry to clipboard or print to terminal"""
1568
+ from .Utils import ClipboardHelper
1569
+ app = self.app
1570
+ win = app.win
1571
+ ctx = win.get_picked_context()
1572
+
1573
+ if not ctx or not hasattr(ctx, 'timestamp'):
1574
+ return
1575
+
1576
+ # Find the entry by timestamp
1577
+ timestamp = ctx.timestamp
1578
+ entry = None
1579
+ for e in self.entries:
1580
+ if e.timestamp == timestamp:
1581
+ entry = e
1582
+ break
1583
+
1584
+ if not entry:
1585
+ return
1586
+
1587
+ # Format entry as JSON
1588
+ entry_text = json.dumps(entry.to_dict(), indent=2)
1589
+
1590
+ # Try clipboard first
1591
+ if ClipboardHelper.has_clipboard():
1592
+ success, error = ClipboardHelper.copy(entry_text)
1593
+ if success:
1594
+ # Show brief success indicator - use the header context temporarily
1595
+ # The message will be visible until next refresh
1596
+ win.add_header(f'Copied to clipboard ({ClipboardHelper.get_method_name()})')
1597
+ else:
1598
+ win.add_header(f'Clipboard error: {error}')
1599
+ else:
1600
+ # Terminal fallback: exit curses, print, wait for input
1601
+ self._copy_terminal_fallback(entry_text)
1602
+
1603
+ def _copy_terminal_fallback(self, text):
1604
+ """Print entry to terminal when clipboard unavailable (e.g., SSH sessions)."""
1605
+ from .Utils import ClipboardHelper
1606
+ app = self.app
1607
+
1608
+ # Exit curses to use the terminal
1609
+ ConsoleWindow.stop_curses()
1610
+ os.system('clear; stty sane')
1611
+
1612
+ # Print with border
1613
+ print('=' * 60)
1614
+ print('LOG ENTRY (copy manually from terminal):')
1615
+ print('=' * 60)
1616
+ print(text)
1617
+ print('=' * 60)
1618
+ print(f'\nClipboard: {ClipboardHelper.get_method_name()}')
1619
+ print('\nPress ENTER to return to dwipe...')
1620
+
1621
+ # Wait for user input
1622
+ try:
1623
+ input()
1624
+ except EOFError:
1625
+ pass
1626
+
1627
+ # Restore curses
1628
+ ConsoleWindow.start_curses()
1629
+ app.win.pick_pos = app.win.pick_pos # Force position refresh