diff --git a/build_macos.sh b/build_macos.sh new file mode 100644 index 0000000..13f2da8 --- /dev/null +++ b/build_macos.sh @@ -0,0 +1,128 @@ +#!/bin/bash +set -eo pipefail + +# --- Configuration --- +APP_NAME="fido2-manage" +CLI_EXECUTABLE_NAME="fido2-token2" +FINAL_APP_NAME="${APP_NAME}.app" +DMG_NAME="${APP_NAME}.dmg" +VOL_NAME="FIDO2 Manager" +BUILD_DIR="build" +DIST_DIR="dist" +STAGING_DIR="${BUILD_DIR}/staging" # A clean directory for our portable binaries + +# --- Helper Functions --- +info() { + echo "[INFO] $1" +} + +fatal() { + echo "[FATAL] $1" >&2 + exit 1 +} + +check_command() { + if ! command -v "$1" &> /dev/null; then + fatal "'$1' is not installed. Please install it first for the build environment." + fi +} + +# --- Build Steps --- + +# 1. Prerequisite Checks for the Build Machine +info "Checking build machine prerequisites..." +check_command "cmake" +check_command "hdiutil" +check_command "otool" +check_command "install_name_tool" +if ! command -v "brew" &> /dev/null; then + fatal "Homebrew is not installed. It is required to fetch build dependencies." +fi +dependencies=("pkg-config" "openssl@3" "libcbor" "zlib" "python-tk") +info "Checking Homebrew dependencies..." +for dep in "${dependencies[@]}"; do + if ! brew list "$dep" &>/dev/null; then + info "Dependency '$dep' not found. Installing with Homebrew..." + brew install "$dep" || fatal "Failed to install '$dep'." + fi +done +PYTHON_EXEC="/opt/homebrew/bin/python3" + +# 2. Setup Python environment and install PyInstaller +info "Setting up Python virtual environment and installing PyInstaller..." +if [ -d ".venv" ]; then rm -rf ".venv"; fi +"$PYTHON_EXEC" -m venv .venv +source .venv/bin/activate +pip install --upgrade pip +# Let pip choose the latest compatible version of PyInstaller +pip install pyinstaller +deactivate + +# 3. Build the C++ binary +info "Building the C++ binary: ${CLI_EXECUTABLE_NAME}..." +# Clean up old build artifacts to ensure a fresh build +if [ -d "$BUILD_DIR" ]; then rm -rf "$BUILD_DIR"; fi +if [ -d "$DIST_DIR" ]; then rm -rf "$DIST_DIR"; fi +if [ -f "${APP_NAME}.spec" ]; then rm -f "${APP_NAME}.spec"; fi + +mkdir -p "$STAGING_DIR" +cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES="arm64" +cmake --build "$BUILD_DIR" --config Release +CLI_BINARY_PATH="${BUILD_DIR}/tools/${CLI_EXECUTABLE_NAME}" +if [ ! -f "$CLI_BINARY_PATH" ]; then + fatal "Build failed. C++ Executable not found." +fi + +# 4. Make the C++ binary and its dependencies portable +info "Making the C++ binary and its libraries portable..." +if [ ! -f "./bundle_libs.sh" ]; then + fatal "./bundle_libs.sh not found. Please create it first." +fi +chmod +x ./bundle_libs.sh +# The bundle_libs script copies the binary and its dylib dependencies into the staging folder +# and corrects their internal linkage paths. +./bundle_libs.sh "$STAGING_DIR" "$CLI_BINARY_PATH" + +# 4.1 Fix library version compatibility issues +info "Fixing library version compatibility..." +cd "$STAGING_DIR" +# Create symlink for libcbor version compatibility +if [ -f "libcbor.0.12.dylib" ] && [ ! -f "libcbor.0.11.dylib" ]; then + ln -sf libcbor.0.12.dylib libcbor.0.11.dylib +fi +cd .. + +# 4.2 Fix library linking (this must be done on macOS) +info "Fixing library linking..." +if [ ! -f "./fix_macos_linking.sh" ]; then + fatal "./fix_macos_linking.sh not found. Please run this script to create it." +fi +chmod +x ./fix_macos_linking.sh +./fix_macos_linking.sh + +info "Portable binary and libraries are in ${STAGING_DIR}. Verifying linkage..." +# This check is for you to confirm it worked. All paths should start with @rpath or be system paths. +otool -L "${STAGING_DIR}/${CLI_EXECUTABLE_NAME}" + +# 5. Create the standalone macOS app using PyInstaller +info "Creating the standalone .app bundle with PyInstaller..." +source .venv/bin/activate +# Bundle the Python script and include the entire staging directory as data. +# PyInstaller will place the contents of the staging directory in the root of the app bundle. +pyinstaller --name "$APP_NAME" \ + --windowed \ + --noconsole \ + --add-data "${STAGING_DIR}/*:." \ + gui-mac.py +deactivate + +# 6. Package the final .app into a .dmg +info "Packaging into ${DMG_NAME}..." +APP_BUNDLE_PATH="${DIST_DIR}/${FINAL_APP_NAME}" +FINAL_DMG_PATH="${DIST_DIR}/${DMG_NAME}" + +if [ -f "$FINAL_DMG_PATH" ]; then rm -f "$FINAL_DMG_PATH"; fi + +hdiutil create -fs HFS+ -srcfolder "$APP_BUNDLE_PATH" -volname "$VOL_NAME" "$FINAL_DMG_PATH" + +info "Process complete! The final distributable file is: ${FINAL_DMG_PATH}" diff --git a/bundle_libs.sh b/bundle_libs.sh new file mode 100644 index 0000000..025d892 --- /dev/null +++ b/bundle_libs.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# +# Standalone script to bundle Homebrew libraries with a macOS binary +# This makes the binary portable and self-contained +# +set -e + +# Configuration +TARGET_DIR="$1" +BINARY_PATH="$2" + +if [[ ! -d "$TARGET_DIR" ]] || [[ ! -f "$BINARY_PATH" ]]; then + echo "Usage: $0 " + echo "Example: $0 ./staging ./build/tools/fido2-token2" + exit 1 +fi + +# Copy the main binary into the target directory +BINARY_NAME=$(basename "$BINARY_PATH") +cp "$BINARY_PATH" "$TARGET_DIR/" +PORTABLE_BINARY_PATH="${TARGET_DIR}/${BINARY_NAME}" + +echo "--- Making ${BINARY_NAME} portable ---" +echo "Target directory: $TARGET_DIR" + +# Keep track of processed libraries to avoid infinite loops +processed_libs=":" + +# Function to process a single binary (executable or library) +process_binary() { + local file_to_fix="$1" + local depth="${2:-0}" + local indent=$(printf "%*s" $((depth * 2)) "") + + echo "${indent}Processing: $(basename "$file_to_fix")" + + # Get the list of Homebrew library dependencies, removing trailing colons from otool output + local deps=$(otool -L "$file_to_fix" 2>/dev/null | grep '/opt/homebrew/' | awk '{print $1}' | tr -d ':' | grep -v "^$file_to_fix$" || true) + + if [[ -z "$deps" ]]; then + echo "${indent} No Homebrew dependencies found" + return + fi + + while IFS= read -r lib; do + if [[ -z "$lib" ]]; then + continue + fi + + local lib_name=$(basename "$lib") + local new_lib_path="${TARGET_DIR}/${lib_name}" + + # Skip if already processed + if echo "$processed_libs" | grep -q ":${lib_name}:"; then + echo "${indent} -> ${lib_name} (already processed, updating reference)" + install_name_tool -change "$lib" "@executable_path/${lib_name}" "$file_to_fix" 2>/dev/null || { + echo "${indent} Warning: Could not update reference to ${lib_name}" + } + continue + fi + + # Mark as processed + processed_libs="${processed_libs}${lib_name}:" + + # Copy the library if it doesn't exist + if [[ ! -f "$new_lib_path" ]]; then + echo "${indent} -> Copying ${lib_name}" + if ! cp "$lib" "$TARGET_DIR/"; then + echo "${indent} Error: Could not copy ${lib}" + continue + fi + chmod 755 "$new_lib_path" + fi + + # Update the dependency path in the current binary + echo "${indent} -> Updating reference to ${lib_name}" + install_name_tool -change "$lib" "@executable_path/${lib_name}" "$file_to_fix" 2>/dev/null || { + echo "${indent} Warning: Could not update reference to ${lib_name}" + } + + # Set the library's own ID to be relative (only for libraries, not the main executable) + if [[ "$new_lib_path" != "$PORTABLE_BINARY_PATH" ]]; then + install_name_tool -id "@executable_path/${lib_name}" "$new_lib_path" 2>/dev/null || { + echo "${indent} Warning: Could not set ID for ${lib_name}" + } + fi + + # Recursively process this library's dependencies + if [[ -f "$new_lib_path" ]] && [[ $depth -lt 5 ]]; then + process_binary "$new_lib_path" $((depth + 1)) + fi + + done <<< "$deps" +} + +# Start processing with the main executable +echo "Starting dependency analysis..." +process_binary "$PORTABLE_BINARY_PATH" + +echo "" +echo "--- Bundling Summary ---" +echo "Bundled libraries:" +ls -la "$TARGET_DIR" + +echo "" +echo "--- Final Verification ---" +echo "Main executable dependencies:" +otool -L "$PORTABLE_BINARY_PATH" + +echo "" +echo "--- Checking for remaining external dependencies ---" +remaining_deps=$(otool -L "$PORTABLE_BINARY_PATH" | grep -E '/opt/homebrew/|/usr/local/' | grep -v '@executable_path' || true) +if [[ -n "$remaining_deps" ]]; then + echo "WARNING: Some external dependencies remain:" + echo "$remaining_deps" +else + echo "SUCCESS: All external dependencies have been bundled!" +fi + +echo "" +echo "--- Library ID Verification ---" +for lib in "$TARGET_DIR"/*.dylib; do + if [[ -f "$lib" ]]; then + lib_name=$(basename "$lib") + lib_id=$(otool -D "$lib" | tail -n1) + echo "${lib_name}: ${lib_id}" + fi +done + +echo "" +echo "Bundling complete for ${BINARY_NAME}" \ No newline at end of file diff --git a/fix_macos_linking.sh b/fix_macos_linking.sh new file mode 100644 index 0000000..31da942 --- /dev/null +++ b/fix_macos_linking.sh @@ -0,0 +1,103 @@ +#!/bin/bash +# Script to fix macOS library linking for fido2-token2 binary +# This script should be run on macOS with proper tools + +set -e + +STAGING_DIR="build/staging" +BINARY_NAME="fido2-token2" +BINARY_PATH="${STAGING_DIR}/${BINARY_NAME}" + +# Also check alternative paths +if [[ ! -f "$BINARY_PATH" ]]; then + STAGING_DIR="staging" + BINARY_PATH="${STAGING_DIR}/${BINARY_NAME}" +fi + +echo "=== Fixing macOS Library Linking ===" +echo "Target binary: $BINARY_PATH" + +# Check if we're on macOS +if [[ "$OSTYPE" != "darwin"* ]]; then + echo "ERROR: This script must be run on macOS" + echo "Please run this script on your macOS build machine" + exit 1 +fi + +# Check if required tools are available +for tool in otool install_name_tool; do + if ! command -v "$tool" &> /dev/null; then + echo "ERROR: $tool is required but not found" + exit 1 + fi +done + +# Check if binary exists +if [[ ! -f "$BINARY_PATH" ]]; then + echo "ERROR: Binary not found at $BINARY_PATH" + exit 1 +fi + +echo "Current library dependencies:" +otool -L "$BINARY_PATH" + +echo "" +echo "=== Fixing Library References ===" + +# Fix libcbor reference +echo "Fixing libcbor reference..." +install_name_tool -change "/opt/homebrew/opt/libcbor/lib/libcbor.0.11.dylib" "@executable_path/libcbor.0.11.dylib" "$BINARY_PATH" +install_name_tool -change "/opt/homebrew/Cellar/libcbor/0.12.0/lib/libcbor.0.11.dylib" "@executable_path/libcbor.0.11.dylib" "$BINARY_PATH" + +# Fix OpenSSL reference +echo "Fixing OpenSSL reference..." +install_name_tool -change "/opt/homebrew/opt/openssl@3/lib/libcrypto.3.dylib" "@executable_path/libcrypto.3.dylib" "$BINARY_PATH" + +# Fix libfido2 @rpath reference (from local build) +echo "Fixing libfido2 @rpath reference..." +install_name_tool -change "@rpath/libfido2.1.dylib" "@executable_path/libfido2.1.dylib" "$BINARY_PATH" + +# Fix any other homebrew references +echo "Checking for remaining homebrew references..." +homebrew_deps=$(otool -L "$BINARY_PATH" | grep -E '/opt/homebrew/|/usr/local/' | awk '{print $1}' || true) + +if [[ -n "$homebrew_deps" ]]; then + echo "Found additional homebrew dependencies to fix:" + while IFS= read -r dep; do + if [[ -n "$dep" ]]; then + lib_name=$(basename "$dep") + echo " Fixing: $dep -> @executable_path/$lib_name" + install_name_tool -change "$dep" "@executable_path/$lib_name" "$BINARY_PATH" + fi + done <<< "$homebrew_deps" +fi + +# Fix library IDs for the bundled libraries +echo "" +echo "=== Fixing Library IDs ===" +for lib in "${STAGING_DIR}"/*.dylib; do + if [[ -f "$lib" ]]; then + lib_name=$(basename "$lib") + echo "Setting ID for $lib_name" + install_name_tool -id "@executable_path/$lib_name" "$lib" + fi +done + +echo "" +echo "=== Final Verification ===" +echo "Updated library dependencies:" +otool -L "$BINARY_PATH" + +echo "" +echo "Checking for remaining external dependencies..." +remaining_deps=$(otool -L "$BINARY_PATH" | grep -E '/opt/homebrew/|/usr/local/' | grep -v '@executable_path' || true) +if [[ -n "$remaining_deps" ]]; then + echo "WARNING: Some external dependencies remain:" + echo "$remaining_deps" + exit 1 +else + echo "SUCCESS: All external dependencies have been fixed!" +fi + +echo "" +echo "Library linking fix complete!" \ No newline at end of file diff --git a/gui-mac.py b/gui-mac.py new file mode 100644 index 0000000..9fd751f --- /dev/null +++ b/gui-mac.py @@ -0,0 +1,344 @@ +import os +import re +import subprocess +import sys +import tkinter as tk +from tkinter import messagebox, simpledialog, ttk + +# --- Path Resolution for pyinstaller Bundle --- +def get_fido_command_path(): + """ + 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. + """ + if getattr(sys, 'frozen', False): + # We are running in a PyInstaller bundle + if hasattr(sys, '_MEIPASS'): + # PyInstaller bundle with temporary directory + base_path = sys._MEIPASS + else: + # PyInstaller one-file bundle + base_path = os.path.dirname(sys.executable) + + # Print debug info for troubleshooting + print(f"[DEBUG] Running in bundle mode, base_path: {base_path}") + print(f"[DEBUG] Contents of base_path: {os.listdir(base_path) if os.path.exists(base_path) else 'NOT FOUND'}") + + # Primary locations to check for the fido2-token2 binary + binary_locations = [ + os.path.join(base_path, "fido2-token2"), + os.path.join(base_path, "Contents", "MacOS", "fido2-token2"), + os.path.join(base_path, "Contents", "Frameworks", "fido2-token2"), + os.path.join(base_path, "MacOS", "fido2-token2"), + os.path.join(base_path, "Frameworks", "fido2-token2"), + os.path.join(os.path.dirname(base_path), "fido2-token2"), + os.path.join(os.path.dirname(base_path), "Frameworks", "fido2-token2"), + os.path.join(os.path.dirname(base_path), "MacOS", "fido2-token2") + ] + + for binary_path in binary_locations: + 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: + # 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__)) + + dev_locations = [ + os.path.join(script_dir, "build", "tools", "fido2-token2"), + os.path.join(script_dir, "tools", "fido2-token2"), + os.path.join(script_dir, "fido2-token2"), + "./build/tools/fido2-token2", + "./tools/fido2-token2", + "./fido2-token2", + "fido2-token2" # System PATH lookup + ] + + for binary_path in dev_locations: + if os.path.exists(binary_path): + print(f"[DEBUG] Found development binary at: {binary_path}") + return binary_path + + # Fallback to system-wide installation + print(f"[DEBUG] Using system PATH lookup for fido2-token2") + return "fido2-token2" + +print("FIDO2 Manager GUI starting...") +FIDO_COMMAND = get_fido_command_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): + """ + Extract device number from device string format "Device [1] : SoloKeys" + """ + 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 +PIN = None + +# --- Core Functions --- + +def get_device_list(): + """Gets the list of connected FIDO devices by calling the C binary directly.""" + try: + if not os.path.exists(FIDO_COMMAND): + messagebox.showerror("Dependency Error", f"Required tool not found at: {FIDO_COMMAND}") + 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_count = 1 + + for line in result.stdout.strip().split("\n"): + if line.strip(): + # Extract device info (look for parentheses with device type) + import re + match = re.search(r'\(([^)]+)\)', line) + if match: + device_type = match.group(1) + device_list.append(f"Device [{device_count}] : {device_type}") + device_count += 1 + else: + # Fallback if no parentheses found + device_list.append(f"Device [{device_count}] : {line.strip()}") + device_count += 1 + + return device_list + except Exception as e: + error_message = f"Error getting device list: {e}\n\nDetails:\n{getattr(e, 'stderr', '')}" + messagebox.showerror("Execution Error", error_message) + return [] + +def get_device_path_by_number(device_number): + """Get the actual device path string for a given device number.""" + try: + # Get device list from fido2-token2 -L + result = subprocess.run([FIDO_COMMAND, "-L"], capture_output=True, text=True, check=True) + lines = [line.strip() for line in result.stdout.split('\n') if line.strip()] + + # 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 + except Exception as e: + print(f"[ERROR] Failed to get device path: {e}") + 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 + device_path = get_device_path_by_number(device_number) + if not device_path: + messagebox.showerror("Error", f"Could not find device path for device {device_number}") + return + + print(f"[DEBUG] Using device path: {device_path}") + + # Build PIN arguments for fido2-token2 (-w pin) + pin_args = ["-w", PIN] if PIN else [] + + # Commands to run with fido2-token2 directly + commands_to_run = { + "info": [FIDO_COMMAND, "-I"] + pin_args + [device_path], + "storage": [FIDO_COMMAND, "-I", "-c"] + pin_args + [device_path], + } + + 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: + print(f"[DEBUG] Command {key} failed with code {result.returncode}") + print(f"[DEBUG] stderr: {result.stderr}") + + if "FIDO_ERR_PIN_INVALID" in result.stderr: + messagebox.showerror("Error", "Invalid PIN provided.") + return # Stop processing on bad PIN + if "FIDO_ERR_PIN_REQUIRED" in result.stderr: + 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 + for line in result.stdout.splitlines(): + if ": " in line: + k, v = line.split(": ", 1) + tree.insert("", tk.END, values=(k.strip(), v.strip())) + elif line.strip(): + # For lines without colon, add as single value + tree.insert("", tk.END, values=(line.strip(), "")) + + except Exception as e: + messagebox.showerror("Error", f"Command '{key}' failed: {e}\nOutput: {getattr(e, 'stderr', '')}") + + check_passkeys_button_state() + check_changepin_button_state() + +# --- UI Event Handlers --- + +def set_pin_and_get_info(device_string): + """Prompts for PIN and then fetches device info.""" + global PIN + PIN = simpledialog.askstring("PIN Code", "Enter PIN code:", show="*") + if PIN is not None: # Proceed even if PIN is empty, but not if cancelled + execute_info_command(device_string) + +def on_device_selected(event): + """Handles the device selection from the dropdown.""" + selected_text = device_var.get() + # Robust regex to find device paths on Linux, macOS, or Windows + match = re.search(r"(/dev/\w+)|(pcsc://\S+)|(windows://\S+)|(ioreg://\S+)", selected_text) + if match: + device_string = match.group(0).split(':')[0] # Get the path part before any colon + set_pin_and_get_info(device_string) + else: + messagebox.showerror("Device Error", "Could not parse device path from selection.") + +def refresh_combobox(): + """Refreshes the device list in the dropdown.""" + 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("") + tree.delete(*tree.get_children()) + passkeys_button.config(state=tk.DISABLED) + change_pin_button.config(state=tk.DISABLED) + +def show_about_message(): + """Displays the about dialog.""" + messagebox.showinfo( + "About", + "FIDO2 Manager\n\n" + "A utility to manage and interact with FIDO2 security keys.\n\n" + "(c) TOKEN2 Sàrl, Yubico AB" + ) + +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(): + """Enables 'Change PIN' button if PIN is set.""" + state = tk.DISABLED + for child in tree.get_children(): + values = tree.item(child, "values") + if values and len(values) > 1 and values[0] == "pin retries": + state = tk.NORMAL + break + change_pin_button.config(state=state) + +# Placeholder functions for buttons +def on_passkeys_button_click(): + messagebox.showinfo("Not Implemented", "Passkey management functionality is not yet implemented.") + +def change_pin(): + messagebox.showinfo("Not Implemented", "Change PIN functionality is not yet implemented.") + +# --- GUI Layout --- + +root = tk.Tk() +root.title("FIDO2 Manager") +root.geometry("800x600") +root.minsize(700, 400) + +# Main container +main_frame = ttk.Frame(root, padding="10") +main_frame.pack(expand=True, fill="both") + +# --- Top Frame: Device Selection & Refresh --- +top_frame = ttk.Frame(main_frame) +top_frame.pack(side="top", fill="x", pady=(0, 10)) + +ttk.Label(top_frame, text="Select Device:").pack(side="left") + +device_var = tk.StringVar() +device_combobox = ttk.Combobox(top_frame, textvariable=device_var, state="readonly") +device_combobox.pack(side="left", expand=True, fill="x", padx=5) +device_combobox.bind("<>", on_device_selected) + +refresh_button = ttk.Button(top_frame, text="Refresh", command=refresh_combobox) +refresh_button.pack(side="left") + +# --- Center Frame: Information Treeview --- +tree_frame = ttk.Frame(main_frame) +tree_frame.pack(expand=True, fill="both") + +tree = ttk.Treeview(tree_frame, columns=("Key", "Value"), show="headings") +tree.heading("Key", text="Key") +tree.heading("Value", text="Value") +tree.column("Key", width=200, stretch=tk.NO, anchor="w") +tree.column("Value", width=550, anchor="w") + +# Scrollbars +vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview) +hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=tree.xview) +tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + +vsb.pack(side="right", fill="y") +hsb.pack(side="bottom", fill="x") +tree.pack(side="left", expand=True, fill="both") + +# --- Bottom Frame: Action Buttons --- +bottom_frame = ttk.Frame(main_frame) +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) +passkeys_button.pack(side="left") + +change_pin_button = ttk.Button(bottom_frame, text="Change PIN", state=tk.DISABLED, command=change_pin) +change_pin_button.pack(side="left", padx=5) + +about_button = ttk.Button(bottom_frame, text="About", command=show_about_message) +about_button.pack(side="right") + +# --- Initial Load --- +refresh_combobox() +root.mainloop()