first commit

This commit is contained in:
rdavidek 2026-01-25 19:06:08 +01:00
commit fc9e7b7598
35 changed files with 1395 additions and 0 deletions

16
.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,16 @@
# Workspace Instructions
- Project: Java 11 SSH Terminal
- Language: Java 11
- UI Framework: Swing
- SSH Library: JSch (mwiede fork)
- Security: AES Encryption with Master Password
## Steps Completed
- [x] Initialize project structure and pom.xml
- [x] Implement Encryption Service
- [x] Implement Connection Model and Storage
- [x] Implement SSH Terminal UI component
- [x] Implement Main UI with SplitPane and Tabs
- [x] Add Connection Management (Add/Remove)
- [x] Compile and Test
- [x] README and Documentation

1
.master_key Normal file
View File

@ -0,0 +1 @@
ByBYvcAXjSA51UXD76DrxA==

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}

26
README.md Normal file
View File

@ -0,0 +1,26 @@
# Java SSH Terminal
A basic SSH terminal application built with Java 11 and Swing.
## Features
- Connection management in the left side panel.
- Multiple SSH terminals in a tabbed view.
- Master password protection for stored credentials (AES encrypted).
- Toolbar for quick actions (Add connection, Disconnect).
## Prerequisites
- Java 11
- Maven
## How to Run
1. Build the project:
```bash
mvn clean package
```
2. Run the application:
```bash
java -jar target/java-ssh-terminal-1.0-SNAPSHOT.jar
```
## Note on Terminal Emulation
This is a base application. The terminal component uses a basic `JTextArea` which does not support full ANSI escape sequences (colors, cursor positioning). For production use, consider integrating a library like `JediTerm`.

1
connections.json Normal file
View File

@ -0,0 +1 @@
[{"name":"server01","host":"server01","port":64022,"username":"kamma","encryptedPassword":"cT7BoqH9BONVY740RjdkFQ\u003d\u003d"},{"name":"kamma.cz","host":"kamma.cz","port":22,"username":"root","encryptedPassword":"DmErsKpA1Jf4az0cc2lrgA\u003d\u003d"}]

78
pom.xml Normal file
View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cz.kamma.term</groupId>
<artifactId>java-ssh-terminal</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- SSH Library -->
<dependency>
<groupId>com.github.mwiede</groupId>
<artifactId>jsch</artifactId>
<version>0.2.17</version>
</dependency>
<!-- JSON for storing connections -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<!-- Better Terminal UI (Optional but good) -->
<!-- We will use a standard JTextArea/JTextPane with ANSI support for simplicity or a lightweight lib -->
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<manifest>
<mainClass>cz.kamma.term.App</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.4</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>cz.kamma.term.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

1
settings.json Normal file
View File

@ -0,0 +1 @@
{"fontName":"Monospaced","fontSize":12,"backgroundColor":-16777216,"foregroundColor":-16711936}

View File

@ -0,0 +1,18 @@
package cz.kamma.term;
import cz.kamma.term.ui.MainFrame;
import javax.swing.*;
public class App {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception e) {
e.printStackTrace();
}
new MainFrame().setVisible(true);
});
}
}

View File

@ -0,0 +1,20 @@
package cz.kamma.term.model;
import java.awt.Color;
public class AppSettings {
private String fontName = "Monospaced";
private int fontSize = 14;
private int backgroundColor = Color.BLACK.getRGB();
private int foregroundColor = Color.GREEN.getRGB();
// Getters and Setters
public String getFontName() { return fontName; }
public void setFontName(String fontName) { this.fontName = fontName; }
public int getFontSize() { return fontSize; }
public void setFontSize(int fontSize) { this.fontSize = fontSize; }
public int getBackgroundColor() { return backgroundColor; }
public void setBackgroundColor(int backgroundColor) { this.backgroundColor = backgroundColor; }
public int getForegroundColor() { return foregroundColor; }
public void setForegroundColor(int foregroundColor) { this.foregroundColor = foregroundColor; }
}

View File

@ -0,0 +1,34 @@
package cz.kamma.term.model;
public class ConnectionInfo {
private String name;
private String host;
private int port = 22;
private String username;
private String encryptedPassword;
public ConnectionInfo(String name, String host, int port, String username, String encryptedPassword) {
this.name = name;
this.host = host;
this.port = port;
this.username = username;
this.encryptedPassword = encryptedPassword;
}
// Getters and Setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getHost() { return host; }
public void setHost(String host) { this.host = host; }
public int getPort() { return port; }
public void setPort(int port) { this.port = port; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getEncryptedPassword() { return encryptedPassword; }
public void setEncryptedPassword(String encryptedPassword) { this.encryptedPassword = encryptedPassword; }
@Override
public String toString() {
return name + " (" + host + ")";
}
}

View File

@ -0,0 +1,55 @@
package cz.kamma.term.service;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import cz.kamma.term.model.ConnectionInfo;
import java.io.*;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
public class ConnectionManager {
private static final String FILE_NAME = "connections.json";
private final Gson gson = new Gson();
private List<ConnectionInfo> connections = new ArrayList<>();
public ConnectionManager() {
load();
}
public List<ConnectionInfo> getConnections() {
return connections;
}
public void addConnection(ConnectionInfo connection) {
connections.add(connection);
save();
}
public void removeConnection(ConnectionInfo connection) {
connections.remove(connection);
save();
}
public void save() {
try (Writer writer = new FileWriter(FILE_NAME)) {
gson.toJson(connections, writer);
} catch (IOException e) {
e.printStackTrace();
}
}
private void load() {
File file = new File(FILE_NAME);
if (file.exists()) {
try (Reader reader = new FileReader(file)) {
Type listType = new TypeToken<ArrayList<ConnectionInfo>>(){}.getType();
connections = gson.fromJson(reader, listType);
if (connections == null) connections = new ArrayList<>();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

View File

@ -0,0 +1,37 @@
package cz.kamma.term.service;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.spec.KeySpec;
import java.util.Base64;
public class EncryptionService {
private static final String ALGORITHM = "AES";
private static final String SALT = "some_random_salt_123"; // Should be stored properly, but for base app this is okay
public static String encrypt(String password, String masterPassword) throws Exception {
SecretKey key = deriveKey(masterPassword);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] encryptedBytes = cipher.doFinal(password.getBytes());
return Base64.getEncoder().encodeToString(encryptedBytes);
}
public static String decrypt(String encryptedPassword, String masterPassword) throws Exception {
SecretKey key = deriveKey(masterPassword);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, key);
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedPassword));
return new String(decryptedBytes);
}
private static SecretKey deriveKey(String masterPassword) throws Exception {
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(masterPassword.toCharArray(), SALT.getBytes(), 65536, 128);
SecretKey tmp = factory.generateSecret(spec);
return new SecretKeySpec(tmp.getEncoded(), ALGORITHM);
}
}

View File

@ -0,0 +1,36 @@
package cz.kamma.term.service;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class HistoryManager {
private List<String> history = new ArrayList<>();
public HistoryManager() {
// Don't load local history - will be loaded from server
}
public void addRemoteCommand(String command) {
if (command == null || command.trim().isEmpty()) return;
// Remove duplicate if it was the last command
if (!history.isEmpty() && history.get(history.size() - 1).equals(command)) {
return;
}
history.add(command);
// Don't save locally - server handles it via .history
}
public void loadRemoteHistory(List<String> remoteCommands) {
if (remoteCommands != null) {
history.clear();
history.addAll(remoteCommands);
}
}
public List<String> getHistory() {
return history;
}
}

View File

@ -0,0 +1,42 @@
package cz.kamma.term.service;
import com.google.gson.Gson;
import cz.kamma.term.model.AppSettings;
import java.io.*;
public class SettingsManager {
private static final String FILE_NAME = "settings.json";
private final Gson gson = new Gson();
private AppSettings settings;
public SettingsManager() {
load();
}
public AppSettings getSettings() {
return settings;
}
public void save() {
try (Writer writer = new FileWriter(FILE_NAME)) {
gson.toJson(settings, writer);
} catch (IOException e) {
e.printStackTrace();
}
}
private void load() {
File file = new File(FILE_NAME);
if (file.exists()) {
try (Reader reader = new FileReader(file)) {
settings = gson.fromJson(reader, AppSettings.class);
} catch (IOException e) {
e.printStackTrace();
}
}
if (settings == null) {
settings = new AppSettings();
}
}
}

View File

@ -0,0 +1,313 @@
package cz.kamma.term.ui;
import cz.kamma.term.model.ConnectionInfo;
import cz.kamma.term.model.AppSettings;
import cz.kamma.term.service.ConnectionManager;
import cz.kamma.term.service.EncryptionService;
import cz.kamma.term.service.SettingsManager;
import cz.kamma.term.util.SystemIdUtil;
import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.File;
import java.nio.file.Files;
public class MainFrame extends JFrame {
private static final String MASTER_PASS_FILE = ".master_key";
private ConnectionManager connectionManager;
private SettingsManager settingsManager;
private JTabbedPane tabbedPane;
private DefaultListModel<ConnectionInfo> listModel;
private JList<ConnectionInfo> connectionList;
private String masterPassword;
public MainFrame() {
setTitle("Java SSH Terminal");
setSize(1000, 700);
setDefaultCloseOperation(EXIT_ON_CLOSE);
setLocationRelativeTo(null);
connectionManager = new ConnectionManager();
settingsManager = new SettingsManager();
if (!tryLoadMasterPassword()) {
showMasterPasswordDialog();
}
initUI();
}
private boolean tryLoadMasterPassword() {
File file = new File(MASTER_PASS_FILE);
if (file.exists()) {
try {
String encryptedMaster = Files.readString(file.toPath()).trim();
masterPassword = EncryptionService.decrypt(encryptedMaster, SystemIdUtil.getMachineId());
return true;
} catch (Exception e) {
file.delete(); // Corrupted or wrong machine
}
}
return false;
}
private void showMasterPasswordDialog() {
JPasswordField passF = new JPasswordField();
JCheckBox rememberCb = new JCheckBox("Remember on this computer");
Object[] message = {
"Enter Master Password:", passF,
rememberCb
};
int option = JOptionPane.showConfirmDialog(this, message, "Security", JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE);
if (option == JOptionPane.OK_OPTION) {
masterPassword = new String(passF.getPassword());
if (masterPassword.isEmpty()) {
System.exit(0);
}
if (rememberCb.isSelected()) {
saveMasterPassword();
}
} else {
System.exit(0);
}
}
private void saveMasterPassword() {
try {
String encryptedMaster = EncryptionService.encrypt(masterPassword, SystemIdUtil.getMachineId());
Files.writeString(new File(MASTER_PASS_FILE).toPath(), encryptedMaster);
} catch (Exception e) {
e.printStackTrace();
}
}
private void initUI() {
// Toolbar
JToolBar toolBar = new JToolBar();
JButton addButton = new JButton("Add Connection");
JButton settingsButton = new JButton("Settings");
toolBar.add(addButton);
toolBar.add(settingsButton);
add(toolBar, BorderLayout.NORTH);
// Left Panel (Connections)
listModel = new DefaultListModel<>();
connectionManager.getConnections().forEach(listModel::addElement);
connectionList = new JList<>(listModel);
connectionList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
JScrollPane leftScroll = new JScrollPane(connectionList);
leftScroll.setPreferredSize(new Dimension(200, 0));
// Main Panel (Tabs)
tabbedPane = new JTabbedPane();
JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftScroll, tabbedPane);
add(splitPane, BorderLayout.CENTER);
// Listeners
addButton.addActionListener(e -> showAddConnectionDialog(null));
settingsButton.addActionListener(e -> showSettingsDialog());
connectionList.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
checkPopup(e);
}
@Override
public void mouseReleased(MouseEvent e) {
checkPopup(e);
}
private void checkPopup(MouseEvent e) {
if (e.isPopupTrigger()) {
int index = connectionList.locationToIndex(e.getPoint());
if (index != -1) {
connectionList.setSelectedIndex(index);
showContextMenu(e.getComponent(), e.getX(), e.getY(), connectionList.getSelectedValue());
}
}
}
@Override
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 2) {
ConnectionInfo selected = connectionList.getSelectedValue();
if (selected != null) {
openTerminal(selected);
}
}
}
});
}
private void openTerminal(ConnectionInfo info) {
TerminalPanel terminal = new TerminalPanel(info, masterPassword, settingsManager.getSettings());
tabbedPane.addTab(info.getName(), terminal);
int index = tabbedPane.indexOfComponent(terminal);
// Custom Tab Component with (X) button
JPanel pnlTab = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 0));
pnlTab.setOpaque(false);
JLabel lblTitle = new JLabel(info.getName());
JButton btnClose = new JButton("x");
// Style the close button
btnClose.setMargin(new Insets(0, 2, 0, 2));
btnClose.setBorderPainted(false);
btnClose.setContentAreaFilled(false);
btnClose.setFocusable(false);
btnClose.setFont(new Font("Monospaced", Font.BOLD, 12));
btnClose.addActionListener(e -> {
terminal.disconnect();
tabbedPane.remove(terminal);
});
pnlTab.add(lblTitle);
pnlTab.add(btnClose);
tabbedPane.setTabComponentAt(index, pnlTab);
tabbedPane.setSelectedIndex(index);
}
private void showContextMenu(Component invoker, int x, int y, ConnectionInfo selected) {
JPopupMenu menu = new JPopupMenu();
JMenuItem connectItem = new JMenuItem("Connect in new tab");
JMenuItem editItem = new JMenuItem("Edit");
JMenuItem deleteItem = new JMenuItem("Delete");
connectItem.addActionListener(e -> openTerminal(selected));
editItem.addActionListener(e -> showAddConnectionDialog(selected));
deleteItem.addActionListener(e -> {
int confirm = JOptionPane.showConfirmDialog(this, "Delete " + selected.getName() + "?", "Confirm", JOptionPane.YES_NO_OPTION);
if (confirm == JOptionPane.YES_OPTION) {
connectionManager.removeConnection(selected);
listModel.removeElement(selected);
}
});
menu.add(connectItem);
menu.addSeparator();
menu.add(editItem);
menu.add(deleteItem);
menu.show(invoker, x, y);
}
private void showAddConnectionDialog(ConnectionInfo existing) {
JTextField nameF = new JTextField(existing != null ? existing.getName() : "");
JTextField hostF = new JTextField(existing != null ? existing.getHost() : "");
JTextField portF = new JTextField(existing != null ? String.valueOf(existing.getPort()) : "22");
JTextField userF = new JTextField(existing != null ? existing.getUsername() : "");
JPasswordField passF = new JPasswordField();
Object[] message = {
"Name:", nameF,
"Host:", hostF,
"Port:", portF,
"User:", userF,
"Pass (leave empty to keep existing):", passF
};
int option = JOptionPane.showConfirmDialog(null, message,
existing == null ? "Add New Connection" : "Edit Connection", JOptionPane.OK_CANCEL_OPTION);
if (option == JOptionPane.OK_OPTION) {
try {
String encryptedPass;
if (passF.getPassword().length > 0) {
encryptedPass = EncryptionService.encrypt(new String(passF.getPassword()), masterPassword);
} else if (existing != null) {
encryptedPass = existing.getEncryptedPassword();
} else {
throw new Exception("Password is required for new connection.");
}
if (existing != null) {
existing.setName(nameF.getText());
existing.setHost(hostF.getText());
existing.setPort(Integer.parseInt(portF.getText()));
existing.setUsername(userF.getText());
existing.setEncryptedPassword(encryptedPass);
connectionManager.save();
connectionList.repaint();
} else {
ConnectionInfo info = new ConnectionInfo(
nameF.getText(),
hostF.getText(),
Integer.parseInt(portF.getText()),
userF.getText(),
encryptedPass
);
connectionManager.addConnection(info);
listModel.addElement(info);
}
} catch (Exception ex) {
JOptionPane.showMessageDialog(this, "Error: " + ex.getMessage());
}
}
}
private void showSettingsDialog() {
AppSettings settings = settingsManager.getSettings();
String[] fonts = GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames();
JComboBox<String> fontCmbo = new JComboBox<>(fonts);
fontCmbo.setSelectedItem(settings.getFontName());
JSpinner sizeSpin = new JSpinner(new SpinnerNumberModel(settings.getFontSize(), 8, 72, 1));
JButton bgBtn = new JButton("Choose Background");
bgBtn.setBackground(new Color(settings.getBackgroundColor()));
final int[] bgArr = {settings.getBackgroundColor()};
bgBtn.addActionListener(e -> {
Color c = JColorChooser.showDialog(this, "Select Background", new Color(bgArr[0]));
if (c != null) {
bgArr[0] = c.getRGB();
bgBtn.setBackground(c);
}
});
JButton fgBtn = new JButton("Choose Foreground");
fgBtn.setBackground(new Color(settings.getForegroundColor()));
final int[] fgArr = {settings.getForegroundColor()};
fgBtn.addActionListener(e -> {
Color c = JColorChooser.showDialog(this, "Select Foreground", new Color(fgArr[0]));
if (c != null) {
fgArr[0] = c.getRGB();
fgBtn.setBackground(c);
}
});
Object[] message = {
"Font:", fontCmbo,
"Size:", sizeSpin,
"Background:", bgBtn,
"Foreground:", fgBtn
};
int option = JOptionPane.showConfirmDialog(this, message, "Settings", JOptionPane.OK_CANCEL_OPTION);
if (option == JOptionPane.OK_OPTION) {
settings.setFontName((String) fontCmbo.getSelectedItem());
settings.setFontSize((Integer) sizeSpin.getValue());
settings.setBackgroundColor(bgArr[0]);
settings.setForegroundColor(fgArr[0]);
settingsManager.save();
// Apply to all tabs
for (int i = 0; i < tabbedPane.getTabCount(); i++) {
Component comp = tabbedPane.getComponentAt(i);
if (comp instanceof TerminalPanel) {
((TerminalPanel) comp).updateSettings(settings);
}
}
}
}
}

View File

@ -0,0 +1,347 @@
package cz.kamma.term.ui;
import com.jcraft.jsch.*;
import cz.kamma.term.model.ConnectionInfo;
import cz.kamma.term.model.AppSettings;
import cz.kamma.term.service.EncryptionService;
import cz.kamma.term.service.HistoryManager;
import cz.kamma.term.util.AnsiHelper;
import javax.swing.*;
import javax.swing.text.DefaultCaret;
import javax.swing.text.NavigationFilter;
import javax.swing.text.Position;
import javax.swing.text.StyledDocument;
import java.awt.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
public class TerminalPanel extends JPanel {
private JTextPane textPane;
private Session session;
private ChannelShell channel;
private PrintStream out;
private String masterPassword;
private ConnectionInfo connectionInfo;
private AppSettings settings;
private HistoryManager historyManager;
private StringBuilder commandLineBuffer = new StringBuilder();
private int historyIndex = -1;
private int commandStartPosition = 0;
public TerminalPanel(ConnectionInfo info, String masterPassword, AppSettings settings) {
this.connectionInfo = info;
this.masterPassword = masterPassword;
this.settings = settings;
this.historyManager = new HistoryManager(); // Initialize without loading any history
setLayout(new BorderLayout());
textPane = new JTextPane();
textPane.setFocusTraversalKeysEnabled(false);
applySettings();
textPane.setCaretColor(Color.WHITE);
// Auto-scroll to bottom
DefaultCaret caret = (DefaultCaret) textPane.getCaret();
caret.setUpdatePolicy(DefaultCaret.ALWAYS_UPDATE);
// Prevent moving caret into protected prompt area
textPane.setNavigationFilter(new NavigationFilter() {
@Override
public void setDot(FilterBypass fb, int dot, Position.Bias bias) {
if (dot < commandStartPosition) dot = commandStartPosition;
fb.setDot(dot, bias);
}
@Override
public void moveDot(FilterBypass fb, int dot, Position.Bias bias) {
if (dot < commandStartPosition) dot = commandStartPosition;
fb.moveDot(dot, bias);
}
});
JScrollPane scrollPane = new JScrollPane(textPane);
add(scrollPane, BorderLayout.CENTER);
textPane.addKeyListener(new KeyAdapter() {
@Override
public void keyPressed(KeyEvent e) {
if (out == null) return;
// UP arrow - previous command in history
if (e.getKeyCode() == KeyEvent.VK_UP) {
if (historyManager.getHistory().isEmpty()) {
e.consume();
return;
}
if (historyIndex < historyManager.getHistory().size() - 1) {
historyIndex++;
String command = historyManager.getHistory().get(historyManager.getHistory().size() - 1 - historyIndex);
displayCommand(command);
}
e.consume();
return;
}
// DOWN arrow - next command in history
if (e.getKeyCode() == KeyEvent.VK_DOWN) {
if (historyIndex > 0) {
historyIndex--;
String command = historyManager.getHistory().get(historyManager.getHistory().size() - 1 - historyIndex);
displayCommand(command);
} else if (historyIndex == 0) {
historyIndex = -1;
clearCurrentCommand();
}
e.consume();
return;
}
String seq = null;
switch (e.getKeyCode()) {
case KeyEvent.VK_TAB:
out.print("\t");
out.flush();
e.consume();
return;
case KeyEvent.VK_RIGHT: seq = "\u001B[C"; break;
case KeyEvent.VK_LEFT: seq = "\u001B[D"; break;
case KeyEvent.VK_HOME: seq = "\u001B[1~"; break;
case KeyEvent.VK_END: seq = "\u001B[4~"; break;
case KeyEvent.VK_DELETE:
// Protect command prompt - only allow delete if there's command content
if (canDeleteCharacter()) {
seq = "\u001B[3~";
}
e.consume();
return;
}
if (seq != null) {
out.print(seq);
out.flush();
e.consume();
}
}
@Override
public void keyTyped(KeyEvent e) {
if (out != null) {
char c = e.getKeyChar();
// Handle backspace - protect command prompt
if (c == '\b') {
if (canDeleteCharacter()) {
out.print(c);
out.flush();
if (commandLineBuffer.length() > 0) {
commandLineBuffer.setLength(commandLineBuffer.length() - 1);
}
}
// Don't send backspace if it would delete the prompt
e.consume();
return;
}
// Send all other characters to server
out.print(c);
out.flush();
if (c == '\r' || c == '\n') {
// Command sent to server, server will save it to .history
// Add to local memory only for history browsing
String cmd = commandLineBuffer.toString();
if (!cmd.trim().isEmpty()) {
historyManager.addRemoteCommand(cmd);
}
commandLineBuffer.setLength(0);
historyIndex = -1;
} else if (!Character.isISOControl(c)) {
commandLineBuffer.append(c);
}
}
e.consume();
}
});
connect();
}
private void connect() {
new Thread(() -> {
try {
JSch jsch = new JSch();
String password = EncryptionService.decrypt(connectionInfo.getEncryptedPassword(), masterPassword);
session = jsch.getSession(connectionInfo.getUsername(), connectionInfo.getHost(), connectionInfo.getPort());
session.setPassword(password);
session.setConfig("StrictHostKeyChecking", "no");
session.connect();
channel = (ChannelShell) session.openChannel("shell");
channel.setPtyType("xterm");
// Set up input from our pipe
PipedInputStream pin = new PipedInputStream(65536);
PipedOutputStream pout = new PipedOutputStream(pin);
out = new PrintStream(pout, true);
channel.setInputStream(pin);
// Connect first, then read output from channel
channel.connect();
// Get the output stream from the channel and read it
InputStream channelInput = channel.getInputStream();
// Read from channel in a separate thread to ensure we get all output
new Thread(() -> {
try {
byte[] buffer = new byte[1024];
int len;
while ((len = channelInput.read(buffer)) > 0) {
String output = new String(buffer, 0, len);
SwingUtilities.invokeLater(() -> {
AnsiHelper.appendAnsi(textPane, output, commandStartPosition);
// Update prompt boundary only when not typing/navigating history
if (commandLineBuffer.length() == 0) {
commandStartPosition = textPane.getDocument().getLength();
}
});
}
} catch (Exception e) {
// Connection closed
}
}).start();
// Load command history from remote server in a separate thread
// Delay to ensure shell initializes first
new Thread(() -> {
try {
Thread.sleep(500); // Shorter delay - 500ms should be enough
loadRemoteHistory();
} catch (InterruptedException ignored) {}
}).start();
// Focus on textPane after successful connection
SwingUtilities.invokeLater(() -> {
textPane.requestFocusInWindow();
textPane.setCaretPosition(textPane.getDocument().getLength());
});
} catch (Exception e) {
SwingUtilities.invokeLater(() -> {
AnsiHelper.appendAnsi(textPane, "\nConnection failed: " + e.getMessage(), 0);
});
}
}).start();
}
private void loadRemoteHistory() {
try {
ChannelSftp sftpChannel = (ChannelSftp) session.openChannel("sftp");
sftpChannel.connect();
try {
// Try to get home directory first
String homeDir = sftpChannel.pwd();
// Try .bash_history first (bash shell), then .history (zsh/generic)
InputStream is = null;
try {
is = sftpChannel.get(homeDir + "/.bash_history");
} catch (com.jcraft.jsch.SftpException e1) {
try {
is = sftpChannel.get(homeDir + "/.history");
} catch (com.jcraft.jsch.SftpException e2) {
throw e1; // throw original exception
}
}
String historyContent = new String(is.readAllBytes());
is.close();
List<String> commands = new ArrayList<>();
for (String line : historyContent.split("\n")) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
commands.add(trimmed);
}
}
historyManager.loadRemoteHistory(commands);
} catch (com.jcraft.jsch.SftpException sftpEx) {
if (sftpEx.id == 2) {
// File not found - that's okay, history is empty
historyManager.loadRemoteHistory(new ArrayList<>());
}
} catch (Exception e) {
// Ignore errors loading history
}
sftpChannel.disconnect();
} catch (Exception e) {
// SFTP not available, continue without history
}
}
public void disconnect() {
if (channel != null && channel.isConnected()) channel.disconnect();
if (session != null && session.isConnected()) session.disconnect();
}
public void updateSettings(AppSettings newSettings) {
this.settings = newSettings;
applySettings();
}
private void applySettings() {
textPane.setBackground(new Color(settings.getBackgroundColor()));
textPane.setForeground(new Color(settings.getForegroundColor()));
textPane.setFont(new Font(settings.getFontName(), Font.PLAIN, settings.getFontSize()));
}
private void displayCommand(String command) {
try {
StyledDocument doc = (StyledDocument) textPane.getDocument();
int docLength = doc.getLength();
// Remove everything after the prompt boundary
if (docLength > commandStartPosition) {
doc.remove(commandStartPosition, docLength - commandStartPosition);
}
// Put the history command into the buffer so it can be edited
commandLineBuffer.setLength(0);
commandLineBuffer.append(command);
// Add command from history to display
AnsiHelper.appendAnsi(textPane, command, commandStartPosition);
} catch (Exception e) {
e.printStackTrace();
}
}
private void clearCurrentCommand() {
try {
StyledDocument doc = (StyledDocument) textPane.getDocument();
int docLength = doc.getLength();
// Remove everything after the prompt boundary
if (docLength > commandStartPosition) {
doc.remove(commandStartPosition, docLength - commandStartPosition);
}
commandLineBuffer.setLength(0);
} catch (Exception e) {
e.printStackTrace();
}
}
private boolean canDeleteCharacter() {
// Allow deletion only if the caret is beyond the protected prompt boundary
// Standard backspace deletes character at caret - 1.
return textPane.getCaretPosition() > commandStartPosition;
}
}

View File

@ -0,0 +1,311 @@
package cz.kamma.term.util;
import javax.swing.*;
import javax.swing.text.*;
import java.awt.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AnsiHelper {
// Comprehensive ANSI escape sequence pattern
// Matches: ESC [ params command, ESC ] OSC sequence, and other escape sequences
private static final Pattern ANSI_PATTERN = Pattern.compile(
"\u001B" + // ESC character
"(?:" +
"\\[([?0-9;]*)([@-~])|" + // CSI sequences: ESC [ params char
"\\].*?(?:\u0007|\u001B\\\\)|" + // OSC sequences
"[()][B0UK]" + // Charset selection
")"
);
public static void appendAnsi(JTextPane textPane, String text, int protectedLength) {
if (text == null || text.isEmpty()) return;
StyledDocument doc = textPane.getStyledDocument();
MutableAttributeSet currentAttrs = new SimpleAttributeSet();
StyleConstants.setForeground(currentAttrs, new Color(0, 200, 0)); // Default green
StyleConstants.setBackground(currentAttrs, Color.BLACK);
Matcher matcher = ANSI_PATTERN.matcher(text);
int lastEnd = 0;
try {
while (matcher.find()) {
// Insert text before the sequence
if (matcher.start() > lastEnd) {
processPlainText(doc, text.substring(lastEnd, matcher.start()), currentAttrs, protectedLength);
}
String params = matcher.group(1);
String command = matcher.group(2);
if (command != null && !command.isEmpty()) {
processCsiSequence(command, params, currentAttrs, doc);
}
lastEnd = matcher.end();
}
// Insert remaining text
if (lastEnd < text.length()) {
processPlainText(doc, text.substring(lastEnd), currentAttrs, protectedLength);
}
textPane.setCaretPosition(doc.getLength());
} catch (BadLocationException e) {
e.printStackTrace();
}
}
private static void processPlainText(StyledDocument doc, String text, AttributeSet attrs, int protectedLength) throws BadLocationException {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c == '\r') {
// Check if next char is \n (standard Windows/SSH line ending)
if (i + 1 < text.length() && text.charAt(i + 1) == '\n') {
// \r\n sequence - treat as single newline
if (sb.length() > 0) {
sb.append('\n');
} else {
doc.insertString(doc.getLength(), "\n", attrs);
}
i++; // Skip the \n
} else {
// Standalone \r - carriage return (move to beginning of line)
if (sb.length() > 0) {
doc.insertString(doc.getLength(), sb.toString(), attrs);
sb.setLength(0);
}
String content = doc.getText(0, doc.getLength());
int lastNewline = content.lastIndexOf('\n');
int startToDelete = lastNewline == -1 ? 0 : lastNewline + 1;
// Respect protected boundary
if (startToDelete < protectedLength) {
startToDelete = protectedLength;
}
int endToDelete = doc.getLength();
if (endToDelete > startToDelete) {
doc.remove(startToDelete, endToDelete - startToDelete);
}
}
} else if (c == '\b') {
// Backspace: delete previous character
if (sb.length() > 0) {
doc.insertString(doc.getLength(), sb.toString(), attrs);
sb.setLength(0);
}
// NEVER delete before or at protectedLength
if (doc.getLength() > protectedLength) {
doc.remove(doc.getLength() - 1, 1);
}
} else if (c == '\t') {
// Tab: convert to spaces (4 spaces)
sb.append(" ");
} else if (!Character.isISOControl(c)) {
sb.append(c);
}
}
if (sb.length() > 0) {
doc.insertString(doc.getLength(), sb.toString(), attrs);
}
}
private static void processCsiSequence(String command, String params, MutableAttributeSet attrs, StyledDocument doc) {
if (params == null) params = "";
switch (command) {
case "m": // SGR - Select Graphic Rendition (colors, styles)
processSgr(params, attrs);
break;
case "K": // EL - Erase in Line
eraseInLine(doc, params);
break;
case "J": // ED - Erase in Display
eraseInDisplay(doc, params);
break;
case "H":
case "f": // CUP/HVP - Cursor Position
// Ignore - JTextPane doesn't support cursor repositioning
break;
case "A": // CUU - Cursor Up
case "B": // CUD - Cursor Down
case "C": // CUF - Cursor Forward (Right)
case "D": // CUB - Cursor Backward (Left)
case "E": // CNL - Cursor Next Line
case "F": // CPL - Cursor Previous Line
case "G": // CHA - Cursor Horizontal Absolute
// Ignore cursor movement - JTextPane handles cursor differently
break;
case "s": // SCP - Save Cursor Position
case "u": // RCP - Restore Cursor Position
// Ignore - not needed for output display
break;
case "h": // SM - Set Mode
case "l": // RM - Reset Mode
// Handle mode settings (like cursor visibility, alt screen)
if (params.contains("25")) {
// ?25h = show cursor, ?25l = hide cursor - we'll just display normally
}
break;
case "c": // DA - Device Attributes
case "n": // DSR - Device Status Report
// Device info requests - ignore
break;
case "1" :
case "2" :
case "3" :
case "4" :
// These look like incomplete sequences, ignore
break;
}
}
private static void processSgr(String params, MutableAttributeSet attrs) {
if (params == null || params.isEmpty()) {
resetAttributes(attrs);
return;
}
String[] codes = params.split(";");
for (String code : codes) {
if (code.isEmpty()) continue;
try {
int c = Integer.parseInt(code);
if (c == 0) {
// Reset all attributes
resetAttributes(attrs);
} else if (c == 1) {
// Bold
StyleConstants.setBold(attrs, true);
} else if (c == 2) {
// Dim (reduced intensity)
StyleConstants.setBold(attrs, false);
} else if (c == 3) {
// Italic
StyleConstants.setItalic(attrs, true);
} else if (c == 4) {
// Underline
StyleConstants.setUnderline(attrs, true);
} else if (c == 5) {
// Blink (we'll just show it normally)
StyleConstants.setBold(attrs, true);
} else if (c == 7) {
// Reverse video
Color fg = (Color) attrs.getAttribute(StyleConstants.Foreground);
Color bg = (Color) attrs.getAttribute(StyleConstants.Background);
if (fg != null && bg != null) {
StyleConstants.setForeground(attrs, bg);
StyleConstants.setBackground(attrs, fg);
}
} else if (c == 22) {
// Normal intensity
StyleConstants.setBold(attrs, false);
} else if (c == 23) {
// No italic
StyleConstants.setItalic(attrs, false);
} else if (c == 24) {
// No underline
StyleConstants.setUnderline(attrs, false);
} else if (c >= 30 && c <= 37) {
// Foreground color (standard)
StyleConstants.setForeground(attrs, getAnsiColor(c - 30, false));
} else if (c == 39) {
// Default foreground
StyleConstants.setForeground(attrs, new Color(0, 200, 0));
} else if (c >= 40 && c <= 47) {
// Background color (standard)
StyleConstants.setBackground(attrs, getAnsiColor(c - 40, false));
} else if (c == 49) {
// Default background
StyleConstants.setBackground(attrs, Color.BLACK);
} else if (c >= 90 && c <= 97) {
// Foreground color (bright)
StyleConstants.setForeground(attrs, getAnsiColor(c - 90, true));
} else if (c >= 100 && c <= 107) {
// Background color (bright)
StyleConstants.setBackground(attrs, getAnsiColor(c - 100, true));
}
} catch (NumberFormatException ignored) {
// Ignore invalid codes
}
}
}
private static void resetAttributes(MutableAttributeSet attrs) {
StyleConstants.setForeground(attrs, new Color(0, 200, 0)); // Default green
StyleConstants.setBackground(attrs, Color.BLACK);
StyleConstants.setBold(attrs, false);
StyleConstants.setItalic(attrs, false);
StyleConstants.setUnderline(attrs, false);
}
private static void eraseInLine(StyledDocument doc, String param) {
try {
// EL 0 = erase from cursor to end of line (default)
// EL 1 = erase from start of line to cursor
// EL 2 = erase entire line
String content = doc.getText(0, doc.getLength());
int lastNewline = content.lastIndexOf('\n');
int lineStart = lastNewline == -1 ? 0 : lastNewline + 1;
int lineEnd = doc.getLength();
if ("2".equals(param)) {
// Erase entire line
doc.remove(lineStart, lineEnd - lineStart);
} else {
// Default: erase from cursor to end of line
doc.remove(lineStart, lineEnd - lineStart);
}
} catch (BadLocationException ignored) {}
}
private static void eraseInDisplay(StyledDocument doc, String param) {
try {
// ED 0 = erase from cursor to end of display (default)
// ED 1 = erase from start of display to cursor
// ED 2 = erase entire display
// ED 3 = erase scrollback buffer
if ("2".equals(param) || "3".equals(param)) {
// For terminal apps: don't clear entire display,
// just leave the content as is
// Clearing would remove important command output
}
} catch (Exception ignored) {}
}
private static Color getAnsiColor(int index, boolean bright) {
if (bright) {
switch (index) {
case 0: return new Color(128, 128, 128); // Bright Black (Gray)
case 1: return new Color(255, 85, 85); // Bright Red
case 2: return new Color(85, 255, 85); // Bright Green
case 3: return new Color(255, 255, 85); // Bright Yellow
case 4: return new Color(85, 85, 255); // Bright Blue
case 5: return new Color(255, 85, 255); // Bright Magenta
case 6: return new Color(85, 255, 255); // Bright Cyan
case 7: return new Color(255, 255, 255); // Bright White
default: return Color.WHITE;
}
} else {
switch (index) {
case 0: return new Color(0, 0, 0); // Black
case 1: return new Color(170, 0, 0); // Red
case 2: return new Color(0, 170, 0); // Green
case 3: return new Color(170, 85, 0); // Yellow
case 4: return new Color(0, 0, 170); // Blue
case 5: return new Color(170, 0, 170); // Magenta
case 6: return new Color(0, 170, 170); // Cyan
case 7: return new Color(170, 170, 170); // White
default: return new Color(0, 200, 0);
}
}
}
}

View File

@ -0,0 +1,30 @@
package cz.kamma.term.util;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.util.Enumeration;
public class SystemIdUtil {
public static String getMachineId() {
try {
StringBuilder sb = new StringBuilder();
sb.append(System.getProperty("os.name"));
sb.append(System.getProperty("user.name"));
Enumeration<NetworkInterface> networkInterfaces = NetworkInterface.getNetworkInterfaces();
while (networkInterfaces.hasMoreElements()) {
NetworkInterface ni = networkInterfaces.nextElement();
byte[] hardwareAddress = ni.getHardwareAddress();
if (hardwareAddress != null) {
for (byte b : hardwareAddress) {
sb.append(String.format("%02X", b));
}
break; // Just use the first MAC address found
}
}
return sb.toString();
} catch (Exception e) {
return "fallback-machine-id-12345";
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,15 @@
cz/kamma/term/service/ConnectionManager.class
cz/kamma/term/service/HistoryManager.class
cz/kamma/term/ui/MainFrame$1.class
cz/kamma/term/model/AppSettings.class
cz/kamma/term/util/SystemIdUtil.class
cz/kamma/term/service/EncryptionService.class
cz/kamma/term/model/ConnectionInfo.class
cz/kamma/term/ui/TerminalPanel$2.class
cz/kamma/term/ui/TerminalPanel.class
cz/kamma/term/service/SettingsManager.class
cz/kamma/term/ui/TerminalPanel$1.class
cz/kamma/term/service/ConnectionManager$1.class
cz/kamma/term/ui/MainFrame.class
cz/kamma/term/util/AnsiHelper.class
cz/kamma/term/App.class

View File

@ -0,0 +1,11 @@
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/util/SystemIdUtil.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/ui/MainFrame.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/HistoryManager.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/EncryptionService.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/SettingsManager.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/service/ConnectionManager.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/model/AppSettings.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/ui/TerminalPanel.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/model/ConnectionInfo.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/util/AnsiHelper.java
/home/kamma/projects/terminal/src/main/java/cz/kamma/term/App.java