mirror of
https://github.com/token2/fido2-manage.git
synced 2026-04-09 10:45:39 +00:00
Update gui-mac.py
This commit is contained in:
866
gui-mac.py
866
gui-mac.py
@@ -2,15 +2,20 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import messagebox, simpledialog, ttk
|
from tkinter import messagebox, simpledialog, ttk
|
||||||
|
|
||||||
# --- Path Resolution for pyinstaller Bundle ---
|
|
||||||
def get_fido_command_path():
|
|
||||||
|
# --- Path Resolution for fido2-token2 Binary ---
|
||||||
|
def get_fido2_binary_path():
|
||||||
"""
|
"""
|
||||||
Determines the absolute path to the fido2-token2 binary, whether running
|
Determines the absolute path to the fido2-token2 binary, whether running
|
||||||
as a script or as a bundled pyinstaller app. Enhanced for self-contained builds.
|
as a script or as a bundled pyinstaller app.
|
||||||
"""
|
"""
|
||||||
|
binary_name = "fido2-token2"
|
||||||
|
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
# We are running in a PyInstaller bundle
|
# We are running in a PyInstaller bundle
|
||||||
if hasattr(sys, '_MEIPASS'):
|
if hasattr(sys, '_MEIPASS'):
|
||||||
@@ -20,325 +25,714 @@ def get_fido_command_path():
|
|||||||
# PyInstaller one-file bundle
|
# PyInstaller one-file bundle
|
||||||
base_path = os.path.dirname(sys.executable)
|
base_path = os.path.dirname(sys.executable)
|
||||||
|
|
||||||
# Print debug info for troubleshooting
|
# Try direct path first
|
||||||
print(f"[DEBUG] Running in bundle mode, base_path: {base_path}")
|
binary_path = os.path.join(base_path, binary_name)
|
||||||
print(f"[DEBUG] Contents of base_path: {os.listdir(base_path) if os.path.exists(base_path) else 'NOT FOUND'}")
|
if os.path.exists(binary_path):
|
||||||
|
return binary_path
|
||||||
|
|
||||||
# Primary locations to check for the fido2-token2 binary
|
# Try alternative locations in the bundle
|
||||||
binary_locations = [
|
for alt_path in [
|
||||||
os.path.join(base_path, "fido2-token2"),
|
os.path.join(base_path, "Contents", "MacOS", binary_name),
|
||||||
os.path.join(base_path, "Contents", "MacOS", "fido2-token2"),
|
os.path.join(base_path, "Contents", "Frameworks", binary_name),
|
||||||
os.path.join(base_path, "Contents", "Frameworks", "fido2-token2"),
|
os.path.join(base_path, "MacOS", binary_name),
|
||||||
os.path.join(base_path, "MacOS", "fido2-token2"),
|
os.path.join(base_path, "Frameworks", binary_name),
|
||||||
os.path.join(base_path, "Frameworks", "fido2-token2"),
|
os.path.join(os.path.dirname(base_path), binary_name),
|
||||||
os.path.join(os.path.dirname(base_path), "fido2-token2"),
|
os.path.join(os.path.dirname(base_path), "MacOS", binary_name),
|
||||||
os.path.join(os.path.dirname(base_path), "Frameworks", "fido2-token2"),
|
os.path.join(os.path.dirname(base_path), "Frameworks", binary_name)
|
||||||
os.path.join(os.path.dirname(base_path), "MacOS", "fido2-token2")
|
]:
|
||||||
]
|
if os.path.exists(alt_path):
|
||||||
|
return alt_path
|
||||||
|
|
||||||
for binary_path in binary_locations:
|
return binary_path # Return the expected path even if not found
|
||||||
print(f"[DEBUG] Checking for binary: {binary_path}")
|
|
||||||
if os.path.exists(binary_path):
|
|
||||||
print(f"[DEBUG] Found fido2-token2 binary at: {binary_path}")
|
|
||||||
return binary_path
|
|
||||||
|
|
||||||
# Final fallback - return first location even if not found
|
|
||||||
print(f"[DEBUG] Binary not found, returning default: {binary_locations[0]}")
|
|
||||||
return binary_locations[0]
|
|
||||||
else:
|
else:
|
||||||
# We are running in a normal Python environment for development.
|
# We are running in a normal Python environment for development.
|
||||||
# Look for fido2-token2 binary in common development locations
|
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
dev_locations = [
|
# Try various development locations
|
||||||
os.path.join(script_dir, "build", "tools", "fido2-token2"),
|
dev_paths = [
|
||||||
os.path.join(script_dir, "tools", "fido2-token2"),
|
os.path.join(script_dir, "build", "staging", binary_name),
|
||||||
os.path.join(script_dir, "fido2-token2"),
|
os.path.join(script_dir, "staging", binary_name),
|
||||||
"./build/tools/fido2-token2",
|
os.path.join(script_dir, "build", "tools", binary_name),
|
||||||
"./tools/fido2-token2",
|
os.path.join(script_dir, "tools", binary_name),
|
||||||
"./fido2-token2",
|
os.path.join(script_dir, binary_name)
|
||||||
"fido2-token2" # System PATH lookup
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for binary_path in dev_locations:
|
for path in dev_paths:
|
||||||
if os.path.exists(binary_path):
|
if os.path.exists(path):
|
||||||
print(f"[DEBUG] Found development binary at: {binary_path}")
|
return path
|
||||||
return binary_path
|
|
||||||
|
|
||||||
# Fallback to system-wide installation
|
# Fallback to PATH lookup
|
||||||
print(f"[DEBUG] Using system PATH lookup for fido2-token2")
|
print(binary_name)
|
||||||
return "fido2-token2"
|
return binary_name
|
||||||
|
|
||||||
print("FIDO2 Manager GUI starting...")
|
# Define the command to execute directly
|
||||||
FIDO_COMMAND = get_fido_command_path()
|
FIDO2_TOKEN_CMD = get_fido2_binary_path()
|
||||||
print(f"FIDO2 Command Path: {FIDO_COMMAND}")
|
|
||||||
print(f"Script exists: {os.path.exists(FIDO_COMMAND)}")
|
|
||||||
|
|
||||||
def get_device_number_from_string(device_string):
|
# Checks the terminal emulator from which "gui.py" is executed
|
||||||
"""
|
# and sets it for the subprocess commands
|
||||||
Extract device number from device string format "Device [1] : SoloKeys"
|
TERM = os.environ.get("TERM", "x-terminal-emulator")
|
||||||
"""
|
|
||||||
import re
|
|
||||||
match = re.search(r'Device \[(\d+)\]', device_string)
|
|
||||||
if match:
|
|
||||||
return match.group(1)
|
|
||||||
return "1" # Default to device 1
|
|
||||||
# --- End of Path Resolution ---
|
|
||||||
|
|
||||||
# Global variable to store the PIN for the current session
|
# Command below for Windows
|
||||||
|
# FIDO2_TOKEN_CMD = 'fido2-token2.exe'
|
||||||
|
|
||||||
|
# Global variables
|
||||||
PIN = None
|
PIN = None
|
||||||
|
device_strings = [] # Store actual device strings for fido2-token2
|
||||||
|
|
||||||
# --- Core Functions ---
|
|
||||||
|
|
||||||
def get_device_list():
|
def get_device_list():
|
||||||
"""Gets the list of connected FIDO devices by calling the C binary directly."""
|
"""Get device list directly from fido2-token2"""
|
||||||
|
global device_strings
|
||||||
try:
|
try:
|
||||||
if not os.path.exists(FIDO_COMMAND):
|
# Execute fido2-token2 -L to list devices
|
||||||
messagebox.showerror("Dependency Error", f"Required tool not found at: {FIDO_COMMAND}")
|
result = subprocess.run([FIDO2_TOKEN_CMD, "-L"], capture_output=True, text=True)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Error executing {FIDO2_TOKEN_CMD} -L: {result.stderr}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Call fido2-token2 -L directly to list devices
|
|
||||||
result = subprocess.run([FIDO_COMMAND, "-L"], capture_output=True, text=True, check=True)
|
|
||||||
|
|
||||||
# Parse the output to format like the shell script
|
|
||||||
device_list = []
|
device_list = []
|
||||||
|
device_strings = []
|
||||||
device_count = 1
|
device_count = 1
|
||||||
|
|
||||||
for line in result.stdout.strip().split("\n"):
|
for line in result.stdout.strip().split('\n'):
|
||||||
if line.strip():
|
if line.strip() and ':' in line:
|
||||||
# Extract device info (look for parentheses with device type)
|
# Handle pcsc devices specially
|
||||||
import re
|
if 'pcsc://slot0:' in line:
|
||||||
match = re.search(r'\(([^)]+)\)', line)
|
device_string = "pcsc://slot0"
|
||||||
if match:
|
device_description = "pcsc://slot0"
|
||||||
device_type = match.group(1)
|
|
||||||
device_list.append(f"Device [{device_count}] : {device_type}")
|
|
||||||
device_count += 1
|
|
||||||
else:
|
else:
|
||||||
# Fallback if no parentheses found
|
# Extract device string - everything before the first space or tab
|
||||||
device_list.append(f"Device [{device_count}] : {line.strip()}")
|
parts = line.split()
|
||||||
device_count += 1
|
if parts:
|
||||||
|
device_string = parts[0]
|
||||||
|
# For ioreg devices, ensure we don't have trailing colon
|
||||||
|
if device_string.endswith(':'):
|
||||||
|
device_string = device_string[:-1]
|
||||||
|
else:
|
||||||
|
# Fallback: split by colon and reconstruct
|
||||||
|
colon_parts = line.split(':')
|
||||||
|
if len(colon_parts) >= 2:
|
||||||
|
device_string = ':'.join(colon_parts[:2])
|
||||||
|
else:
|
||||||
|
device_string = line.strip()
|
||||||
|
|
||||||
|
# Extract device description for display
|
||||||
|
match = re.search(r'\(([^)]+)\)', line)
|
||||||
|
if match:
|
||||||
|
device_description = match.group(1)
|
||||||
|
else:
|
||||||
|
device_description = device_string
|
||||||
|
|
||||||
|
device_strings.append(device_string)
|
||||||
|
device_list.append(f"Device [{device_count}] : {device_description}")
|
||||||
|
device_count += 1
|
||||||
|
|
||||||
return device_list
|
return device_list
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = f"Error getting device list: {e}\n\nDetails:\n{getattr(e, 'stderr', '')}"
|
print(f"Error executing device list command: {e}")
|
||||||
messagebox.showerror("Execution Error", error_message)
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_device_path_by_number(device_number):
|
|
||||||
"""Get the actual device path string for a given device number."""
|
def get_device_string(device_digit):
|
||||||
|
"""Get the actual device string for fido2-token2 command"""
|
||||||
try:
|
try:
|
||||||
# Get device list from fido2-token2 -L
|
device_index = int(device_digit) - 1
|
||||||
result = subprocess.run([FIDO_COMMAND, "-L"], capture_output=True, text=True, check=True)
|
if 0 <= device_index < len(device_strings):
|
||||||
lines = [line.strip() for line in result.stdout.split('\n') if line.strip()]
|
return device_strings[device_index]
|
||||||
|
|
||||||
# Convert device_number to index (1-based to 0-based)
|
|
||||||
device_index = int(device_number) - 1
|
|
||||||
|
|
||||||
if 0 <= device_index < len(lines):
|
|
||||||
device_line = lines[device_index]
|
|
||||||
# Extract device path (everything before the first colon and space)
|
|
||||||
# Handle both "path: info" and "path:info" formats
|
|
||||||
if ':' in device_line:
|
|
||||||
# Split on ':' and take first part, but handle "pcsc://slot0:" specially
|
|
||||||
if device_line.startswith('pcsc://'):
|
|
||||||
return "pcsc://slot0"
|
|
||||||
else:
|
|
||||||
# For other formats like "/dev/hidraw0: ..."
|
|
||||||
parts = device_line.split(':', 1)
|
|
||||||
return parts[0].strip()
|
|
||||||
else:
|
|
||||||
# If no colon, return the whole line
|
|
||||||
return device_line
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except (ValueError, IndexError):
|
||||||
print(f"[ERROR] Failed to get device path: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def execute_info_command(device_string):
|
|
||||||
"""Fetches and displays device info in the treeview."""
|
|
||||||
tree.delete(*tree.get_children())
|
|
||||||
# Extract device number from device_string
|
|
||||||
device_number = get_device_number_from_string(device_string)
|
|
||||||
|
|
||||||
# Get the actual device path for this device number
|
def execute_storage_command(device_digit):
|
||||||
device_path = get_device_path_by_number(device_number)
|
"""Execute storage command directly with fido2-token2"""
|
||||||
if not device_path:
|
global PIN
|
||||||
messagebox.showerror("Error", f"Could not find device path for device {device_number}")
|
device_string = get_device_string(device_digit)
|
||||||
|
if not device_string:
|
||||||
|
messagebox.showerror("Error", "Invalid device selection")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"[DEBUG] Using device path: {device_path}")
|
# Build command with PIN option if provided
|
||||||
|
command = [FIDO2_TOKEN_CMD, "-I", "-c"]
|
||||||
|
if PIN and PIN != "0000":
|
||||||
|
command.extend(["-w", PIN])
|
||||||
|
command.append(device_string)
|
||||||
|
|
||||||
# Build PIN arguments for fido2-token2 (-w pin)
|
try:
|
||||||
pin_args = ["-w", PIN] if PIN else []
|
result = subprocess.run(command, capture_output=True, text=True)
|
||||||
|
|
||||||
# Commands to run with fido2-token2 directly
|
if result.returncode == 0:
|
||||||
commands_to_run = {
|
# Parse the output and insert into the treeview
|
||||||
"info": [FIDO_COMMAND, "-I"] + pin_args + [device_path],
|
for line in reversed(result.stdout.splitlines()):
|
||||||
"storage": [FIDO_COMMAND, "-I", "-c"] + pin_args + [device_path],
|
if ": " in line:
|
||||||
}
|
key, value = line.split(": ", 1)
|
||||||
|
tree.insert("", 0, values=(key, value))
|
||||||
|
else:
|
||||||
|
raise subprocess.CalledProcessError(result.returncode, command)
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Error", f"Command execution failed: {e}\nOutput: {result.stderr}")
|
||||||
|
|
||||||
for key, command in commands_to_run.items():
|
|
||||||
try:
|
|
||||||
print(f"[DEBUG] Running command: {' '.join(command)}")
|
|
||||||
result = subprocess.run(command, capture_output=True, text=True)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
def execute_info_command(device_digit):
|
||||||
print(f"[DEBUG] Command {key} failed with code {result.returncode}")
|
"""Execute info command directly with fido2-token2"""
|
||||||
print(f"[DEBUG] stderr: {result.stderr}")
|
global PIN
|
||||||
|
tree.delete(*tree.get_children())
|
||||||
|
|
||||||
if "FIDO_ERR_PIN_INVALID" in result.stderr:
|
device_string = get_device_string(device_digit)
|
||||||
messagebox.showerror("Error", "Invalid PIN provided.")
|
if not device_string:
|
||||||
return # Stop processing on bad PIN
|
messagebox.showerror("Error", "Invalid device selection")
|
||||||
if "FIDO_ERR_PIN_REQUIRED" in result.stderr:
|
return
|
||||||
messagebox.showerror("Error", "A PIN is required for this operation.")
|
|
||||||
return # Stop processing
|
|
||||||
continue # Try next command even if one fails for other reasons
|
|
||||||
|
|
||||||
# Parse output and add to tree
|
# First execute storage command
|
||||||
|
storage_command = [FIDO2_TOKEN_CMD, "-I", "-c"]
|
||||||
|
if PIN and PIN != "0000":
|
||||||
|
storage_command.extend(["-w", PIN])
|
||||||
|
storage_command.append(device_string)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(storage_command, capture_output=True, text=True)
|
||||||
|
|
||||||
|
# Check for specific FIDO errors
|
||||||
|
if "FIDO_ERR_PIN_INVALID" in result.stderr:
|
||||||
|
messagebox.showerror("Error", "Invalid PIN provided")
|
||||||
|
return
|
||||||
|
if "FIDO_ERR_INVALID_ARGUMENT" in result.stderr:
|
||||||
|
messagebox.showerror("Error", "Invalid PIN provided")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if "FIDO_ERR_PIN_AUTH_BLOCKED" in result.stderr:
|
||||||
|
messagebox.showerror("Error", "Wrong PIN provided too many times. Reinsert the key")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "FIDO_ERR_PIN_REQUIRED" in result.stderr:
|
||||||
|
messagebox.showerror("Error",
|
||||||
|
"No PIN set for this key. Passkeys can be managed only with a PIN set. "
|
||||||
|
"You will be prompted to create a PIN on the next window")
|
||||||
|
set_pin_command = [FIDO2_TOKEN_CMD, "-S", device_string]
|
||||||
|
cmd_str = " ".join(set_pin_command)
|
||||||
|
|
||||||
|
# macOS: use AppleScript to run in Terminal
|
||||||
|
apple_script = f'''
|
||||||
|
tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "{cmd_str}"
|
||||||
|
end tell
|
||||||
|
'''
|
||||||
|
subprocess.Popen(["osascript", "-e", apple_script])
|
||||||
|
return
|
||||||
|
|
||||||
|
if "FIDO_ERR_INVALID_CBOR" in result.stderr:
|
||||||
|
messagebox.showerror("Error",
|
||||||
|
"This is an older key (probably FIDO2.0). No passkey management is possible "
|
||||||
|
"with this key. Only basic information will be shown.")
|
||||||
|
|
||||||
|
if "FIDO_ERR_INTERNAL" in result.stderr:
|
||||||
|
messagebox.showerror("Error",
|
||||||
|
"Internal error communicating with the device. Please try unplugging and "
|
||||||
|
"replugging the device, then refresh the device list.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Parse storage output
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
if ": " in line:
|
if ": " in line:
|
||||||
k, v = line.split(": ", 1)
|
key, value = line.split(": ", 1)
|
||||||
tree.insert("", tk.END, values=(k.strip(), v.strip()))
|
tree.insert("", tk.END, values=(key, value))
|
||||||
elif line.strip():
|
else:
|
||||||
# For lines without colon, add as single value
|
raise subprocess.CalledProcessError(result.returncode, storage_command)
|
||||||
tree.insert("", tk.END, values=(line.strip(), ""))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
messagebox.showerror("Error", f"Command '{key}' failed: {e}\nOutput: {getattr(e, 'stderr', '')}")
|
messagebox.showerror("Error", f"Storage command execution failed: {e}\nOutput: {result.stderr}")
|
||||||
|
return
|
||||||
|
|
||||||
check_passkeys_button_state()
|
# Now execute info command
|
||||||
check_changepin_button_state()
|
info_command = [FIDO2_TOKEN_CMD, "-I"]
|
||||||
|
if PIN and PIN != "0000":
|
||||||
|
info_command.extend(["-w", PIN])
|
||||||
|
info_command.append(device_string)
|
||||||
|
|
||||||
# --- UI Event Handlers ---
|
try:
|
||||||
|
result = subprocess.run(info_command, capture_output=True, text=True)
|
||||||
|
|
||||||
def set_pin_and_get_info(device_string):
|
if result.returncode == 0:
|
||||||
"""Prompts for PIN and then fetches device info."""
|
# Parse info output
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if ": " in line:
|
||||||
|
key, value = line.split(": ", 1)
|
||||||
|
tree.insert("", tk.END, values=(key, value))
|
||||||
|
else:
|
||||||
|
raise subprocess.CalledProcessError(result.returncode, info_command)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Error", f"Info command execution failed: {e}\nOutput: {result.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
def set_pin():
|
||||||
|
"""Set the PIN global variable"""
|
||||||
global PIN
|
global PIN
|
||||||
PIN = simpledialog.askstring("PIN Code", "Enter PIN code:", show="*")
|
PIN = simpledialog.askstring(
|
||||||
if PIN is not None: # Proceed even if PIN is empty, but not if cancelled
|
"PIN Code", "Enter PIN code (enter 0000 if no PIN is set/known):", show="*"
|
||||||
execute_info_command(device_string)
|
)
|
||||||
|
|
||||||
|
|
||||||
def on_device_selected(event):
|
def on_device_selected(event):
|
||||||
"""Handles the device selection from the dropdown."""
|
"""Handle device selection event"""
|
||||||
selected_text = device_var.get()
|
global PIN
|
||||||
# Robust regex to find device paths on Linux, macOS, or Windows
|
selected_device = device_var.get()
|
||||||
match = re.search(r"(/dev/\w+)|(pcsc://\S+)|(windows://\S+)|(ioreg://\S+)", selected_text)
|
# Extract the digit inside the first pair of square brackets
|
||||||
|
match = re.search(r"\[(\d+)\]", selected_device)
|
||||||
|
PIN = None
|
||||||
|
|
||||||
|
set_pin()
|
||||||
|
|
||||||
if match:
|
if match:
|
||||||
device_string = match.group(0).split(':')[0] # Get the path part before any colon
|
device_digit = match.group(1)
|
||||||
set_pin_and_get_info(device_string)
|
if PIN is not None:
|
||||||
|
execute_info_command(device_digit)
|
||||||
|
check_passkeys_button_state()
|
||||||
|
check_changepin_button_state()
|
||||||
else:
|
else:
|
||||||
messagebox.showerror("Device Error", "Could not parse device path from selection.")
|
messagebox.showinfo("Device Selected", "No digit found in the selected device")
|
||||||
|
|
||||||
|
|
||||||
|
def check_passkeys_button_state():
|
||||||
|
"""Check if the passkeys button should be enabled"""
|
||||||
|
passkeys_button_state = tk.DISABLED
|
||||||
|
for child in tree.get_children():
|
||||||
|
values = tree.item(child, "values")
|
||||||
|
if values and len(values) == 2 and values[0] == "existing rk(s)":
|
||||||
|
try:
|
||||||
|
rk_count = int(values[1])
|
||||||
|
if rk_count > 0:
|
||||||
|
passkeys_button_state = tk.NORMAL
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
passkeys_button.config(state=passkeys_button_state)
|
||||||
|
|
||||||
|
|
||||||
|
def check_changepin_button_state():
|
||||||
|
"""Check if the change PIN button should be enabled"""
|
||||||
|
change_pin_button_state = tk.DISABLED
|
||||||
|
for child in tree.get_children():
|
||||||
|
values = tree.item(child, "values")
|
||||||
|
if values and len(values) == 2 and values[0] == "remaining rk(s)":
|
||||||
|
try:
|
||||||
|
rk_count = int(values[1])
|
||||||
|
if rk_count > 0:
|
||||||
|
change_pin_button_state = tk.NORMAL
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
change_pin_button.config(state=change_pin_button_state)
|
||||||
|
|
||||||
|
|
||||||
|
def on_passkeys_button_click():
|
||||||
|
"""Handle passkeys button click"""
|
||||||
|
global PIN
|
||||||
|
selected_device = device_var.get()
|
||||||
|
match = re.search(r"\[(\d+)\]", selected_device)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
device_digit = match.group(1)
|
||||||
|
device_string = get_device_string(device_digit)
|
||||||
|
|
||||||
|
if not device_string:
|
||||||
|
messagebox.showerror("Error", "Invalid device selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
if PIN is not None:
|
||||||
|
# Execute command to get resident keys (list domains)
|
||||||
|
command = [FIDO2_TOKEN_CMD, "-L", "-r"]
|
||||||
|
if PIN and PIN != "0000":
|
||||||
|
command.extend(["-w", PIN])
|
||||||
|
command.append(device_string)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True)
|
||||||
|
if result.returncode == 0:
|
||||||
|
# Parse domains from output
|
||||||
|
domains = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
match = re.search(r"= (.+)$", line)
|
||||||
|
if match:
|
||||||
|
domains.append(match.group(1))
|
||||||
|
|
||||||
|
# Execute command for each domain
|
||||||
|
cumulated_output = []
|
||||||
|
for domain in domains:
|
||||||
|
domain_command = [FIDO2_TOKEN_CMD, "-L", "-k", domain]
|
||||||
|
if PIN and PIN != "0000":
|
||||||
|
domain_command.extend(["-w", PIN])
|
||||||
|
domain_command.append(device_string)
|
||||||
|
|
||||||
|
domain_result = subprocess.run(domain_command, capture_output=True, text=True)
|
||||||
|
|
||||||
|
if domain_result.returncode == 0:
|
||||||
|
# Process the output line by line
|
||||||
|
processed_lines = []
|
||||||
|
for line in domain_result.stdout.splitlines():
|
||||||
|
if line.strip():
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 3:
|
||||||
|
key_id = parts[0]
|
||||||
|
credential_id = parts[1]
|
||||||
|
user_field = " ".join(parts[2:4]) if len(parts) > 3 else parts[2]
|
||||||
|
email_field = " ".join(parts[4:6]) if len(parts) > 5 else ""
|
||||||
|
|
||||||
|
if user_field == "(null)":
|
||||||
|
user_field = ""
|
||||||
|
|
||||||
|
# Determine if user_field is an email
|
||||||
|
if "@" in user_field:
|
||||||
|
email = user_field
|
||||||
|
user = ""
|
||||||
|
else:
|
||||||
|
user = user_field
|
||||||
|
email = email_field
|
||||||
|
|
||||||
|
processed_lines.append(f"Credential ID: {credential_id}, User: {user} {email}")
|
||||||
|
|
||||||
|
cumulated_output.append(f"Domain: {domain}\n" + "\n".join(processed_lines))
|
||||||
|
else:
|
||||||
|
raise subprocess.CalledProcessError(domain_result.returncode, domain_command)
|
||||||
|
|
||||||
|
# Show cumulated output in new window
|
||||||
|
cumulated_output_str = "\n\n".join(cumulated_output)
|
||||||
|
show_output_in_new_window(cumulated_output_str, device_digit)
|
||||||
|
else:
|
||||||
|
raise subprocess.CalledProcessError(result.returncode, command)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Error", f"Command execution failed: {e}\nOutput: {result.stderr}")
|
||||||
|
else:
|
||||||
|
messagebox.showinfo("Device Selected", "No digit found in the selected device")
|
||||||
|
|
||||||
|
|
||||||
|
def change_pin():
|
||||||
|
"""Change PIN using direct fido2-token2 command"""
|
||||||
|
selected_device = device_var.get()
|
||||||
|
match = re.search(r"\[(\d+)\]", selected_device)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
device_digit = match.group(1)
|
||||||
|
device_string = get_device_string(device_digit)
|
||||||
|
|
||||||
|
if not device_string:
|
||||||
|
messagebox.showerror("Error", "Invalid device selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
command = [FIDO2_TOKEN_CMD, "-C", device_string]
|
||||||
|
cmd_str = " ".join(command)
|
||||||
|
|
||||||
|
# macOS: use AppleScript to run in Terminal
|
||||||
|
apple_script = f'''
|
||||||
|
tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "{cmd_str}"
|
||||||
|
end tell
|
||||||
|
'''
|
||||||
|
subprocess.Popen(["osascript", "-e", apple_script])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_combobox():
|
def refresh_combobox():
|
||||||
"""Refreshes the device list in the dropdown."""
|
"""Refresh the device combobox"""
|
||||||
device_list = get_device_list()
|
|
||||||
if not device_list:
|
|
||||||
device_combobox['values'] = ["No FIDO devices found."]
|
|
||||||
else:
|
|
||||||
device_combobox['values'] = device_list
|
|
||||||
device_combobox.set("")
|
device_combobox.set("")
|
||||||
tree.delete(*tree.get_children())
|
tree.delete(*tree.get_children())
|
||||||
passkeys_button.config(state=tk.DISABLED)
|
passkeys_button.config(state=tk.DISABLED)
|
||||||
change_pin_button.config(state=tk.DISABLED)
|
change_pin_button.config(state=tk.DISABLED)
|
||||||
|
|
||||||
|
device_list = get_device_list()
|
||||||
|
if not device_list:
|
||||||
|
print("No devices found.")
|
||||||
|
device_combobox["values"] = device_list
|
||||||
|
|
||||||
|
|
||||||
|
def show_output_in_new_window(output, device_digit):
|
||||||
|
"""Show output in a new window for passkey management"""
|
||||||
|
new_window = tk.Toplevel(root)
|
||||||
|
new_window.geometry("800x650")
|
||||||
|
new_window.title("Resident Keys / Passkeys")
|
||||||
|
|
||||||
|
# Create Treeview for displaying output
|
||||||
|
tree_new_window = ttk.Treeview(
|
||||||
|
new_window, columns=("Domain", "Credential ID", "User"), show="headings"
|
||||||
|
)
|
||||||
|
tree_new_window.heading("Domain", text="Domain")
|
||||||
|
tree_new_window.heading("Credential ID", text="Credential ID")
|
||||||
|
tree_new_window.heading("User", text="User")
|
||||||
|
tree_new_window.pack(expand=True, fill=tk.BOTH, padx=10, pady=10)
|
||||||
|
|
||||||
|
# Add scrollbars
|
||||||
|
tree_scrollbar_y = ttk.Scrollbar(new_window, orient="vertical", command=tree_new_window.yview)
|
||||||
|
tree_scrollbar_y.pack(side="right", fill="y")
|
||||||
|
tree_new_window.configure(yscrollcommand=tree_scrollbar_y.set)
|
||||||
|
|
||||||
|
tree_scrollbar_x = ttk.Scrollbar(new_window, orient="horizontal", command=tree_new_window.xview)
|
||||||
|
tree_scrollbar_x.pack(side="bottom", fill="x")
|
||||||
|
tree_new_window.configure(xscrollcommand=tree_scrollbar_x.set)
|
||||||
|
|
||||||
|
# Parse output and insert into Treeview
|
||||||
|
current_domain = ""
|
||||||
|
for line in output.splitlines():
|
||||||
|
if line.startswith("Domain: "):
|
||||||
|
current_domain = line.split("Domain: ")[1].strip()
|
||||||
|
elif "Credential ID: " in line and "User: " in line:
|
||||||
|
credential_id = line.split("Credential ID: ")[1].split(",")[0].strip()
|
||||||
|
user = line.split("User: ")[1].strip()
|
||||||
|
user = re.sub(re.escape(credential_id), "", user).strip()
|
||||||
|
tree_new_window.insert("", tk.END, values=(current_domain, credential_id, user))
|
||||||
|
|
||||||
|
def show_selected_value():
|
||||||
|
"""Delete selected passkey"""
|
||||||
|
selected_item = tree_new_window.selection()
|
||||||
|
if selected_item:
|
||||||
|
credential_id = tree_new_window.item(selected_item, "values")[1]
|
||||||
|
device_string = get_device_string(device_digit)
|
||||||
|
|
||||||
|
if not device_string:
|
||||||
|
messagebox.showerror("Error", "Invalid device selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_window.destroy()
|
||||||
|
command = [FIDO2_TOKEN_CMD, "-D", "-i", credential_id, device_string]
|
||||||
|
cmd_str = " ".join(command)
|
||||||
|
# macOS: use AppleScript to run in Terminal
|
||||||
|
apple_script = f'''
|
||||||
|
tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "{cmd_str}"
|
||||||
|
end tell
|
||||||
|
'''
|
||||||
|
subprocess.Popen(["osascript", "-e", apple_script])
|
||||||
|
|
||||||
|
|
||||||
|
# Create delete button
|
||||||
|
show_value_button = tk.Button(new_window, text="delete passkey", command=show_selected_value)
|
||||||
|
show_value_button.pack(pady=10)
|
||||||
|
|
||||||
|
|
||||||
def show_about_message():
|
def show_about_message():
|
||||||
"""Displays the about dialog."""
|
"""Show about dialog"""
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"About",
|
"About",
|
||||||
"FIDO2 Manager\n\n"
|
"The FIDO2.1 Security Key Management Tool is a utility designed to manage and interact with FIDO2.1 security keys.\r\n"
|
||||||
"A utility to manage and interact with FIDO2 security keys.\n\n"
|
"It provides functionalities to view information, manage relying parties, and perform various operations on connected FIDO2.1 devices.\r\n\r\n"
|
||||||
"(c) TOKEN2 Sàrl, Yubico AB"
|
"(c)TOKEN2 Sarl\r\nVersoix, Switzerland"
|
||||||
)
|
)
|
||||||
|
|
||||||
def check_passkeys_button_state():
|
|
||||||
"""Enables 'Passkeys' button if resident keys exist."""
|
|
||||||
state = tk.DISABLED
|
|
||||||
for child in tree.get_children():
|
|
||||||
values = tree.item(child, "values")
|
|
||||||
if values and len(values) > 1 and values[0] == "existing rk(s)":
|
|
||||||
try:
|
|
||||||
if int(values[1]) > 0:
|
|
||||||
state = tk.NORMAL
|
|
||||||
break
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
pass
|
|
||||||
passkeys_button.config(state=state)
|
|
||||||
|
|
||||||
def check_changepin_button_state():
|
def factory_reset():
|
||||||
"""Enables 'Change PIN' button if PIN is set."""
|
# Step 1: Confirm factory reset
|
||||||
state = tk.DISABLED
|
confirm_reset = messagebox.askyesno(
|
||||||
for child in tree.get_children():
|
"Confirm Factory Reset",
|
||||||
values = tree.item(child, "values")
|
"Are you sure you want to factory reset?"
|
||||||
if values and len(values) > 1 and values[0] == "pin retries":
|
)
|
||||||
state = tk.NORMAL
|
if not confirm_reset:
|
||||||
break
|
return
|
||||||
change_pin_button.config(state=state)
|
|
||||||
|
|
||||||
# Placeholder functions for buttons
|
# Step 2: Ask to unplug and replug the key
|
||||||
def on_passkeys_button_click():
|
replug_key = messagebox.askyesno(
|
||||||
messagebox.showinfo("Not Implemented", "Passkey management functionality is not yet implemented.")
|
"Replug Key",
|
||||||
|
"Please unplug the key, plug it back in, then click Yes to continue. Important - the reset command can be completed only within 10 seconds after plugging the key"
|
||||||
|
)
|
||||||
|
if not replug_key:
|
||||||
|
return
|
||||||
|
|
||||||
def change_pin():
|
# Step 3: Tell user to touch key when it starts blinking
|
||||||
messagebox.showinfo("Not Implemented", "Change PIN functionality is not yet implemented.")
|
messagebox.showinfo(
|
||||||
|
"Touch Key",
|
||||||
|
"When the key starts blinking, touch the sensor to complete reset."
|
||||||
|
)
|
||||||
|
|
||||||
# --- GUI Layout ---
|
try:
|
||||||
|
# Run -L to list the device
|
||||||
|
list_command = [FIDO2_TOKEN_CMD, "-L"]
|
||||||
|
result = subprocess.run(list_command, capture_output=True, text=True, check=True)
|
||||||
|
output = result.stdout.strip()
|
||||||
|
|
||||||
|
# Extract string before first colon (e.g., ioreg://4302783856)
|
||||||
|
match = re.search(r"(ioreg://\d+):", output)
|
||||||
|
if not match:
|
||||||
|
messagebox.showerror("Error", "No valid device found in output")
|
||||||
|
return
|
||||||
|
|
||||||
|
device_string = match.group(1)
|
||||||
|
|
||||||
|
# Run -R with extracted device string
|
||||||
|
reset_command = [FIDO2_TOKEN_CMD, "-R", device_string]
|
||||||
|
subprocess.run(reset_command, check=True)
|
||||||
|
|
||||||
|
messagebox.showinfo("Success", "Factory reset command sent successfully.")
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
messagebox.showerror("Error", f"Command failed:\n{e.stderr}")
|
||||||
|
except Exception as e:
|
||||||
|
messagebox.showerror("Error", f"Failed to reset: {e}")
|
||||||
|
|
||||||
|
def terminal_path():
|
||||||
|
bash_script = """#!/bin/bash
|
||||||
|
# CLI installer for FIDO2 Manager
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_PATH="/Applications/fido2-manage.app"
|
||||||
|
CLI_SCRIPT="$APP_PATH/Contents/MacOS/fido2-manage-mac.sh"
|
||||||
|
SYMLINK_TARGET="/usr/local/bin/fido2-manage"
|
||||||
|
|
||||||
|
echo "=== FIDO2 Manager CLI Installer ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ ! -f "$CLI_SCRIPT" ]]; then
|
||||||
|
echo "❌ FIDO2 Manager app not found at: $APP_PATH"
|
||||||
|
echo "Please install the app first by dragging it to Applications folder."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Found FIDO2 Manager app"
|
||||||
|
|
||||||
|
if [[ -L "$SYMLINK_TARGET" ]]; then
|
||||||
|
existing_target=$(readlink "$SYMLINK_TARGET")
|
||||||
|
if [[ "$existing_target" == "$CLI_SCRIPT" ]]; then
|
||||||
|
echo "✅ CLI already installed - fido2-manage command is available"
|
||||||
|
echo ""
|
||||||
|
echo "Test it: fido2-manage -help"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "⚠️ Existing fido2-manage command found pointing to: $existing_target"
|
||||||
|
echo "Do you want to replace it? (y/N)"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Installation cancelled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo rm -f "$SYMLINK_TARGET"
|
||||||
|
fi
|
||||||
|
elif [[ -f "$SYMLINK_TARGET" ]]; then
|
||||||
|
echo "⚠️ Existing fido2-manage file found (not a symlink)"
|
||||||
|
echo "Do you want to replace it? (y/N)"
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Installation cancelled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo rm -f "$SYMLINK_TARGET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating symlink: $SYMLINK_TARGET -> $CLI_SCRIPT"
|
||||||
|
if sudo ln -sf "$CLI_SCRIPT" "$SYMLINK_TARGET"; then
|
||||||
|
echo "✅ CLI installed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "You can now use: fido2-manage -help"
|
||||||
|
echo "Examples:"
|
||||||
|
echo " fido2-manage -list"
|
||||||
|
echo " fido2-manage -info -device 1"
|
||||||
|
echo " fido2-manage -storage -device 1"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "❌ Failed to create symlink. You may need administrator privileges."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -p "Press Enter to close this window..."
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Save script to a temp file
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.sh') as f:
|
||||||
|
f.write(bash_script)
|
||||||
|
script_path = f.name
|
||||||
|
|
||||||
|
os.chmod(script_path, 0o755)
|
||||||
|
|
||||||
|
# Escape the path for AppleScript
|
||||||
|
escaped_path = script_path.replace(" ", "\\ ")
|
||||||
|
|
||||||
|
# AppleScript to run in Terminal
|
||||||
|
applescript = f'''
|
||||||
|
tell application "Terminal"
|
||||||
|
activate
|
||||||
|
do script "{escaped_path}"
|
||||||
|
end tell
|
||||||
|
'''
|
||||||
|
|
||||||
|
subprocess.run(["osascript", "-e", applescript])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Create main application window
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.title("FIDO2 Manager")
|
|
||||||
root.geometry("800x600")
|
root.geometry("800x600")
|
||||||
root.minsize(700, 400)
|
root.title("FIDO2.1 Manager - Python GUI 0.2 - (c) Token2 ")
|
||||||
|
|
||||||
# Main container
|
# Create top frame for controls
|
||||||
main_frame = ttk.Frame(root, padding="10")
|
top_frame = ttk.Frame(root)
|
||||||
main_frame.pack(expand=True, fill="both")
|
top_frame.pack(side=tk.TOP, fill=tk.X)
|
||||||
|
|
||||||
# --- Top Frame: Device Selection & Refresh ---
|
# Create label for dropdown
|
||||||
top_frame = ttk.Frame(main_frame)
|
label = tk.Label(top_frame, text="Select Device:")
|
||||||
top_frame.pack(side="top", fill="x", pady=(0, 10))
|
label.pack(side=tk.LEFT, padx=10, pady=10)
|
||||||
|
|
||||||
ttk.Label(top_frame, text="Select Device:").pack(side="left")
|
# Create ComboBox and populate with device list
|
||||||
|
device_list = get_device_list()
|
||||||
|
if not device_list:
|
||||||
|
device_list = ["No devices found."]
|
||||||
|
|
||||||
device_var = tk.StringVar()
|
device_var = tk.StringVar()
|
||||||
device_combobox = ttk.Combobox(top_frame, textvariable=device_var, state="readonly")
|
device_combobox = ttk.Combobox(top_frame, textvariable=device_var, values=device_list, width=60)
|
||||||
device_combobox.pack(side="left", expand=True, fill="x", padx=5)
|
device_combobox.pack(side=tk.LEFT, padx=10, pady=10)
|
||||||
device_combobox.bind("<<ComboboxSelected>>", on_device_selected)
|
device_combobox.bind("<<ComboboxSelected>>", on_device_selected)
|
||||||
|
|
||||||
refresh_button = ttk.Button(top_frame, text="Refresh", command=refresh_combobox)
|
# Create refresh button
|
||||||
refresh_button.pack(side="left")
|
refresh_button = tk.Button(top_frame, text="Refresh", command=refresh_combobox)
|
||||||
|
refresh_button.pack(side=tk.LEFT, padx=10, pady=10)
|
||||||
|
|
||||||
# --- Center Frame: Information Treeview ---
|
# Create Treeview for displaying output
|
||||||
tree_frame = ttk.Frame(main_frame)
|
tree_frame = ttk.Frame(root)
|
||||||
tree_frame.pack(expand=True, fill="both")
|
tree_frame.pack(expand=True, fill=tk.BOTH, padx=10, pady=10)
|
||||||
|
|
||||||
|
tree_scrollbar_y = ttk.Scrollbar(tree_frame, orient="vertical")
|
||||||
|
tree_scrollbar_x = ttk.Scrollbar(tree_frame, orient="horizontal")
|
||||||
|
|
||||||
|
tree = ttk.Treeview(
|
||||||
|
tree_frame,
|
||||||
|
columns=("Key", "Value"),
|
||||||
|
show="headings",
|
||||||
|
yscrollcommand=tree_scrollbar_y.set,
|
||||||
|
xscrollcommand=tree_scrollbar_x.set,
|
||||||
|
)
|
||||||
|
|
||||||
|
tree_scrollbar_y.config(command=tree.yview)
|
||||||
|
tree_scrollbar_x.config(command=tree.xview)
|
||||||
|
tree_scrollbar_y.pack(side="right", fill="y")
|
||||||
|
tree_scrollbar_x.pack(side="bottom", fill="x")
|
||||||
|
|
||||||
tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings")
|
|
||||||
tree.heading("Key", text="Key")
|
tree.heading("Key", text="Key")
|
||||||
tree.heading("Value", text="Value")
|
tree.heading("Value", text="Value")
|
||||||
tree.column("Key", width=200, stretch=tk.NO, anchor="w")
|
tree.pack(expand=True, fill=tk.BOTH)
|
||||||
tree.column("Value", width=550, anchor="w")
|
|
||||||
|
|
||||||
# Scrollbars
|
# Create buttons
|
||||||
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview)
|
passkeys_button = ttk.Button(root, text="Passkeys", state=tk.DISABLED, command=on_passkeys_button_click)
|
||||||
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=tree.xview)
|
passkeys_button.pack(side=tk.LEFT, padx=5, pady=10)
|
||||||
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
|
||||||
|
|
||||||
vsb.pack(side="right", fill="y")
|
change_pin_button = ttk.Button(root, text="Change PIN", state=tk.DISABLED, command=change_pin)
|
||||||
hsb.pack(side="bottom", fill="x")
|
change_pin_button.pack(side=tk.LEFT, padx=5, pady=10)
|
||||||
tree.pack(side="left", expand=True, fill="both")
|
|
||||||
|
|
||||||
# --- Bottom Frame: Action Buttons ---
|
#factory_reset_button = tk.Button(root, text="Factory Reset", command=factory_reset )
|
||||||
bottom_frame = ttk.Frame(main_frame)
|
#factory_reset_button.pack(side=tk.LEFT, padx=10, pady=10)
|
||||||
bottom_frame.pack(side="bottom", fill="x", pady=(10, 0))
|
|
||||||
|
|
||||||
passkeys_button = ttk.Button(bottom_frame, text="Passkeys", state=tk.DISABLED, command=on_passkeys_button_click)
|
terminal_path = tk.Button(root, text="fido2-manage CLI", command=terminal_path )
|
||||||
passkeys_button.pack(side="left")
|
terminal_path.pack(side=tk.LEFT, padx=15, pady=10)
|
||||||
|
|
||||||
change_pin_button = ttk.Button(bottom_frame, text="Change PIN", state=tk.DISABLED, command=change_pin)
|
about_button = ttk.Button(root, text="About", command=show_about_message)
|
||||||
change_pin_button.pack(side="left", padx=5)
|
about_button.pack(side=tk.RIGHT, padx=5, pady=10)
|
||||||
|
|
||||||
about_button = ttk.Button(bottom_frame, text="About", command=show_about_message)
|
# Run the main loop
|
||||||
about_button.pack(side="right")
|
|
||||||
|
|
||||||
# --- Initial Load ---
|
|
||||||
refresh_combobox()
|
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
|
|||||||
Reference in New Issue
Block a user