diff --git a/ts3-client/README.md b/ts3-client/README.md index ac4fda6..c96b1ac 100644 --- a/ts3-client/README.md +++ b/ts3-client/README.md @@ -54,7 +54,7 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. - Configurable capture/playback **devices**. ### Server interaction -- Connect to any TS3 server (auto-generates and persists a TS3 identity). +- Connect to any TS3 server (an identity is generated on first use if none exists). - **Channel/client tree** styled like TS3, updated live from protocol events (joins, leaves, moves, channel create/edit/delete, nickname/mute/away changes). - **Talk indicators** — speakers turn green live as they talk. @@ -68,7 +68,11 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged. **private chat**, or **locally mute** them. - **Chat** to the current channel or the whole server; receive channel/server/private messages and **pokes**. -- **Server bookmarks** — quick-connect menu with add/edit/remove management. +- **Server bookmarks** — quick-connect menu with add/edit/remove management, each + optionally pinned to a specific identity. +- **Identity management** (Tools → Identities) — keep several identities, mark one + as the default, pick one per server or per bookmark, rename, raise an identity's + security level, and import/export TeamSpeak-compatible `.ini` identity files. - **Self status** — Away (with message) and Channel Commander toggles. - **Status bar** shows the server name, user count and live ping. - Change your nickname, mute/deafen from the toolbar. @@ -97,15 +101,16 @@ java -jar swing/target/ts3-client.jar mvn -pl swing exec:java ``` -Then use **Connections → Connect…**, enter a server address, port (default 9987) -and nickname, and connect. Open **Tools → Options** to pick audio devices and tune +Then use **Connections → Connect…**, enter a server address, port (default 9987), +nickname and identity, and connect. Open **Tools → Options** to pick audio devices and tune voice activation while watching the live meter. ## Architecture ``` core/ com.ts3client ├── config.Settings persisted prefs (~/.ts3jclient/settings.properties) -├── config.Bookmarks persisted server bookmarks +├── config.Bookmarks persisted server bookmarks (with per-server identity) +├── config.IdentityStore managed identities (~/.ts3jclient/identities/*.ini) ├── audio abstractions + reusable DSP (no platform code) │ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput │ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls @@ -137,6 +142,8 @@ swing/ com.ts3client ├── SettingsDialog audio + VAD options with live meter ├── ConnectDialog connect form ├── BookmarksDialog manage saved servers + ├── IdentitiesDialog manage identities (new/import/export/improve) + ├── IdentityChooser identity drop-down shared by connect/bookmark forms ├── LevelMeter dBFS meter with threshold marker ├── Icons programmatic vector icons (no image assets) └── Theme palette + fonts diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java index 6d757bf..83786e3 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java @@ -7,6 +7,8 @@ public final class Bookmark { public int port = 9987; public String nickname; public String password = ""; + /** Identity to connect with; empty means "use the default identity". */ + public String identityId = ""; public Bookmark() { } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java index 07ea106..a8febd4 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java @@ -48,6 +48,7 @@ public final class Bookmarks { bm.port = parseInt(p.getProperty(prefix + "port"), 9987); bm.nickname = p.getProperty(prefix + "nickname", ""); bm.password = p.getProperty(prefix + "password", ""); + bm.identityId = p.getProperty(prefix + "identityId", ""); if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm); } return b; @@ -64,6 +65,7 @@ public final class Bookmarks { p.setProperty(prefix + "port", Integer.toString(bm.port)); p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname)); p.setProperty(prefix + "password", nullToEmpty(bm.password)); + p.setProperty(prefix + "identityId", nullToEmpty(bm.identityId)); } try { if (!DIR.isDirectory()) { diff --git a/ts3-client/core/src/main/java/com/ts3client/config/IdentityEntry.java b/ts3-client/core/src/main/java/com/ts3client/config/IdentityEntry.java new file mode 100644 index 0000000..a4a42ca --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/IdentityEntry.java @@ -0,0 +1,60 @@ +package com.ts3client.config; + +import com.github.manevolent.ts3j.identity.LocalIdentity; + +import java.io.File; + +/** + * One managed identity: a TS3 key pair plus the user-visible name it is filed + * under. Backed by a TeamSpeak-compatible identity INI file on disk. + */ +public final class IdentityEntry { + + private final String id; + private final File file; + private final LocalIdentity identity; + private String name; + + IdentityEntry(String id, String name, File file, LocalIdentity identity) { + this.id = id; + this.name = name; + this.file = file; + this.identity = identity; + } + + /** Stable key used to reference this identity from settings and bookmarks. */ + public String getId() { + return id; + } + + public String getName() { + return name; + } + + void setName(String name) { + this.name = name; + } + + public File getFile() { + return file; + } + + public LocalIdentity getIdentity() { + return identity; + } + + /** The TeamSpeak unique ID (base64 SHA-1 of the public key). */ + public String getUniqueId() { + return identity.getUid().toBase64(); + } + + /** Current security level ("hash cash" leading-zero bits) of the identity. */ + public int getSecurityLevel() { + return identity.getSecurityLevel(); + } + + @Override + public String toString() { + return name; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java b/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java new file mode 100644 index 0000000..2b8893b --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/IdentityStore.java @@ -0,0 +1,312 @@ +package com.ts3client.config; + +import com.github.manevolent.ts3j.identity.LocalIdentity; +import org.ini4j.Ini; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Manages the user's TeamSpeak identities, mirroring the official client: + * several identities may coexist, one is the default, and any of them can be + * imported from or exported to a TS3 identity INI file. + * + *

Each identity lives in its own {@code ~/.ts3jclient/identities/<id>.ini} + * file in TeamSpeak's own format, so files can be exchanged with the official + * client verbatim. The display name is kept in the INI's {@code id} key, again + * as TeamSpeak does. + */ +public final class IdentityStore { + + /** Security level new identities are generated with, matching the TS3 client's default. */ + public static final int DEFAULT_SECURITY_LEVEL = 8; + + private static final File DIR = new File(System.getProperty("user.home"), ".ts3jclient"); + private static final File IDENTITY_DIR = new File(DIR, "identities"); + + private final List entries = new ArrayList<>(); + + /** + * Reports progress while improving an identity's security level. + * Returning {@code false} aborts the search, keeping the best result so far. + */ + public interface ImproveProgress { + boolean onProgress(int level, long offset); + } + + private IdentityStore() { + } + + /** Loads all identities from disk, migrating a legacy single-identity file if needed. */ + public static IdentityStore load(Settings settings) { + IdentityStore store = new IdentityStore(); + File[] files = IDENTITY_DIR.listFiles((d, n) -> n.toLowerCase(Locale.ROOT).endsWith(".ini")); + if (files != null) { + for (File f : files) { + IdentityEntry e = read(f); + if (e != null) store.entries.add(e); + } + } + if (store.entries.isEmpty()) store.migrateLegacy(settings); + store.sort(); + return store; + } + + /** Adopts the pre-multi-identity {@code identity.ini} as the first managed identity. */ + private void migrateLegacy(Settings settings) { + File legacy = settings.identityFile(); + if (legacy == null || !legacy.isFile()) return; + try { + LocalIdentity identity = LocalIdentity.read(legacy); + create("Default", identity); + } catch (Exception ignored) { + // Unreadable legacy identity: start fresh instead. + } + } + + public List all() { + return entries; + } + + public boolean isEmpty() { + return entries.isEmpty(); + } + + public IdentityEntry byId(String id) { + if (id == null || id.isBlank()) return null; + for (IdentityEntry e : entries) { + if (e.getId().equals(id)) return e; + } + return null; + } + + /** + * Resolves the identity to connect with: the requested one, else the configured + * default, else the first available — generating one when the store is empty. + */ + public IdentityEntry resolve(Settings settings, String preferredId) throws Exception { + IdentityEntry e = byId(preferredId); + if (e != null) return e; + + e = byId(settings.defaultIdentityId); + if (e == null) { + e = entries.isEmpty() ? generate("Default", DEFAULT_SECURITY_LEVEL) : entries.get(0); + settings.defaultIdentityId = e.getId(); + settings.save(); + } + return e; + } + + /** Generates a brand-new identity. Blocking: key improvement takes time. */ + public IdentityEntry generate(String name, int securityLevel) throws Exception { + return create(name, LocalIdentity.generateNew(securityLevel)); + } + + /** Imports a TeamSpeak identity INI file, keeping its own name unless one is given. */ + public IdentityEntry importFile(File source, String name) throws IOException { + LocalIdentity identity = LocalIdentity.read(source); + String label = (name == null || name.isBlank()) ? readName(source, null) : name; + if (label == null || label.isBlank()) label = stripExtension(source.getName()); + return create(label, identity); + } + + /** Imports an identity from a raw TeamSpeak identity INI stream. */ + public IdentityEntry importStream(InputStream in, String name) throws IOException { + LocalIdentity identity = LocalIdentity.read(in); + return create((name == null || name.isBlank()) ? "Imported identity" : name, identity); + } + + /** Writes an identity out in TeamSpeak's own format, ready to import elsewhere. */ + public void exportTo(IdentityEntry entry, File target) throws IOException { + write(entry.getIdentity(), entry.getName(), target); + } + + public void rename(IdentityEntry entry, String name) throws IOException { + if (name == null || name.isBlank()) return; + entry.setName(name.trim()); + write(entry.getIdentity(), entry.getName(), entry.getFile()); + sort(); + } + + /** Deletes an identity, clearing any settings or bookmarks that referenced it. */ + public void remove(IdentityEntry entry, Settings settings, Bookmarks bookmarks) { + entries.remove(entry); + //noinspection ResultOfMethodCallIgnored + entry.getFile().delete(); + + if (entry.getId().equals(settings.defaultIdentityId)) { + settings.defaultIdentityId = entries.isEmpty() ? "" : entries.get(0).getId(); + settings.save(); + } + if (bookmarks != null) { + boolean changed = false; + for (Bookmark b : bookmarks.all()) { + if (entry.getId().equals(b.identityId)) { + b.identityId = ""; + changed = true; + } + } + if (changed) bookmarks.save(); + } + } + + /** + * Raises an identity's security level towards {@code target} by searching for a + * better key offset, persisting the result. Returns the level reached. + * + * @param progress called periodically; return {@code false} from it to stop early + */ + public int improveSecurity(IdentityEntry entry, int target, ImproveProgress progress) throws IOException { + LocalIdentity identity = entry.getIdentity(); + byte[] publicKey = identity.getPublicKeyString().getBytes(StandardCharsets.US_ASCII); + MessageDigest sha1 = sha1(); + + int best = identity.getSecurityLevel(); + long offset = Math.max(identity.getKeyOffset(), identity.getLastCheckedKeyOffset()); + try { + while (best < target) { + int level = securityLevel(sha1, publicKey, offset); + if (level > best) { + best = level; + identity.setKeyOffset(offset); + } + offset++; + if ((offset & 0xFFFF) == 0 && progress != null && !progress.onProgress(best, offset)) break; + } + } finally { + identity.setLastCheckedKeyOffset(offset); + write(identity, entry.getName(), entry.getFile()); + } + return best; + } + + // ---- persistence ---- + + private IdentityEntry create(String name, LocalIdentity identity) throws IOException { + String id = uniqueId(name); + File file = new File(IDENTITY_DIR, id + ".ini"); + write(identity, name, file); + IdentityEntry entry = new IdentityEntry(id, name, file, identity); + entries.add(entry); + sort(); + return entry; + } + + private static void write(LocalIdentity identity, String name, File file) throws IOException { + File dir = file.getParentFile(); + if (dir != null && !dir.isDirectory() && !dir.mkdirs()) { + throw new IOException("Cannot create " + dir); + } + Map props = new HashMap<>(); + props.put("id", name == null ? "" : name); + identity.save(file, props); + } + + private static IdentityEntry read(File file) { + try { + LocalIdentity identity = LocalIdentity.read(file); + String id = stripExtension(file.getName()); + String name = readName(file, id); + return new IdentityEntry(id, name, file, identity); + } catch (Exception e) { + return null; + } + } + + /** Reads the display name from an identity INI's {@code id} key. */ + private static String readName(File file, String fallback) { + try { + Ini ini = new Ini(file); + String name = unquote(ini.get("Identity", "id")); + if (name != null && !name.isBlank()) return name; + } catch (Exception ignored) { + } + return fallback; + } + + /** TeamSpeak quotes INI values; ini4j hands them back verbatim. */ + private static String unquote(String value) { + if (value == null) return null; + String s = value.trim(); + if (s.length() >= 2 && s.startsWith("\"") && s.endsWith("\"")) { + s = s.substring(1, s.length() - 1); + } + return s.replace("\\\"", "\""); + } + + /** Derives a filesystem-safe, collision-free key from the display name. */ + private String uniqueId(String name) { + String base = (name == null ? "" : name).trim().toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("(^-+|-+$)", ""); + if (base.isEmpty()) base = "identity"; + String candidate = base; + for (int i = 2; taken(candidate); i++) { + candidate = base + "-" + i; + } + return candidate; + } + + private boolean taken(String id) { + if (byId(id) != null) return true; + return new File(IDENTITY_DIR, id + ".ini").exists(); + } + + private void sort() { + entries.sort(Comparator.comparing(e -> e.getName().toLowerCase(Locale.ROOT))); + } + + private static String stripExtension(String fileName) { + int dot = fileName.lastIndexOf('.'); + return dot > 0 ? fileName.substring(0, dot) : fileName; + } + + // ---- security level search ---- + + private static MessageDigest sha1() { + try { + return MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + /** Leading zero bits of SHA-1(publicKey + decimal offset) — TeamSpeak's proof of work. */ + private static int securityLevel(MessageDigest sha1, byte[] publicKey, long offset) { + sha1.reset(); + sha1.update(publicKey); + sha1.update(Long.toString(offset).getBytes(StandardCharsets.US_ASCII)); + return leadingZeroBits(sha1.digest()); + } + + private static int leadingZeroBits(byte[] data) { + int count = 0; + for (byte b : data) { + if (b == 0) { + count += 8; + continue; + } + for (int bit = 0; bit < 8; bit++) { + if ((b & (1 << bit)) == 0) count++; + else break; + } + break; + } + return count; + } + + /** Directory the managed identity files live in. */ + public static File directory() { + return IDENTITY_DIR; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 67e8134..0076bf7 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -44,7 +44,10 @@ public final class Settings { public String lastAddress = "localhost"; public String nickname = System.getProperty("user.name", "TS3J User"); public String serverPassword = ""; + /** Legacy single-identity file; only used to migrate into the identity store. */ public String identityFile = new File(DIR, "identity.ini").getAbsolutePath(); + /** Id of the identity used when a server doesn't select one of its own. */ + public String defaultIdentityId = ""; // ---- audio devices (mixer names; empty = system default) ---- public String inputDevice = ""; @@ -129,6 +132,7 @@ public final class Settings { nickname = props.getProperty("nickname", nickname); serverPassword = props.getProperty("serverPassword", serverPassword); identityFile = props.getProperty("identityFile", identityFile); + defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId); inputDevice = props.getProperty("inputDevice", inputDevice); outputDevice = props.getProperty("outputDevice", outputDevice); inputMode = parseMode(props.getProperty("inputMode"), inputMode); @@ -156,6 +160,7 @@ public final class Settings { props.setProperty("nickname", nickname); props.setProperty("serverPassword", serverPassword); props.setProperty("identityFile", identityFile); + props.setProperty("defaultIdentityId", defaultIdentityId); props.setProperty("inputDevice", inputDevice); props.setProperty("outputDevice", outputDevice); props.setProperty("inputMode", inputMode.name()); diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index fa23d74..1635172 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -77,37 +77,25 @@ public final class TeamspeakConnection implements TS3Listener { return playback; } - // ---- identity ---- - - /** Loads the persisted identity, or generates and saves a new one. */ - public LocalIdentity loadOrCreateIdentity() throws Exception { - File file = settings.identityFile(); - if (file.isFile()) { - try { - return LocalIdentity.read(file); - } catch (Exception e) { - // fall through and regenerate - } - } - LocalIdentity id = LocalIdentity.generateNew(10); - if (!file.getParentFile().isDirectory()) { - //noinspection ResultOfMethodCallIgnored - file.getParentFile().mkdirs(); - } - id.save(file); - return id; + /** The identity this connection authenticated with. */ + public LocalIdentity getIdentity() { + return identity; } // ---- connection lifecycle ---- - public void connect(String address, int port, String nickname, String password) { - new Thread(() -> doConnect(address, port, nickname, password), "ts3j-connect").start(); + /** + * Connects in the background. + * + * @param identity identity to authenticate with; see {@link com.ts3client.config.IdentityStore} + */ + public void connect(String address, int port, String nickname, String password, LocalIdentity identity) { + new Thread(() -> doConnect(address, port, nickname, password, identity), "ts3j-connect").start(); } - private void doConnect(String address, int port, String nickname, String password) { + private void doConnect(String address, int port, String nickname, String password, LocalIdentity withIdentity) { try { - ui.onStatus("Loading identity…"); - identity = loadOrCreateIdentity(); + identity = withIdentity; model.clear(); playback = audio.createOutput(settings); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java index cf4315a..093c33d 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java @@ -2,6 +2,7 @@ package com.ts3client.ui; import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmarks; +import com.ts3client.config.IdentityStore; import javax.swing.BorderFactory; import javax.swing.Box; @@ -26,14 +27,17 @@ import java.util.function.Consumer; public final class BookmarksDialog extends JDialog { private final Bookmarks bookmarks; + private final IdentityStore identities; private final Consumer onConnect; private final Runnable onChanged; private final DefaultListModel listModel = new DefaultListModel<>(); private final JList list = new JList<>(listModel); - public BookmarksDialog(Frame owner, Bookmarks bookmarks, Consumer onConnect, Runnable onChanged) { + public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities, + Consumer onConnect, Runnable onChanged) { super(owner, "Manage Bookmarks", true); this.bookmarks = bookmarks; + this.identities = identities; this.onConnect = onConnect; this.onChanged = onChanged; @@ -120,6 +124,7 @@ public final class BookmarksDialog extends JDialog { JTextField port = new JTextField(Integer.toString(b.port)); JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname); JPasswordField password = new JPasswordField(b.password == null ? "" : b.password); + IdentityChooser identity = new IdentityChooser(identities, true, b.identityId); JPanel form = new JPanel(new GridLayout(0, 1, 0, 2)); form.add(new JLabel("Label:")); @@ -132,6 +137,8 @@ public final class BookmarksDialog extends JDialog { form.add(nick); form.add(new JLabel("Password (optional):")); form.add(password); + form.add(new JLabel("Identity:")); + form.add(identity); int result = JOptionPane.showConfirmDialog(this, form, "Bookmark", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); @@ -150,6 +157,7 @@ public final class BookmarksDialog extends JDialog { } b.nickname = nick.getText().trim(); b.password = new String(password.getPassword()); + b.identityId = identity.getSelectedIdentityId(); return true; } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java index 2b268f3..6a76337 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ConnectDialog.java @@ -1,5 +1,6 @@ package com.ts3client.ui; +import com.ts3client.config.IdentityStore; import com.ts3client.config.Settings; import javax.swing.BorderFactory; @@ -23,10 +24,11 @@ public final class ConnectDialog extends JDialog { private final JTextField portField; private final JTextField nickField; private final JPasswordField passwordField; + private final IdentityChooser identityChooser; private boolean confirmed; - public ConnectDialog(Frame owner, Settings settings) { + public ConnectDialog(Frame owner, Settings settings, IdentityStore identities) { super(owner, "Connect to Server", true); String addr = settings.lastAddress; @@ -44,6 +46,7 @@ public final class ConnectDialog extends JDialog { portField = new JTextField(Integer.toString(port), 6); nickField = new JTextField(settings.nickname, 18); passwordField = new JPasswordField(settings.serverPassword, 18); + identityChooser = new IdentityChooser(identities, false, settings.defaultIdentityId); JPanel form = new JPanel(new GridBagLayout()); form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12)); @@ -57,6 +60,7 @@ public final class ConnectDialog extends JDialog { add(form, c, row++, "Port:", portField); add(form, c, row++, "Nickname:", nickField); add(form, c, row++, "Password (optional):", passwordField); + add(form, c, row++, "Identity:", identityChooser); JPanel buttons = new JPanel(new BorderLayout()); JPanel right = new JPanel(); @@ -116,4 +120,9 @@ public final class ConnectDialog extends JDialog { public String getPassword() { return new String(passwordField.getPassword()); } + + /** Chosen identity id, or an empty string to fall back to the default identity. */ + public String getIdentityId() { + return identityChooser.getSelectedIdentityId(); + } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java new file mode 100644 index 0000000..7a7cae4 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentitiesDialog.java @@ -0,0 +1,365 @@ +package com.ts3client.ui; + +import com.ts3client.config.Bookmarks; +import com.ts3client.config.IdentityEntry; +import com.ts3client.config.IdentityStore; +import com.ts3client.config.Settings; + +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JButton; +import javax.swing.JDialog; +import javax.swing.JFileChooser; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.Frame; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.io.File; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Identity manager, mirroring the TeamSpeak 3 client's Tools → Identities: + * create, import, export, rename and delete identities, mark one as the default + * and raise an identity's security level. + */ +public final class IdentitiesDialog extends JDialog { + + private final IdentityStore identities; + private final Settings settings; + private final Bookmarks bookmarks; + private final Runnable onChanged; + + private final DefaultListModel listModel = new DefaultListModel<>(); + private final JList list = new JList<>(listModel); + + private final JTextField nameField = new JTextField(); + private final JTextField uidField = new JTextField(); + private final JTextField levelField = new JTextField(); + + public IdentitiesDialog(Frame owner, IdentityStore identities, Settings settings, + Bookmarks bookmarks, Runnable onChanged) { + super(owner, "Identities", true); + this.identities = identities; + this.settings = settings; + this.bookmarks = bookmarks; + this.onChanged = onChanged; + + list.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList l, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + String text = value instanceof IdentityEntry ? label((IdentityEntry) value) : String.valueOf(value); + return super.getListCellRendererComponent(l, text, index, isSelected, cellHasFocus); + } + }); + list.addListSelectionListener(e -> showDetails(list.getSelectedValue())); + list.setVisibleRowCount(10); + + JScrollPane scroll = new JScrollPane(list); + scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + scroll.setPreferredSize(new Dimension(220, 260)); + + JPanel buttons = new JPanel(); + buttons.setLayout(new BoxLayout(buttons, BoxLayout.Y_AXIS)); + buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8)); + addButton(buttons, "New…", this::newIdentity); + addButton(buttons, "Import…", this::importIdentity); + addButton(buttons, "Export…", this::exportSelected); + addButton(buttons, "Rename…", this::renameSelected); + addButton(buttons, "Set as default", this::setSelectedDefault); + addButton(buttons, "Improve security…", this::improveSelected); + addButton(buttons, "Remove", this::removeSelected); + buttons.add(Box.createVerticalGlue()); + addButton(buttons, "Close", this::dispose); + + getContentPane().setLayout(new BorderLayout()); + getContentPane().add(scroll, BorderLayout.WEST); + getContentPane().add(buildDetails(), BorderLayout.CENTER); + getContentPane().add(buttons, BorderLayout.EAST); + + reload(); + if (!listModel.isEmpty()) list.setSelectedIndex(0); + + setDefaultCloseOperation(DISPOSE_ON_CLOSE); + pack(); + setMinimumSize(new Dimension(640, 320)); + setLocationRelativeTo(owner); + } + + private JPanel buildDetails() { + JPanel form = new JPanel(new GridBagLayout()); + form.setBorder(BorderFactory.createEmptyBorder(12, 4, 12, 8)); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(4, 4, 4, 4); + c.anchor = GridBagConstraints.NORTHWEST; + c.fill = GridBagConstraints.HORIZONTAL; + + nameField.setEditable(false); + uidField.setEditable(false); + levelField.setEditable(false); + + int row = 0; + addRow(form, c, row++, "Name:", nameField); + addRow(form, c, row++, "Unique ID:", uidField); + addRow(form, c, row++, "Security level:", levelField); + + c.gridx = 0; + c.gridy = row; + c.gridwidth = 2; + c.weighty = 1; + c.fill = GridBagConstraints.BOTH; + form.add(Box.createGlue(), c); + return form; + } + + private void addRow(JPanel form, GridBagConstraints c, int row, String label, Component field) { + c.gridx = 0; + c.gridy = row; + c.weightx = 0; + form.add(new JLabel(label), c); + c.gridx = 1; + c.weightx = 1; + form.add(field, c); + } + + private void addButton(JPanel panel, String text, Runnable action) { + JButton b = new JButton(text); + b.setAlignmentX(LEFT_ALIGNMENT); + b.setMaximumSize(new Dimension(Integer.MAX_VALUE, b.getPreferredSize().height)); + b.addActionListener(e -> action.run()); + panel.add(b); + panel.add(Box.createVerticalStrut(4)); + } + + private String label(IdentityEntry e) { + return e.getId().equals(settings.defaultIdentityId) ? e.getName() + " (default)" : e.getName(); + } + + private void reload() { + IdentityEntry selected = list.getSelectedValue(); + listModel.clear(); + for (IdentityEntry e : identities.all()) listModel.addElement(e); + if (selected != null && listModel.contains(selected)) { + list.setSelectedValue(selected, true); + } else if (!listModel.isEmpty()) { + list.setSelectedIndex(0); + } else { + showDetails(null); + } + if (onChanged != null) onChanged.run(); + } + + private void showDetails(IdentityEntry e) { + nameField.setText(e == null ? "" : e.getName()); + uidField.setText(e == null ? "" : e.getUniqueId()); + levelField.setText(e == null ? "" : Integer.toString(e.getSecurityLevel())); + } + + // ---- actions ---- + + private void newIdentity() { + String name = JOptionPane.showInputDialog(this, "Name for the new identity:", "New identity"); + if (name == null || name.trim().isEmpty()) return; + + int level = askLevel("Security level to generate:", IdentityStore.DEFAULT_SECURITY_LEVEL); + if (level < 0) return; + + runWithProgress("Generating identity…", (cancelled, status) -> { + IdentityEntry created = identities.generate(name.trim(), level); + if (settings.defaultIdentityId == null || identities.byId(settings.defaultIdentityId) == null) { + settings.defaultIdentityId = created.getId(); + settings.save(); + } + return created; + }, false); + } + + private void importIdentity() { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Import identity"); + chooser.setFileFilter(new FileNameExtensionFilter("TeamSpeak identity (*.ini)", "ini")); + if (chooser.showOpenDialog(this) != JFileChooser.APPROVE_OPTION) return; + File file = chooser.getSelectedFile(); + try { + IdentityEntry imported = identities.importFile(file, null); + if (identities.byId(settings.defaultIdentityId) == null) { + settings.defaultIdentityId = imported.getId(); + settings.save(); + } + reload(); + list.setSelectedValue(imported, true); + } catch (Exception e) { + error("Could not import identity", e); + } + } + + private void exportSelected() { + IdentityEntry e = list.getSelectedValue(); + if (e == null) return; + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Export identity"); + chooser.setFileFilter(new FileNameExtensionFilter("TeamSpeak identity (*.ini)", "ini")); + chooser.setSelectedFile(new File(e.getName().replaceAll("[^\\w.-]+", "_") + ".ini")); + if (chooser.showSaveDialog(this) != JFileChooser.APPROVE_OPTION) return; + + File target = chooser.getSelectedFile(); + if (target.exists() && JOptionPane.showConfirmDialog(this, + target.getName() + " already exists. Overwrite?", "Export identity", + JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION) { + return; + } + try { + identities.exportTo(e, target); + JOptionPane.showMessageDialog(this, "Identity exported to\n" + target.getAbsolutePath(), + "Export identity", JOptionPane.INFORMATION_MESSAGE); + } catch (Exception ex) { + error("Could not export identity", ex); + } + } + + private void renameSelected() { + IdentityEntry e = list.getSelectedValue(); + if (e == null) return; + String name = JOptionPane.showInputDialog(this, "Identity name:", e.getName()); + if (name == null || name.trim().isEmpty()) return; + try { + identities.rename(e, name.trim()); + reload(); + } catch (Exception ex) { + error("Could not rename identity", ex); + } + } + + private void setSelectedDefault() { + IdentityEntry e = list.getSelectedValue(); + if (e == null) return; + settings.defaultIdentityId = e.getId(); + settings.save(); + reload(); + } + + private void removeSelected() { + IdentityEntry e = list.getSelectedValue(); + if (e == null) return; + int result = JOptionPane.showConfirmDialog(this, + "Delete identity \"" + e.getName() + "\"?\n\n" + + "Server groups and permissions tied to it will be lost.\n" + + "Export it first if you may need it again.", + "Remove identity", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); + if (result != JOptionPane.YES_OPTION) return; + identities.remove(e, settings, bookmarks); + reload(); + } + + private void improveSelected() { + IdentityEntry e = list.getSelectedValue(); + if (e == null) return; + int level = askLevel("Target security level (current: " + e.getSecurityLevel() + "):", + Math.max(e.getSecurityLevel() + 1, IdentityStore.DEFAULT_SECURITY_LEVEL)); + if (level < 0) return; + if (level <= e.getSecurityLevel()) { + JOptionPane.showMessageDialog(this, "The identity already reaches that level."); + return; + } + runWithProgress("Improving security level…", + (cancelled, status) -> { + identities.improveSecurity(e, level, (best, offset) -> { + status.accept("Improving security level… reached " + best + " of " + level); + return !cancelled.get(); + }); + return e; + }, true); + } + + private int askLevel(String prompt, int suggestion) { + String s = JOptionPane.showInputDialog(this, prompt, Integer.toString(suggestion)); + if (s == null) return -1; + try { + int level = Integer.parseInt(s.trim()); + if (level < 0 || level > 40) throw new NumberFormatException(); + return level; + } catch (NumberFormatException e) { + JOptionPane.showMessageDialog(this, "Enter a security level between 0 and 40."); + return -1; + } + } + + /** Work that produces the identity to select once it finishes. */ + private interface IdentityTask { + IdentityEntry run(AtomicBoolean cancelled, Consumer status) throws Exception; + } + + /** + * Runs a long identity computation off the EDT behind a modal progress dialog. + * Both generating and improving an identity are proof-of-work searches whose + * duration grows exponentially with the security level. + */ + private void runWithProgress(String message, IdentityTask task, boolean cancellable) { + AtomicBoolean cancelled = new AtomicBoolean(false); + + JDialog progress = new JDialog(this, "Please wait", true); + JLabel status = new JLabel(message); + JPanel panel = new JPanel(new BorderLayout(8, 8)); + panel.setBorder(BorderFactory.createEmptyBorder(16, 16, 12, 16)); + panel.add(status, BorderLayout.CENTER); + if (cancellable) { + JButton cancel = new JButton("Stop"); + cancel.setToolTipText("Keep the best level found so far"); + cancel.addActionListener(e -> cancelled.set(true)); + JPanel south = new JPanel(); + south.add(cancel); + panel.add(south, BorderLayout.SOUTH); + } + progress.setDefaultCloseOperation(DO_NOTHING_ON_CLOSE); + progress.getContentPane().add(panel); + progress.pack(); + progress.setLocationRelativeTo(this); + + Consumer report = text -> SwingUtilities.invokeLater(() -> status.setText(text)); + Thread worker = new Thread(() -> { + IdentityEntry result = null; + Exception failure = null; + try { + result = task.run(cancelled, report); + } catch (Exception e) { + failure = e; + } + IdentityEntry selected = result; + Exception thrown = failure; + SwingUtilities.invokeLater(() -> { + progress.dispose(); + if (thrown != null) { + error("Identity operation failed", thrown); + return; + } + reload(); + if (selected != null) list.setSelectedValue(selected, true); + }); + }, "identity-work"); + + // Start only once the modal dialog is up, so a fast task can't dispose it + // before it becomes visible (which would leave it on screen forever). + SwingUtilities.invokeLater(worker::start); + progress.setVisible(true); + } + + private void error(String what, Exception e) { + String detail = e.getMessage() == null ? e.toString() : e.getMessage(); + JOptionPane.showMessageDialog(this, what + ":\n" + detail, "Identities", JOptionPane.ERROR_MESSAGE); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java new file mode 100644 index 0000000..358da71 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java @@ -0,0 +1,48 @@ +package com.ts3client.ui; + +import com.ts3client.config.IdentityEntry; +import com.ts3client.config.IdentityStore; + +import javax.swing.DefaultListCellRenderer; +import javax.swing.JComboBox; +import javax.swing.JList; +import java.awt.Component; + +/** + * Drop-down for picking the identity to connect with. Optionally offers a + * "use the default identity" entry for places where the choice may stay unset + * (bookmarks), so changing the default later applies retroactively. + */ +final class IdentityChooser extends JComboBox { + + private static final String USE_DEFAULT = "‹Default identity›"; + + IdentityChooser(IdentityStore identities, boolean allowDefault, String selectedId) { + if (allowDefault) addItem(USE_DEFAULT); + for (IdentityEntry e : identities.all()) addItem(e); + + IdentityEntry selected = identities.byId(selectedId); + if (selected != null) { + setSelectedItem(selected); + } else if (allowDefault) { + setSelectedItem(USE_DEFAULT); + } + + setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + String text = value instanceof IdentityEntry + ? ((IdentityEntry) value).getName() + : String.valueOf(value); + return super.getListCellRendererComponent(list, text, index, isSelected, cellHasFocus); + } + }); + } + + /** Selected identity id, or an empty string when the default is to be used. */ + String getSelectedIdentityId() { + Object v = getSelectedItem(); + return v instanceof IdentityEntry ? ((IdentityEntry) v).getId() : ""; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 68b35af..20ef2c3 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -4,6 +4,8 @@ import com.ts3client.audio.AudioBackend; import com.ts3client.audio.desktop.JavaSoundAudioBackend; import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmarks; +import com.ts3client.config.IdentityEntry; +import com.ts3client.config.IdentityStore; import com.ts3client.config.Settings; import com.ts3client.net.ChannelNode; import com.ts3client.net.ClientEntry; @@ -43,9 +45,13 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve private final Settings settings; private final Bookmarks bookmarks = Bookmarks.load(); + private final IdentityStore identities; private final AudioBackend audio = new JavaSoundAudioBackend(); private TeamspeakConnection conn; + /** Identity of the current/last connection, so it can be saved into a bookmark. */ + private String currentIdentityId = ""; + private JMenu bookmarksMenu; private JCheckBoxMenuItem awayItem; private JCheckBoxMenuItem commanderItem; @@ -73,6 +79,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve public MainFrame(Settings settings) { super("TS3J — TeamSpeak 3 Java Client"); this.settings = settings; + this.identities = IdentityStore.load(settings); setIconImage(Icons.app().getImage()); // We tear the connection down ourselves on close, so don't let Swing kill the JVM. @@ -165,8 +172,12 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve self.add(rename); JMenu tools = new JMenu("Tools"); + JMenuItem identitiesItem = new JMenuItem("Identities…"); + identitiesItem.addActionListener(e -> showIdentities()); JMenuItem options = new JMenuItem("Options…"); options.addActionListener(e -> showSettings()); + tools.add(identitiesItem); + tools.addSeparator(); tools.add(options); JMenu help = new JMenu("Help"); @@ -193,7 +204,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve JMenuItem addCurrent = new JMenuItem("Add current server…"); addCurrent.addActionListener(e -> addCurrentServerBookmark()); JMenuItem manage = new JMenuItem("Manage bookmarks…"); - manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, + manage.addActionListener(e -> new BookmarksDialog(this, bookmarks, identities, this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true)); bookmarksMenu.add(addCurrent); bookmarksMenu.add(manage); @@ -290,13 +301,16 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve "Connect", JOptionPane.INFORMATION_MESSAGE); return; } - ConnectDialog dlg = new ConnectDialog(this, settings); + ConnectDialog dlg = new ConnectDialog(this, settings, identities); dlg.setVisible(true); if (!dlg.isConfirmed()) return; - startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword()); + startConnection(dlg.getAddress(), dlg.getPort(), dlg.getNickname(), dlg.getPassword(), dlg.getIdentityId()); } - private void startConnection(String address, int port, String nickname, String password) { + /** + * @param identityId identity to use, or empty for the default one + */ + private void startConnection(String address, int port, String nickname, String password, String identityId) { if (conn.isConnected()) { JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.", "Connect", JOptionPane.INFORMATION_MESSAGE); @@ -307,12 +321,28 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve settings.serverPassword = password; settings.save(); chatPanel.appendSystem("Connecting to " + address + ":" + port + " …"); - conn.connect(address, port, nickname, password); + + // Resolving may have to generate a first identity, so keep it off the EDT. + onStatus("Loading identity…"); + new Thread(() -> { + final IdentityEntry entry; + try { + entry = identities.resolve(settings, identityId); + } catch (Exception e) { + onError("Could not load identity: " + e.getMessage()); + return; + } + SwingUtilities.invokeLater(() -> { + currentIdentityId = entry.getId(); + chatPanel.appendSystem("Using identity \"" + entry.getName() + "\"."); + }); + conn.connect(address, port, nickname, password, entry.getIdentity()); + }, "identity-resolve").start(); } private void connectToBookmark(Bookmark b) { String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname; - startConnection(b.address, b.port, nick, b.password); + startConnection(b.address, b.port, nick, b.password, b.identityId); } private void addCurrentServerBookmark() { @@ -328,7 +358,9 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve } String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr); if (label == null) return; - bookmarks.add(new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword)); + Bookmark bookmark = new Bookmark(label.trim(), addr, port, settings.nickname, settings.serverPassword); + bookmark.identityId = currentIdentityId; + bookmarks.add(bookmark); bookmarks.save(); rebuildBookmarksMenu(); } @@ -371,6 +403,10 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve } } + private void showIdentities() { + new IdentitiesDialog(this, identities, settings, bookmarks, null).setVisible(true); + } + private void showSettings() { SettingsDialog dlg = new SettingsDialog(this, settings, conn.getMicrophone(), conn.getPlayback(), () -> {