commit 46f8ceccbcf211c007b376dc8b361247ecfe15b1 Author: Radek Davidek Date: Fri Feb 6 16:48:54 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb54cae --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iml +*.iws +*.ipr +.vscode/ +.classpath +.project +.settings/ + +# OS +.DS_Store +Thumbs.db diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..40d80b9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic", + "chat.tools.terminal.autoApprove": { + "/home/kamma/java/apache-maven-3.9.11/bin/mvn": true, + "/home/kamma/java/jdk-21.0.9+10/bin/java": true + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..19d13d5 --- /dev/null +++ b/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.jkeepass + jkeepass + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + + com.google.code.gson + gson + 2.10.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.0 + + + package + + shade + + + + + cz.kamma.jkeepass.KeepassApp + + + + + + + + + diff --git a/src/main/java/cz/kamma/jkeepass/CustomDatabaseFormat.java b/src/main/java/cz/kamma/jkeepass/CustomDatabaseFormat.java new file mode 100644 index 0000000..6867fab --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/CustomDatabaseFormat.java @@ -0,0 +1,216 @@ +package cz.kamma.jkeepass; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import cz.kamma.jkeepass.model.Database; +import cz.kamma.jkeepass.model.Entry; +import cz.kamma.jkeepass.model.Group; + +/** + * Handler for custom JKP format (encrypted JSON database) + */ +public class CustomDatabaseFormat { + + private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding"; + private static final Gson gson = new GsonBuilder().create(); + + /** + * Internal JSON structure for serialization + */ + static class JkpDatabase { + public int version = 1; + public String salt; // Base64 encoded + public String iv; // Base64 encoded + public String encryptedData; // Base64 encoded + } + + static class JkpGroup { + public String name; + public List groups = new ArrayList<>(); + public List entries = new ArrayList<>(); + } + + static class JkpEntry { + public String title; + public String username; + public String password; + public String url; + public String notes; + } + + /** + * Save database to .jkp file (encrypted JSON) + */ + public static void saveDatabase(File file, Database database, String password) throws Exception { + // Serialize database to JSON + JkpGroup rootGroup = serializeGroup(database.getRootGroup()); + String jsonData = gson.toJson(rootGroup); + + // Generate salt and derive key + byte[] salt = generateSalt(); + SecretKey key = deriveKey(password.toCharArray(), salt); + + // Encrypt data + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + javax.crypto.spec.IvParameterSpec iv = new javax.crypto.spec.IvParameterSpec(new byte[16]); + cipher.init(Cipher.ENCRYPT_MODE, key, iv); + byte[] encryptedBytes = cipher.doFinal(jsonData.getBytes(StandardCharsets.UTF_8)); + + // Create JKP structure + JkpDatabase jkpDb = new JkpDatabase(); + jkpDb.salt = Base64.getEncoder().encodeToString(salt); + jkpDb.iv = Base64.getEncoder().encodeToString(iv.getIV()); + jkpDb.encryptedData = Base64.getEncoder().encodeToString(encryptedBytes); + + // Write to file + String jkpJson = gson.toJson(jkpDb); + Files.write(Path.of(file.getAbsolutePath()), jkpJson.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Load JKP file and convert to Database object + * Creates an empty database structure and populates it with JKP data + */ + public static Database convertJkpToDatabase(File file, String password) throws Exception { + // Load the JKP structure + JkpGroup jkpData = loadDatabaseStructure(file, password); + + // Create a new empty database to hold the JKP data + Database database = new Database(); + + // Clear template data and populate with JKP data + Group rootGroup = database.getRootGroup(); + rootGroup.setName(jkpData.name != null ? jkpData.name : "Root"); + + // Clear existing entries and groups + for (Object entryObj : new java.util.ArrayList<>(rootGroup.getEntries())) { + rootGroup.removeEntry((Entry) entryObj); + } + for (Object groupObj : new java.util.ArrayList<>(rootGroup.getGroups())) { + rootGroup.removeGroup((Group) groupObj); + } + + // Populate with JKP data + deserializeGroup(rootGroup, jkpData, database); + + return database; + } + + /** + * Load database from .jkp file + */ + public static JkpGroup loadDatabaseStructure(File file, String password) throws Exception { + // Read file + String jkpJson = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + JkpDatabase jkpDb = gson.fromJson(jkpJson, JkpDatabase.class); + + if (jkpDb == null || jkpDb.encryptedData == null) { + throw new IOException("Invalid JKP file format"); + } + + // Decode salt and IV + byte[] salt = Base64.getDecoder().decode(jkpDb.salt); + byte[] ivBytes = Base64.getDecoder().decode(jkpDb.iv); + byte[] encryptedBytes = Base64.getDecoder().decode(jkpDb.encryptedData); + + // Derive key + SecretKey key = deriveKey(password.toCharArray(), salt); + + // Decrypt data + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + javax.crypto.spec.IvParameterSpec ivSpec = new javax.crypto.spec.IvParameterSpec(ivBytes); + cipher.init(Cipher.DECRYPT_MODE, key, ivSpec); + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + String jsonData = new String(decryptedBytes, StandardCharsets.UTF_8); + + // Deserialize from JSON + return gson.fromJson(jsonData, JkpGroup.class); + } + + /** + * Serialize KeePass Group to JKP Group + */ + private static JkpGroup serializeGroup(Group group) { + JkpGroup jkpGroup = new JkpGroup(); + jkpGroup.name = group.getName(); + + // Serialize subgroups + for (Object subGroupObj : group.getGroups()) { + Group subGroup = (Group) subGroupObj; + jkpGroup.groups.add(serializeGroup(subGroup)); + } + + // Serialize entries + for (Object entryObj : group.getEntries()) { + Entry entry = (Entry) entryObj; + JkpEntry jkpEntry = new JkpEntry(); + jkpEntry.title = entry.getTitle() != null ? entry.getTitle() : ""; + jkpEntry.username = entry.getUsername() != null ? entry.getUsername() : ""; + jkpEntry.password = entry.getPassword() != null ? entry.getPassword() : ""; + jkpEntry.url = entry.getUrl() != null ? entry.getUrl() : ""; + jkpEntry.notes = entry.getNotes() != null ? entry.getNotes() : ""; + jkpGroup.entries.add(jkpEntry); + } + + return jkpGroup; + } + + /** + * Deserialize JKP Group and populate KeePass Database + */ + public static void deserializeGroup(Group targetGroup, JkpGroup source, Database database) { + // Add subgroups + for (JkpGroup srcGroup : source.groups) { + Group newGroup = database.newGroup(srcGroup.name); + targetGroup.addGroup(newGroup); + deserializeGroup(newGroup, srcGroup, database); + } + + // Add entries + for (JkpEntry srcEntry : source.entries) { + Entry newEntry = targetGroup.addEntry(database.newEntry()); + newEntry.setTitle(srcEntry.title != null ? srcEntry.title : ""); + newEntry.setUsername(srcEntry.username != null ? srcEntry.username : ""); + newEntry.setPassword(srcEntry.password != null ? srcEntry.password : ""); + newEntry.setUrl(srcEntry.url != null ? srcEntry.url : ""); + newEntry.setNotes(srcEntry.notes != null ? srcEntry.notes : ""); + } + } + + /** + * Derive encryption key from password + */ + private static SecretKey deriveKey(char[] password, byte[] salt) throws Exception { + String passwordStr = new String(password); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(salt); + byte[] keyBytes = md.digest(passwordStr.getBytes(StandardCharsets.UTF_8)); + // Ensure exactly 32 bytes (256 bits) for AES-256 + return new SecretKeySpec(keyBytes, 0, 32, "AES"); + } + + /** + * Generate random salt + */ + private static byte[] generateSalt() { + SecureRandom random = new SecureRandom(); + byte[] salt = new byte[16]; + random.nextBytes(salt); + return salt; + } +} diff --git a/src/main/java/cz/kamma/jkeepass/KeepassApp.java b/src/main/java/cz/kamma/jkeepass/KeepassApp.java new file mode 100644 index 0000000..9ff74c4 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/KeepassApp.java @@ -0,0 +1,985 @@ +package cz.kamma.jkeepass; + +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Image; +import java.awt.Insets; +import java.awt.MenuItem; +import java.awt.Point; +import java.awt.PopupMenu; +import java.awt.SystemTray; +import java.awt.Toolkit; +import java.awt.TrayIcon; +import java.awt.datatransfer.StringSelection; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.io.File; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; +import java.util.prefs.Preferences; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JMenu; +import javax.swing.JMenuBar; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPasswordField; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTable; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.UIManager; +import javax.swing.event.AncestorEvent; +import javax.swing.event.AncestorListener; +import javax.swing.table.DefaultTableModel; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeModel; +import javax.swing.tree.TreePath; + +import cz.kamma.jkeepass.model.Database; +import cz.kamma.jkeepass.model.Entry; +import cz.kamma.jkeepass.model.Group; + +public class KeepassApp extends JFrame { + private JTable entryTable; + private DefaultTableModel tableModel; + private JTextArea notesArea; + private JLabel statusLabel; + private JMenu recentMenu; + private JTree groupTree; + private DefaultTreeModel treeModel; + private JSplitPane mainSplitPane; + private JSplitPane rightSplitPane; + private Database database; + private Group currentGroup; + private List currentEntries = new ArrayList<>(); + private final Preferences prefs = Preferences.userNodeForPackage(KeepassApp.class); + private static final String PREF_RECENT_LIST = "recent_database_list"; + private static final String PREF_WINDOW_X = "window_x"; + private static final String PREF_WINDOW_Y = "window_y"; + private static final String PREF_WINDOW_WIDTH = "window_width"; + private static final String PREF_WINDOW_HEIGHT = "window_height"; + private static final String PREF_MAIN_DIVIDER = "main_divider_location"; + private static final String PREF_RIGHT_DIVIDER = "right_divider_location"; + private static final int MAX_RECENT_FILES = 5; + private File currentFile; + private String currentPassword; + + private TrayIcon trayIcon; + + public KeepassApp() { + // Set properties before anything else + System.setProperty("sun.awt.enableExtraMouseButtons", "true"); + // Force GTK 2 or 3 for better X11 integration in Cinnamon + System.setProperty("jdk.gtk.version", "3"); + // Ensure we don't use the old way of drawing that can conflict with XApp + System.setProperty("sun.java2d.xrender", "true"); + + setTitle("JKeepass"); + setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + + // Load and apply saved window position and size + int savedX = prefs.getInt(PREF_WINDOW_X, -1); + int savedY = prefs.getInt(PREF_WINDOW_Y, -1); + int savedWidth = prefs.getInt(PREF_WINDOW_WIDTH, 800); + int savedHeight = prefs.getInt(PREF_WINDOW_HEIGHT, 500); + + if (savedX != -1 && savedY != -1) { + setLocation(savedX, savedY); + setSize(savedWidth, savedHeight); + } else { + setSize(800, 500); + setLocationRelativeTo(null); + } + + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + saveWindowState(); + saveComponentState(); + if (SystemTray.isSupported() && trayIcon != null) { + setVisible(false); + } else { + System.exit(0); + } + } + }); + + addComponentListener(new ComponentAdapter() { + @Override + public void componentMoved(ComponentEvent e) { + saveWindowState(); + } + + @Override + public void componentResized(ComponentEvent e) { + saveWindowState(); + } + }); + + loadAppIcon(); + initMenuBar(); + initComponents(); + + // Small delay to let OS/Window Manager settle before checking tray + Timer trayTimer = new Timer(1000, e -> initSystemTray()); + trayTimer.setRepeats(false); + trayTimer.start(); + } + + private void loadAppIcon() { + try { + java.net.URL iconUrl = getClass().getResource("/icon.png"); + if (iconUrl != null) { + Image img = Toolkit.getDefaultToolkit().getImage(iconUrl); + setIconImage(img); + System.out.println("Icon loaded successfully via Toolkit."); + } else { + System.err.println("Icon resource not found at /icon.png"); + } + } catch (Exception e) { + System.err.println("Could not load app icon: " + e.getMessage()); + } + } + + private void initSystemTray() { + try { + if (!SystemTray.isSupported()) { + return; + } + SystemTray tray = SystemTray.getSystemTray(); + + java.net.URL iconUrl = getClass().getResource("/icon.png"); + if (iconUrl == null) return; + + Image image; + try { + // Read image directly into a BufferedImage for better compatibility with XApp applet + image = javax.imageio.ImageIO.read(iconUrl); + } catch (Exception e) { + image = Toolkit.getDefaultToolkit().getImage(iconUrl); + } + + PopupMenu popup = new PopupMenu(); + MenuItem showItem = new MenuItem("Show JKeepass"); + MenuItem lockItem = new MenuItem("Lock Database"); + MenuItem exitItem = new MenuItem("Exit"); + + showItem.addActionListener(e -> { + setVisible(true); + setExtendedState(JFrame.NORMAL); + toFront(); + }); + lockItem.addActionListener(e -> lockDatabase()); + exitItem.addActionListener(e -> System.exit(0)); + + popup.add(showItem); + popup.add(lockItem); + popup.addSeparator(); + popup.add(exitItem); + + trayIcon = new TrayIcon(image, "JKeepass", popup); + trayIcon.setToolTip("JKeepass - Password Manager"); + trayIcon.setImageAutoSize(true); + + trayIcon.addActionListener(e -> { + setVisible(true); + setExtendedState(JFrame.NORMAL); + toFront(); + }); + + tray.add(trayIcon); + if (statusLabel != null) { + statusLabel.setText(" Ready (SysTray active)"); + } + setupWindowListeners(); + } catch (Exception e) { + // Silently fail if SystemTray is not available + trayIcon = null; + } + } + + private JFrame createRestoreFrame() { + JFrame restoreFrame = new JFrame("JKeepass - Click to Restore"); + restoreFrame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); + restoreFrame.setAlwaysOnTop(true); + restoreFrame.setUndecorated(true); + restoreFrame.setOpacity(0.85f); + restoreFrame.setSize(160, 60); + restoreFrame.setLocation(20, 20); + restoreFrame.setType(JFrame.Type.UTILITY); + + JButton restoreBtn = new JButton("Restore JKeepass"); + restoreBtn.setFont(restoreBtn.getFont().deriveFont(12f)); + restoreBtn.addActionListener(e -> { + setVisible(true); + setExtendedState(JFrame.NORMAL); + toFront(); + requestFocus(); + restoreFrame.dispose(); + }); + + restoreFrame.add(restoreBtn); + return restoreFrame; + } + + private void minimizeToCustomTray() { + if (SystemTray.isSupported() && trayIcon != null) { + setVisible(false); + } else { + // Linux Mint doesn't support SystemTray - use floating restore button + lockDatabase(); + JFrame restoreFrame = createRestoreFrame(); + restoreFrame.setVisible(true); + setVisible(false); + } + } + + private void setupWindowListeners() { + for (java.awt.event.WindowListener wl : getWindowListeners()) { + if (!(wl instanceof WindowAdapter) || wl.getClass().getName().contains("KeepassApp")) { + removeWindowListener(wl); + } + } + addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + if (trayIcon != null) { + setVisible(false); + } else { + System.exit(0); + } + } + @Override + public void windowIconified(WindowEvent e) { + if (trayIcon != null) { + setVisible(false); + } + } + }); + } + + private void initMenuBar() { + JMenuBar menuBar = new JMenuBar(); + + // File Menu + JMenu fileMenu = new JMenu("File"); + JMenuItem openItem = new JMenuItem("Open Database..."); + recentMenu = new JMenu("Open Recent"); + JMenuItem saveItem = new JMenuItem("Save"); + JMenuItem minimizeItem = new JMenuItem(SystemTray.isSupported() ? "Minimize to Tray" : "Minimize & Lock"); + JMenuItem lockItem = new JMenuItem("Lock Database"); + JMenuItem exitItem = new JMenuItem("Exit"); + + openItem.addActionListener(e -> openDatabase(null)); + saveItem.addActionListener(e -> saveDatabase()); + minimizeItem.addActionListener(e -> minimizeToCustomTray()); + lockItem.addActionListener(e -> lockDatabase()); + exitItem.addActionListener(e -> System.exit(0)); + + fileMenu.add(openItem); + fileMenu.add(recentMenu); + fileMenu.add(saveItem); + fileMenu.addSeparator(); + fileMenu.add(lockItem); + fileMenu.add(minimizeItem); + fileMenu.add(exitItem); + + updateRecentMenu(); + + // Entry Menu + JMenu entryMenu = new JMenu("Entry"); + JMenuItem addItem = new JMenuItem("Add Entry..."); + JMenuItem editItem = new JMenuItem("Edit Entry..."); + JMenuItem delItem = new JMenuItem("Delete Entry"); + + addItem.addActionListener(e -> addEntry()); + editItem.addActionListener(e -> editEntry()); + delItem.addActionListener(e -> deleteEntry()); + + entryMenu.add(addItem); + entryMenu.add(editItem); + entryMenu.add(delItem); + + // Edit Menu + JMenu editMenu = new JMenu("Edit"); + JMenuItem copyUserItem = new JMenuItem("Copy Username"); + JMenuItem copyPassItem = new JMenuItem("Copy Password"); + + copyUserItem.addActionListener(e -> copyToClipboard(true)); + copyPassItem.addActionListener(e -> copyToClipboard(false)); + + editMenu.add(copyUserItem); + editMenu.add(copyPassItem); + + // View Menu + JMenu viewMenu = new JMenu("View"); + JCheckBoxMenuItem onTopItem = new JCheckBoxMenuItem("Always on Top"); + onTopItem.addActionListener(e -> setAlwaysOnTop(onTopItem.isSelected())); + viewMenu.add(onTopItem); + + menuBar.add(fileMenu); + menuBar.add(entryMenu); + menuBar.add(editMenu); + menuBar.add(viewMenu); + setJMenuBar(menuBar); + } + + private void initComponents() { + // Tree setup + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode("No database loaded"); + treeModel = new DefaultTreeModel(rootNode); + groupTree = new JTree(treeModel); + groupTree.addTreeSelectionListener(e -> { + DefaultMutableTreeNode selectedNode = (DefaultMutableTreeNode) groupTree.getLastSelectedPathComponent(); + if (selectedNode != null && selectedNode.getUserObject() instanceof GroupWrapper) { + displayEntriesInGroup(((GroupWrapper) selectedNode.getUserObject()).getGroup()); + } + }); + groupTree.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + showTreePopupMenu(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + showTreePopupMenu(e); + } + + private void showTreePopupMenu(MouseEvent e) { + if (e.isPopupTrigger()) { + TreePath path = groupTree.getPathForLocation(e.getX(), e.getY()); + if (path != null) { + groupTree.setSelectionPath(path); + DefaultMutableTreeNode selectedNode = (DefaultMutableTreeNode) path.getLastPathComponent(); + if (selectedNode.getUserObject() instanceof GroupWrapper) { + Group group = ((GroupWrapper) selectedNode.getUserObject()).getGroup(); + JPopupMenu popup = new JPopupMenu(); + JMenuItem addGroupItem = new JMenuItem("Add Category..."); + addGroupItem.addActionListener(al -> addCategory(group)); + popup.add(addGroupItem); + + // Add Delete Category option (not for root group) + if (selectedNode.getParent() != null) { + popup.addSeparator(); + JMenuItem deleteGroupItem = new JMenuItem("Delete Category..."); + deleteGroupItem.addActionListener(al -> deleteCategory(group, selectedNode)); + popup.add(deleteGroupItem); + } + + popup.show(e.getComponent(), e.getX(), e.getY()); + } + } + } + } + }); + JScrollPane treeScrollPane = new JScrollPane(groupTree); + + // Notes area setup + notesArea = new JTextArea(5, 20); + notesArea.setEditable(false); + notesArea.setLineWrap(true); + notesArea.setWrapStyleWord(true); + JScrollPane notesScrollPane = new JScrollPane(notesArea); + notesScrollPane.setBorder(BorderFactory.createTitledBorder("Notes")); + + // Table setup + String[] columnNames = {"Title", "Username", "Password", "URL", "Notes"}; + tableModel = new DefaultTableModel(columnNames, 0) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + entryTable = new JTable(tableModel); + entryTable.setAutoCreateRowSorter(true); + entryTable.getSelectionModel().addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + notesArea.setText(currentEntries.get(modelRow).getNotes()); + } else { + notesArea.setText(""); + } + } + }); + entryTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + int row = entryTable.rowAtPoint(e.getPoint()); + int col = entryTable.columnAtPoint(e.getPoint()); + if (row >= 0 && col >= 0) { + int modelRow = entryTable.convertRowIndexToModel(row); + Entry entry = currentEntries.get(modelRow); + String content = switch (col) { + case 0 -> entry.getTitle(); + case 1 -> entry.getUsername(); + case 2 -> entry.getPassword(); + case 3 -> entry.getUrl(); + case 4 -> entry.getNotes(); + default -> ""; + }; + copyTextToClipboard(content, entryTable.getColumnName(col)); + } + } + } + + @Override + public void mousePressed(MouseEvent e) { + showPopupMenu(e); + } + + @Override + public void mouseReleased(MouseEvent e) { + showPopupMenu(e); + } + + private void showPopupMenu(MouseEvent e) { + if (e.isPopupTrigger()) { + int row = entryTable.rowAtPoint(e.getPoint()); + if (row >= 0) { + entryTable.setRowSelectionInterval(row, row); + } + + JPopupMenu popup = new JPopupMenu(); + + JMenuItem addPop = new JMenuItem("Add Entry"); + JMenuItem editPop = new JMenuItem("Edit"); + JMenuItem copyPop = new JMenuItem("Copy Username"); + JMenuItem copyPassPop = new JMenuItem("Copy Password"); + JMenuItem delPop = new JMenuItem("Delete"); + + addPop.addActionListener(al -> addEntry()); + editPop.addActionListener(al -> editEntry()); + copyPop.addActionListener(al -> copyToClipboard(true)); + copyPassPop.addActionListener(al -> copyToClipboard(false)); + delPop.addActionListener(al -> deleteEntry()); + + popup.add(addPop); + popup.add(editPop); + popup.addSeparator(); + popup.add(copyPop); + popup.add(copyPassPop); + popup.addSeparator(); + popup.add(delPop); + + popup.show(e.getComponent(), e.getX(), e.getY()); + } + } + }); + JScrollPane tableScrollPane = new JScrollPane(entryTable); + + // Right side: Table and Notes + rightSplitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT, tableScrollPane, notesScrollPane); + int rightDivider = prefs.getInt(PREF_RIGHT_DIVIDER, 250); + rightSplitPane.setDividerLocation(rightDivider); + rightSplitPane.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, e -> saveComponentState()); + + // Main Split pane + mainSplitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, treeScrollPane, rightSplitPane); + int mainDivider = prefs.getInt(PREF_MAIN_DIVIDER, 200); + mainSplitPane.setDividerLocation(mainDivider); + mainSplitPane.addPropertyChangeListener(JSplitPane.DIVIDER_LOCATION_PROPERTY, e -> saveComponentState()); + add(mainSplitPane, BorderLayout.CENTER); + + // Status bar setup + statusLabel = new JLabel(" Ready"); + statusLabel.setBorder(BorderFactory.createEmptyBorder(0, 5, 0, 5)); + + JButton lockBtn = new JButton("Lock"); + lockBtn.setFocusable(false); + lockBtn.setMargin(new Insets(0, 5, 0, 5)); + lockBtn.addActionListener(e -> lockDatabase()); + + JPanel statusBar = new JPanel(new BorderLayout()); + statusBar.setPreferredSize(new Dimension(getWidth(), 30)); + statusBar.setBorder(BorderFactory.createEtchedBorder()); + statusBar.add(statusLabel, BorderLayout.WEST); + statusBar.add(lockBtn, BorderLayout.EAST); + add(statusBar, BorderLayout.PAGE_END); + + // Tray diagnostic + if (!SystemTray.isSupported()) { + statusLabel.setText(" Ready (SysTray not supported - use Lock button)"); + } else if (trayIcon == null) { + statusLabel.setText(" Ready (SysTray icon failed)"); + } else { + statusLabel.setText(" Ready (SysTray active)"); + } + } + + /** + * Save current window position, size, and component layout to preferences + */ + private void saveWindowState() { + Point location = getLocation(); + Dimension size = getSize(); + prefs.putInt(PREF_WINDOW_X, location.x); + prefs.putInt(PREF_WINDOW_Y, location.y); + prefs.putInt(PREF_WINDOW_WIDTH, size.width); + prefs.putInt(PREF_WINDOW_HEIGHT, size.height); + } + + /** + * Save component layout (divider positions) to preferences + */ + private void saveComponentState() { + if (mainSplitPane != null) { + prefs.putInt(PREF_MAIN_DIVIDER, mainSplitPane.getDividerLocation()); + } + if (rightSplitPane != null) { + prefs.putInt(PREF_RIGHT_DIVIDER, rightSplitPane.getDividerLocation()); + } + } + + private void updateRecentMenu() { + recentMenu.removeAll(); + String recentFiles = prefs.get(PREF_RECENT_LIST, ""); + if (recentFiles.isEmpty()) { + JMenuItem noneItem = new JMenuItem("No recent files"); + noneItem.setEnabled(false); + recentMenu.add(noneItem); + } else { + String[] paths = recentFiles.split(";"); + for (String path : paths) { + if (path.isEmpty()) continue; + JMenuItem item = new JMenuItem(path); + item.addActionListener(e -> { + File file = new File(path); + if (file.exists()) { + openDatabase(file); + } else { + JOptionPane.showMessageDialog(this, "File not found: " + path, "Error", JOptionPane.ERROR_MESSAGE); + removeRecentFile(path); + } + }); + recentMenu.add(item); + } + } + } + + private void saveRecentFile(String path) { + String recentFiles = prefs.get(PREF_RECENT_LIST, ""); + List list = new ArrayList<>(List.of(recentFiles.split(";"))); + list.remove(""); // Clean up + list.remove(path); // Avoid duplicates + list.add(0, path); // Add to top + if (list.size() > MAX_RECENT_FILES) { + list = list.subList(0, MAX_RECENT_FILES); + } + prefs.put(PREF_RECENT_LIST, String.join(";", list)); + updateRecentMenu(); + } + + private void removeRecentFile(String path) { + String recentFiles = prefs.get(PREF_RECENT_LIST, ""); + List list = new ArrayList<>(List.of(recentFiles.split(";"))); + list.remove(path); + prefs.put(PREF_RECENT_LIST, String.join(";", list)); + updateRecentMenu(); + } + + private void openDatabase(File selectedFile) { + if (selectedFile == null) { + JFileChooser fileChooser = new JFileChooser(); + fileChooser.setFileFilter(new javax.swing.filechooser.FileNameExtensionFilter("JKeepass Database (*.jkp)", "jkp")); + if (fileChooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + selectedFile = fileChooser.getSelectedFile(); + } else { + return; + } + } + + JPasswordField pf = new JPasswordField(); + pf.addAncestorListener(new AncestorListener() { + @Override + public void ancestorAdded(AncestorEvent event) { + SwingUtilities.invokeLater(pf::requestFocusInWindow); + } + @Override public void ancestorRemoved(AncestorEvent event) {} + @Override public void ancestorMoved(AncestorEvent event) {} + }); + int okCxl = JOptionPane.showConfirmDialog(this, pf, "Enter Master Password:", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); + + if (okCxl == JOptionPane.OK_OPTION) { + String password = new String(pf.getPassword()); + try { + // Load JKP format + this.database = CustomDatabaseFormat.convertJkpToDatabase(selectedFile, password); + this.currentFile = selectedFile; + this.currentPassword = password; + updateTree(this.database.getRootGroup()); + saveRecentFile(selectedFile.getAbsolutePath()); + statusLabel.setText(" Database loaded: " + selectedFile.getName()); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Error loading database: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + ex.printStackTrace(); + } + } + } + + private void saveDatabase() { + if (database == null || currentFile == null || currentPassword == null) { + JOptionPane.showMessageDialog(this, "No database open to save.", "Warning", JOptionPane.WARNING_MESSAGE); + return; + } + try { + // Save as custom JKP format + CustomDatabaseFormat.saveDatabase(currentFile, database, currentPassword); + statusLabel.setText(" Database saved successfully."); + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Error saving database: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + ex.printStackTrace(); + } + } + + private void lockDatabase() { + if (database == null) return; + + // Clear sensitive data + database = null; + currentGroup = null; + currentEntries.clear(); + tableModel.setRowCount(0); + notesArea.setText(""); + treeModel.setRoot(new DefaultMutableTreeNode("Database locked")); + + statusLabel.setText(" Database locked."); + + // Re-open password prompt + if (currentFile != null) { + File toOpen = currentFile; + // Clear current file/pass so we don't think it's still open + currentPassword = null; + currentFile = null; + openDatabase(toOpen); + } + } + + private void addEntry() { + if (currentGroup == null) { + JOptionPane.showMessageDialog(this, "Please select a folder first.", "Warning", JOptionPane.WARNING_MESSAGE); + return; + } + EntryDialog dialog = new EntryDialog(this, "Add Entry", null); + if (dialog.showDialog()) { + Entry entry = currentGroup.addEntry(database.newEntry()); + updateEntryFromDialog(entry, dialog); + displayEntriesInGroup(currentGroup); + statusLabel.setText(" Entry added. Remember to Save."); + } + } + + private void editEntry() { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + Entry entry = currentEntries.get(modelRow); + EntryDialog dialog = new EntryDialog(this, "Edit Entry", entry); + if (dialog.showDialog()) { + updateEntryFromDialog(entry, dialog); + displayEntriesInGroup(currentGroup); + statusLabel.setText(" Entry updated. Remember to Save."); + } + } else { + JOptionPane.showMessageDialog(this, "Please select an entry to edit.", "Warning", JOptionPane.WARNING_MESSAGE); + } + } + + private void deleteEntry() { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int confirm = JOptionPane.showConfirmDialog(this, "Are you sure you want to delete this entry?", "Confirm Delete", JOptionPane.YES_NO_OPTION); + if (confirm == JOptionPane.YES_OPTION) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + Entry entry = currentEntries.get(modelRow); + currentGroup.removeEntry(entry); + displayEntriesInGroup(currentGroup); + statusLabel.setText(" Entry deleted. Remember to Save."); + } + } else { + JOptionPane.showMessageDialog(this, "Please select an entry to delete.", "Warning", JOptionPane.WARNING_MESSAGE); + } + } + + private void updateEntryFromDialog(Entry entry, EntryDialog dialog) { + entry.setTitle(dialog.getTitleText()); + entry.setUsername(dialog.getUsernameText()); + entry.setPassword(dialog.getPasswordText()); + entry.setUrl(dialog.getUrlText()); + entry.setNotes(dialog.getNotesText()); + } + + private void addCategory(Group parentGroup) { + String name = JOptionPane.showInputDialog(this, "Enter name for the new category:", "Add Category", JOptionPane.QUESTION_MESSAGE); + if (name != null && !name.trim().isEmpty()) { + parentGroup.addGroup(database.newGroup(name.trim())); + updateTree(database.getRootGroup()); + statusLabel.setText(" Category added. Remember to Save."); + } + } + + private void deleteCategory(Group group, DefaultMutableTreeNode node) { + // Get parent group from parent node + DefaultMutableTreeNode parentNode = (DefaultMutableTreeNode) node.getParent(); + if (parentNode == null || !(parentNode.getUserObject() instanceof GroupWrapper)) { + JOptionPane.showMessageDialog(this, "Cannot delete root category.", "Error", JOptionPane.ERROR_MESSAGE); + return; + } + + // Check if category has subgroups or entries + int subGroupCount = group.getGroups().size(); + int entryCount = group.getEntries().size(); + + String message = "Delete category \"" + group.getName() + "\"?"; + if (subGroupCount > 0) { + message += "\nThis category has " + subGroupCount + " subcategory/categories."; + } + if (entryCount > 0) { + message += "\nThis category has " + entryCount + " entry/entries."; + } + if (subGroupCount > 0 || entryCount > 0) { + message += "\n\nWarning: All contents will be deleted!"; + } + message += "\n\nThis action cannot be undone!"; + + int result = JOptionPane.showConfirmDialog(this, message, "Delete Category", + JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + + if (result == JOptionPane.YES_OPTION) { + Group parentGroup = ((GroupWrapper) parentNode.getUserObject()).getGroup(); + parentGroup.removeGroup(group); + updateTree(database.getRootGroup()); + statusLabel.setText(" Category deleted. Remember to Save."); + } + } + + private void displayEntriesInGroup(Group group) { + this.currentGroup = group; + tableModel.setRowCount(0); + currentEntries.clear(); + for (Object entryObj : group.getEntries()) { + Entry entry = (Entry) entryObj; + currentEntries.add(entry); + tableModel.addRow(new Object[]{ + entry.getTitle(), + entry.getUsername(), + "********", + entry.getUrl(), + entry.getNotes() + }); + } + } + + /** + * Dialog for adding/editing entries + */ + private static class EntryDialog extends JDialog { + private final JTextField titleField = new JTextField(20); + private final JTextField userField = new JTextField(20); + private final JPasswordField passField = new JPasswordField(20); + private final JTextField urlField = new JTextField(20); + private final JTextArea notesArea = new JTextArea(5, 20); + private boolean confirmed = false; + + public EntryDialog(Frame owner, String title, Entry entry) { + super(owner, title, true); + setLayout(new BorderLayout()); + + titleField.addAncestorListener(new AncestorListener() { + @Override + public void ancestorAdded(AncestorEvent event) { + SwingUtilities.invokeLater(titleField::requestFocusInWindow); + } + @Override public void ancestorRemoved(AncestorEvent event) {} + @Override public void ancestorMoved(AncestorEvent event) {} + }); + + JPanel panel = new JPanel(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(5, 5, 5, 5); + gbc.fill = GridBagConstraints.HORIZONTAL; + + gbc.gridx = 0; gbc.gridy = 0; + panel.add(new JLabel(" Title:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(titleField, gbc); + + gbc.gridx = 0; gbc.gridy = 1; gbc.gridwidth = 1; + panel.add(new JLabel(" Username:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(userField, gbc); + + gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 1; + panel.add(new JLabel(" Password:"), gbc); + gbc.gridx = 1; + panel.add(passField, gbc); + + JPanel passBtnOptionPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0)); + JCheckBox showPassCheck = new JCheckBox("Show"); + showPassCheck.addActionListener(e -> { + if (showPassCheck.isSelected()) { + passField.setEchoChar((char) 0); + } else { + passField.setEchoChar('•'); + } + }); + JButton genBtn = new JButton("Generate"); + genBtn.addActionListener(e -> passField.setText(generateRandomPassword(16))); + passBtnOptionPanel.add(showPassCheck); + passBtnOptionPanel.add(genBtn); + gbc.gridx = 2; + panel.add(passBtnOptionPanel, gbc); + + gbc.gridx = 0; gbc.gridy = 3; gbc.gridwidth = 1; + panel.add(new JLabel(" URL:"), gbc); + gbc.gridx = 1; gbc.gridwidth = 2; + panel.add(urlField, gbc); + + if (entry != null) { + titleField.setText(entry.getTitle()); + userField.setText(entry.getUsername()); + passField.setText(entry.getPassword()); + urlField.setText(entry.getUrl()); + notesArea.setText(entry.getNotes()); + } + + add(panel, BorderLayout.NORTH); + JScrollPane scrollPane = new JScrollPane(notesArea); + scrollPane.setBorder(BorderFactory.createTitledBorder("Notes")); + add(scrollPane, BorderLayout.CENTER); + + JPanel btnPanel = new JPanel(); + JButton okBtn = new JButton("OK"); + JButton cancelBtn = new JButton("Cancel"); + okBtn.addActionListener(e -> { confirmed = true; setVisible(false); }); + cancelBtn.addActionListener(e -> setVisible(false)); + btnPanel.add(okBtn); + btnPanel.add(cancelBtn); + add(btnPanel, BorderLayout.SOUTH); + + pack(); + setLocationRelativeTo(owner); + } + + public boolean showDialog() { + setVisible(true); + return confirmed; + } + + public String getTitleText() { return titleField.getText(); } + public String getUsernameText() { return userField.getText(); } + public String getPasswordText() { return new String(passField.getPassword()); } + public String getUrlText() { return urlField.getText(); } + public String getNotesText() { return notesArea.getText(); } + + private String generateRandomPassword(int length) { + String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+"; + SecureRandom random = new SecureRandom(); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < length; i++) { + sb.append(chars.charAt(random.nextInt(chars.length()))); + } + return sb.toString(); + } + } + + private void updateTree(Group rootGroup) { + DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(new GroupWrapper(rootGroup)); + populateTreeRecursive(rootNode, rootGroup); + treeModel.setRoot(rootNode); + // Automatically select root and display its entries + groupTree.setSelectionRow(0); + displayEntriesInGroup(rootGroup); + } + + private void populateTreeRecursive(DefaultMutableTreeNode node, Group group) { + for (Object subGroupObj : group.getGroups()) { + Group subGroup = (Group) subGroupObj; + DefaultMutableTreeNode childNode = new DefaultMutableTreeNode(new GroupWrapper(subGroup)); + node.add(childNode); + populateTreeRecursive(childNode, subGroup); + } + } + + /** + * Helper class to display group name in JTree while keeping reference to Group object. + */ + private static class GroupWrapper { + private final Group group; + + public GroupWrapper(Group group) { + this.group = group; + } + + public Group getGroup() { + return group; + } + + @Override + public String toString() { + return group.getName(); + } + } + + private void copyToClipboard(boolean isUsername) { + int selectedRow = entryTable.getSelectedRow(); + if (selectedRow >= 0) { + int modelRow = entryTable.convertRowIndexToModel(selectedRow); + Entry entry = currentEntries.get(modelRow); + String text = isUsername ? entry.getUsername() : entry.getPassword(); + String label = isUsername ? "Username" : "Password"; + copyTextToClipboard(text, label); + } else { + JOptionPane.showMessageDialog(this, "Please select an entry first."); + } + } + + private void copyTextToClipboard(String text, String label) { + if (text != null) { + StringSelection selection = new StringSelection(text); + Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection); + statusLabel.setText(" " + label + " copied to clipboard."); + Timer timer = new Timer(3000, e -> statusLabel.setText(" Ready")); + timer.setRepeats(false); + timer.start(); + } + } + + + + public static void main(String[] args) { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception ignored) {} + + SwingUtilities.invokeLater(() -> { + new KeepassApp().setVisible(true); + }); + } +} diff --git a/src/main/java/cz/kamma/jkeepass/model/Database.java b/src/main/java/cz/kamma/jkeepass/model/Database.java new file mode 100644 index 0000000..008feb8 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/model/Database.java @@ -0,0 +1,24 @@ +package cz.kamma.jkeepass.model; + +/** + * Custom Database implementation (replaces KeePassJava2 dependency) + */ +public class Database { + private Group rootGroup; + + public Database() { + this.rootGroup = new Group("Root"); + } + + public Group getRootGroup() { + return rootGroup; + } + + public Entry newEntry() { + return new Entry(); + } + + public Group newGroup(String name) { + return new Group(name); + } +} diff --git a/src/main/java/cz/kamma/jkeepass/model/Entry.java b/src/main/java/cz/kamma/jkeepass/model/Entry.java new file mode 100644 index 0000000..c006377 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/model/Entry.java @@ -0,0 +1,52 @@ +package cz.kamma.jkeepass.model; + +/** + * Custom Entry implementation (replaces KeePassJava2 dependency) + */ +public class Entry { + private String title = ""; + private String username = ""; + private String password = ""; + private String url = ""; + private String notes = ""; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title != null ? title : ""; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username != null ? username : ""; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password != null ? password : ""; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url != null ? url : ""; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes != null ? notes : ""; + } +} diff --git a/src/main/java/cz/kamma/jkeepass/model/Group.java b/src/main/java/cz/kamma/jkeepass/model/Group.java new file mode 100644 index 0000000..f496862 --- /dev/null +++ b/src/main/java/cz/kamma/jkeepass/model/Group.java @@ -0,0 +1,51 @@ +package cz.kamma.jkeepass.model; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Custom Group implementation (replaces KeePassJava2 dependency) + */ +public class Group { + private String name; + private List groups = new ArrayList<>(); + private List entries = new ArrayList<>(); + + public Group(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Collection getGroups() { + return new ArrayList<>(groups); + } + + public Collection getEntries() { + return new ArrayList<>(entries); + } + + public void addGroup(Group group) { + groups.add(group); + } + + public void removeGroup(Group group) { + groups.remove(group); + } + + public void removeEntry(Entry entry) { + entries.remove(entry); + } + + public Entry addEntry(Entry entry) { + entries.add(entry); + return entry; + } +} diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000..c4f07b9 Binary files /dev/null and b/src/main/resources/icon.png differ