mirror of
https://github.com/overcuriousity/trace.git
synced 2025-12-20 04:52:21 +00:00
Auto-export PGP signatures to clipboard and file
When pressing 'V' on a note detail view, the PGP signature is now: 1. Automatically copied to system clipboard (if available) 2. Saved to ~/.trace/last_signature.txt for manual access This solves the copy/paste problem where signatures displayed in the TUI modal couldn't be selected and copied. Users can now: - Paste directly from clipboard into Kleopatra/GPG tools (if clipboard worked) - Access the signature file from another terminal if needed Platform support: - Linux: Uses xclip or xsel (falls back to file if not available) - macOS: Uses pbcopy (falls back to file if not available) - Windows: Uses clip (falls back to file if not available) The dialog shows clear feedback about: - Whether clipboard copy succeeded - File location and how to access it manually - Verification status (verified/failed/unsigned) No external dependencies added - uses only stdlib subprocess calls.
This commit is contained in:
134
trace/tui_app.py
134
trace/tui_app.py
@@ -119,13 +119,13 @@ class TUI:
|
|||||||
self.flash_time = time.time()
|
self.flash_time = time.time()
|
||||||
|
|
||||||
def verify_note_signature(self):
|
def verify_note_signature(self):
|
||||||
"""Show detailed verification dialog for current note with raw signature"""
|
"""Show detailed verification dialog for current note with signature export"""
|
||||||
if not self.current_note:
|
if not self.current_note:
|
||||||
return
|
return
|
||||||
|
|
||||||
verified, info = self.current_note.verify_signature()
|
verified, info = self.current_note.verify_signature()
|
||||||
|
|
||||||
# Prepare dialog content
|
# Handle unsigned notes
|
||||||
if not self.current_note.signature:
|
if not self.current_note.signature:
|
||||||
title = "Note Signature Status"
|
title = "Note Signature Status"
|
||||||
message = [
|
message = [
|
||||||
@@ -134,8 +134,69 @@ class TUI:
|
|||||||
"To sign notes, enable GPG signing in settings",
|
"To sign notes, enable GPG signing in settings",
|
||||||
"and ensure you have a GPG key configured."
|
"and ensure you have a GPG key configured."
|
||||||
]
|
]
|
||||||
signature_lines = []
|
self._show_simple_dialog(title, message)
|
||||||
elif verified:
|
return
|
||||||
|
|
||||||
|
# Save signature to file and attempt clipboard copy
|
||||||
|
import subprocess
|
||||||
|
import platform
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sig_file = Path.home() / ".trace" / "last_signature.txt"
|
||||||
|
sig_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sig_file.write_text(self.current_note.signature)
|
||||||
|
file_saved = True
|
||||||
|
except Exception:
|
||||||
|
file_saved = False
|
||||||
|
|
||||||
|
# Try to copy to clipboard
|
||||||
|
clipboard_success = False
|
||||||
|
clipboard_method = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Linux":
|
||||||
|
# Try xclip first, then xsel
|
||||||
|
try:
|
||||||
|
subprocess.run(['xclip', '-selection', 'clipboard'],
|
||||||
|
input=self.current_note.signature.encode(),
|
||||||
|
check=True, timeout=2, capture_output=True)
|
||||||
|
clipboard_success = True
|
||||||
|
clipboard_method = "xclip"
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
try:
|
||||||
|
subprocess.run(['xsel', '--clipboard', '--input'],
|
||||||
|
input=self.current_note.signature.encode(),
|
||||||
|
check=True, timeout=2, capture_output=True)
|
||||||
|
clipboard_success = True
|
||||||
|
clipboard_method = "xsel"
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
elif system == "Darwin": # macOS
|
||||||
|
try:
|
||||||
|
subprocess.run(['pbcopy'],
|
||||||
|
input=self.current_note.signature.encode(),
|
||||||
|
check=True, timeout=2, capture_output=True)
|
||||||
|
clipboard_success = True
|
||||||
|
clipboard_method = "pbcopy"
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
elif system == "Windows":
|
||||||
|
try:
|
||||||
|
subprocess.run(['clip'],
|
||||||
|
input=self.current_note.signature.encode(),
|
||||||
|
check=True, timeout=2, capture_output=True)
|
||||||
|
clipboard_success = True
|
||||||
|
clipboard_method = "clip"
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build dialog message based on verification and export status
|
||||||
|
if verified:
|
||||||
title = "✓ Signature Verified"
|
title = "✓ Signature Verified"
|
||||||
message = [
|
message = [
|
||||||
"The note's signature is valid.",
|
"The note's signature is valid.",
|
||||||
@@ -143,12 +204,7 @@ class TUI:
|
|||||||
f"Signed by: {info}",
|
f"Signed by: {info}",
|
||||||
"",
|
"",
|
||||||
"This note has not been tampered with since signing.",
|
"This note has not been tampered with since signing.",
|
||||||
"",
|
|
||||||
"─" * 60,
|
|
||||||
"RAW PGP SIGNATURE (copy/paste for external verification):",
|
|
||||||
"─" * 60,
|
|
||||||
]
|
]
|
||||||
signature_lines = self.current_note.signature.split('\n')
|
|
||||||
else:
|
else:
|
||||||
title = "✗ Signature Verification Failed"
|
title = "✗ Signature Verification Failed"
|
||||||
message = [
|
message = [
|
||||||
@@ -160,28 +216,48 @@ class TUI:
|
|||||||
"- Public key not in keyring",
|
"- Public key not in keyring",
|
||||||
"- Note content was modified after signing",
|
"- Note content was modified after signing",
|
||||||
"- Signature is corrupted",
|
"- Signature is corrupted",
|
||||||
"",
|
|
||||||
"─" * 60,
|
|
||||||
"RAW PGP SIGNATURE (copy/paste for external verification):",
|
|
||||||
"─" * 60,
|
|
||||||
]
|
]
|
||||||
signature_lines = self.current_note.signature.split('\n')
|
|
||||||
|
|
||||||
# Combine message and signature
|
# Add export status information
|
||||||
all_lines = message + signature_lines
|
message.append("")
|
||||||
|
message.append("─" * 60)
|
||||||
|
|
||||||
# Display scrollable dialog
|
if clipboard_success:
|
||||||
|
message.append("✓ Signature copied to clipboard!")
|
||||||
|
message.append("")
|
||||||
|
message.append("You can paste it directly into Kleopatra or GPG tools.")
|
||||||
|
|
||||||
|
if file_saved:
|
||||||
|
if clipboard_success:
|
||||||
|
message.append("")
|
||||||
|
message.append("Also saved to file:")
|
||||||
|
else:
|
||||||
|
message.append("✓ Signature saved to file:")
|
||||||
|
message.append("")
|
||||||
|
message.append(f" {sig_file}")
|
||||||
|
message.append("")
|
||||||
|
message.append("To copy manually, run in another terminal:")
|
||||||
|
message.append(f" cat {sig_file}")
|
||||||
|
|
||||||
|
if not clipboard_success and not file_saved:
|
||||||
|
message.append("⚠ Could not copy to clipboard or save to file.")
|
||||||
|
message.append("")
|
||||||
|
message.append("Please check file permissions.")
|
||||||
|
|
||||||
|
self._show_simple_dialog(title, message)
|
||||||
|
|
||||||
|
def _show_simple_dialog(self, title, message_lines):
|
||||||
|
"""Display a simple scrollable dialog with the given title and message lines"""
|
||||||
h, w = self.stdscr.getmaxyx()
|
h, w = self.stdscr.getmaxyx()
|
||||||
dialog_h = min(h - 4, 40) # Max height for dialog
|
dialog_h = min(h - 4, len(message_lines) + 8)
|
||||||
dialog_w = min(w - 4, 100) # Max width for dialog
|
dialog_w = min(w - 4, max(len(title) + 4, max((len(line) for line in message_lines), default=40) + 4))
|
||||||
start_y = (h - dialog_h) // 2
|
start_y = (h - dialog_h) // 2
|
||||||
start_x = (w - dialog_w) // 2
|
start_x = (w - dialog_w) // 2
|
||||||
|
|
||||||
# Create dialog window
|
|
||||||
dialog = curses.newwin(dialog_h, dialog_w, start_y, start_x)
|
dialog = curses.newwin(dialog_h, dialog_w, start_y, start_x)
|
||||||
|
|
||||||
scroll_offset = 0
|
scroll_offset = 0
|
||||||
max_scroll = max(0, len(all_lines) - (dialog_h - 6))
|
max_scroll = max(0, len(message_lines) - (dialog_h - 6))
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
@@ -189,26 +265,25 @@ class TUI:
|
|||||||
|
|
||||||
# Title
|
# Title
|
||||||
dialog.attron(curses.A_BOLD)
|
dialog.attron(curses.A_BOLD)
|
||||||
title_x = (dialog_w - len(title)) // 2
|
title_x = max(2, (dialog_w - len(title)) // 2)
|
||||||
try:
|
try:
|
||||||
dialog.addstr(1, title_x, title)
|
dialog.addstr(1, title_x, title[:dialog_w - 4])
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
dialog.attroff(curses.A_BOLD)
|
dialog.attroff(curses.A_BOLD)
|
||||||
|
|
||||||
# Display visible lines
|
# Display visible lines
|
||||||
visible_lines = all_lines[scroll_offset:scroll_offset + dialog_h - 6]
|
visible_lines = message_lines[scroll_offset:scroll_offset + dialog_h - 6]
|
||||||
for i, line in enumerate(visible_lines):
|
for i, line in enumerate(visible_lines):
|
||||||
try:
|
try:
|
||||||
# Truncate line if too long for dialog width
|
|
||||||
truncated_line = line[:dialog_w - 4]
|
truncated_line = line[:dialog_w - 4]
|
||||||
dialog.addstr(3 + i, 2, truncated_line)
|
dialog.addstr(3 + i, 2, truncated_line)
|
||||||
except curses.error:
|
except curses.error:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Footer with scroll indicators
|
# Footer
|
||||||
if max_scroll > 0:
|
if max_scroll > 0:
|
||||||
footer = f"↑/↓ Scroll ({scroll_offset + 1}/{len(all_lines)}) Any other key to close"
|
footer = f"↑/↓ Scroll Any other key to close"
|
||||||
else:
|
else:
|
||||||
footer = "Press any key to close"
|
footer = "Press any key to close"
|
||||||
footer_x = max(2, (dialog_w - len(footer)) // 2)
|
footer_x = max(2, (dialog_w - len(footer)) // 2)
|
||||||
@@ -225,12 +300,11 @@ class TUI:
|
|||||||
scroll_offset -= 1
|
scroll_offset -= 1
|
||||||
elif key == curses.KEY_DOWN and scroll_offset < max_scroll:
|
elif key == curses.KEY_DOWN and scroll_offset < max_scroll:
|
||||||
scroll_offset += 1
|
scroll_offset += 1
|
||||||
elif key == curses.KEY_PPAGE: # Page Up
|
elif key == curses.KEY_PPAGE:
|
||||||
scroll_offset = max(0, scroll_offset - (dialog_h - 6))
|
scroll_offset = max(0, scroll_offset - (dialog_h - 6))
|
||||||
elif key == curses.KEY_NPAGE: # Page Down
|
elif key == curses.KEY_NPAGE:
|
||||||
scroll_offset = min(max_scroll, scroll_offset + (dialog_h - 6))
|
scroll_offset = min(max_scroll, scroll_offset + (dialog_h - 6))
|
||||||
else:
|
else:
|
||||||
# Any other key closes the dialog
|
|
||||||
break
|
break
|
||||||
|
|
||||||
def _save_nav_position(self):
|
def _save_nav_position(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user