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