Add identity management

Manage several TeamSpeak identities like the official client instead of
using one auto-generated identity file.

- IdentityStore/IdentityEntry keep each identity as a TeamSpeak-format INI
  in ~/.ts3jclient/identities, so files interchange with the TS3 client;
  supports create, import, export, rename, remove and a cancellable
  security-level search. A legacy identity.ini is migrated in on first load.
- Settings hold the default identity; bookmarks may pin their own, falling
  back to the default when unset.
- TeamspeakConnection now connects with the identity it is handed.
- Swing: Tools -> Identities manager, plus an identity drop-down in the
  connect and bookmark forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 07:42:08 +00:00
parent f912cab07b
commit 17ad6cde1c
12 changed files with 880 additions and 38 deletions

View File

@@ -54,7 +54,7 @@ frontend supplies its own UI and audio backend while reusing `core` unchanged.
- Configurable capture/playback **devices**. - Configurable capture/playback **devices**.
### Server interaction ### 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 - **Channel/client tree** styled like TS3, updated live from protocol events
(joins, leaves, moves, channel create/edit/delete, nickname/mute/away changes). (joins, leaves, moves, channel create/edit/delete, nickname/mute/away changes).
- **Talk indicators** — speakers turn green live as they talk. - **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. **private chat**, or **locally mute** them.
- **Chat** to the current channel or the whole server; receive channel/server/private - **Chat** to the current channel or the whole server; receive channel/server/private
messages and **pokes**. 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. - **Self status** — Away (with message) and Channel Commander toggles.
- **Status bar** shows the server name, user count and live ping. - **Status bar** shows the server name, user count and live ping.
- Change your nickname, mute/deafen from the toolbar. - Change your nickname, mute/deafen from the toolbar.
@@ -97,15 +101,16 @@ java -jar swing/target/ts3-client.jar
mvn -pl swing exec:java mvn -pl swing exec:java
``` ```
Then use **Connections → Connect…**, enter a server address, port (default 9987) Then use **Connections → Connect…**, enter a server address, port (default 9987),
and nickname, and connect. Open **Tools → Options** to pick audio devices and tune nickname and identity, and connect. Open **Tools → Options** to pick audio devices and tune
voice activation while watching the live meter. voice activation while watching the live meter.
## Architecture ## Architecture
``` ```
core/ com.ts3client core/ com.ts3client
├── config.Settings persisted prefs (~/.ts3jclient/settings.properties) ├── 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) ├── audio abstractions + reusable DSP (no platform code)
│ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput │ ├── AudioBackend factory for a platform's VoiceInput/VoiceOutput
│ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls │ ├── VoiceInput capture source (extends ts3j Microphone) + gating controls
@@ -137,6 +142,8 @@ swing/ com.ts3client
├── SettingsDialog audio + VAD options with live meter ├── SettingsDialog audio + VAD options with live meter
├── ConnectDialog connect form ├── ConnectDialog connect form
├── BookmarksDialog manage saved servers ├── 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 ├── LevelMeter dBFS meter with threshold marker
├── Icons programmatic vector icons (no image assets) ├── Icons programmatic vector icons (no image assets)
└── Theme palette + fonts └── Theme palette + fonts

View File

@@ -7,6 +7,8 @@ public final class Bookmark {
public int port = 9987; public int port = 9987;
public String nickname; public String nickname;
public String password = ""; public String password = "";
/** Identity to connect with; empty means "use the default identity". */
public String identityId = "";
public Bookmark() { public Bookmark() {
} }

View File

@@ -48,6 +48,7 @@ public final class Bookmarks {
bm.port = parseInt(p.getProperty(prefix + "port"), 9987); bm.port = parseInt(p.getProperty(prefix + "port"), 9987);
bm.nickname = p.getProperty(prefix + "nickname", ""); bm.nickname = p.getProperty(prefix + "nickname", "");
bm.password = p.getProperty(prefix + "password", ""); bm.password = p.getProperty(prefix + "password", "");
bm.identityId = p.getProperty(prefix + "identityId", "");
if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm); if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm);
} }
return b; return b;
@@ -64,6 +65,7 @@ public final class Bookmarks {
p.setProperty(prefix + "port", Integer.toString(bm.port)); p.setProperty(prefix + "port", Integer.toString(bm.port));
p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname)); p.setProperty(prefix + "nickname", nullToEmpty(bm.nickname));
p.setProperty(prefix + "password", nullToEmpty(bm.password)); p.setProperty(prefix + "password", nullToEmpty(bm.password));
p.setProperty(prefix + "identityId", nullToEmpty(bm.identityId));
} }
try { try {
if (!DIR.isDirectory()) { if (!DIR.isDirectory()) {

View File

@@ -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;
}
}

View File

@@ -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.
*
* <p>Each identity lives in its own {@code ~/.ts3jclient/identities/&lt;id&gt;.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<IdentityEntry> 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<IdentityEntry> 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<String, String> 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;
}
}

View File

@@ -44,7 +44,10 @@ public final class Settings {
public String lastAddress = "localhost"; public String lastAddress = "localhost";
public String nickname = System.getProperty("user.name", "TS3J User"); public String nickname = System.getProperty("user.name", "TS3J User");
public String serverPassword = ""; public String serverPassword = "";
/** Legacy single-identity file; only used to migrate into the identity store. */
public String identityFile = new File(DIR, "identity.ini").getAbsolutePath(); 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) ---- // ---- audio devices (mixer names; empty = system default) ----
public String inputDevice = ""; public String inputDevice = "";
@@ -129,6 +132,7 @@ public final class Settings {
nickname = props.getProperty("nickname", nickname); nickname = props.getProperty("nickname", nickname);
serverPassword = props.getProperty("serverPassword", serverPassword); serverPassword = props.getProperty("serverPassword", serverPassword);
identityFile = props.getProperty("identityFile", identityFile); identityFile = props.getProperty("identityFile", identityFile);
defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId);
inputDevice = props.getProperty("inputDevice", inputDevice); inputDevice = props.getProperty("inputDevice", inputDevice);
outputDevice = props.getProperty("outputDevice", outputDevice); outputDevice = props.getProperty("outputDevice", outputDevice);
inputMode = parseMode(props.getProperty("inputMode"), inputMode); inputMode = parseMode(props.getProperty("inputMode"), inputMode);
@@ -156,6 +160,7 @@ public final class Settings {
props.setProperty("nickname", nickname); props.setProperty("nickname", nickname);
props.setProperty("serverPassword", serverPassword); props.setProperty("serverPassword", serverPassword);
props.setProperty("identityFile", identityFile); props.setProperty("identityFile", identityFile);
props.setProperty("defaultIdentityId", defaultIdentityId);
props.setProperty("inputDevice", inputDevice); props.setProperty("inputDevice", inputDevice);
props.setProperty("outputDevice", outputDevice); props.setProperty("outputDevice", outputDevice);
props.setProperty("inputMode", inputMode.name()); props.setProperty("inputMode", inputMode.name());

View File

@@ -77,37 +77,25 @@ public final class TeamspeakConnection implements TS3Listener {
return playback; return playback;
} }
// ---- identity ---- /** The identity this connection authenticated with. */
public LocalIdentity getIdentity() {
/** Loads the persisted identity, or generates and saves a new one. */ return identity;
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;
} }
// ---- connection lifecycle ---- // ---- 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 { try {
ui.onStatus("Loading identity…"); identity = withIdentity;
identity = loadOrCreateIdentity();
model.clear(); model.clear();
playback = audio.createOutput(settings); playback = audio.createOutput(settings);

View File

@@ -2,6 +2,7 @@ package com.ts3client.ui;
import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
import javax.swing.Box; import javax.swing.Box;
@@ -26,14 +27,17 @@ import java.util.function.Consumer;
public final class BookmarksDialog extends JDialog { public final class BookmarksDialog extends JDialog {
private final Bookmarks bookmarks; private final Bookmarks bookmarks;
private final IdentityStore identities;
private final Consumer<Bookmark> onConnect; private final Consumer<Bookmark> onConnect;
private final Runnable onChanged; private final Runnable onChanged;
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>(); private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
private final JList<Bookmark> list = new JList<>(listModel); private final JList<Bookmark> list = new JList<>(listModel);
public BookmarksDialog(Frame owner, Bookmarks bookmarks, Consumer<Bookmark> onConnect, Runnable onChanged) { public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities,
Consumer<Bookmark> onConnect, Runnable onChanged) {
super(owner, "Manage Bookmarks", true); super(owner, "Manage Bookmarks", true);
this.bookmarks = bookmarks; this.bookmarks = bookmarks;
this.identities = identities;
this.onConnect = onConnect; this.onConnect = onConnect;
this.onChanged = onChanged; this.onChanged = onChanged;
@@ -120,6 +124,7 @@ public final class BookmarksDialog extends JDialog {
JTextField port = new JTextField(Integer.toString(b.port)); JTextField port = new JTextField(Integer.toString(b.port));
JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname); JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname);
JPasswordField password = new JPasswordField(b.password == null ? "" : b.password); 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)); JPanel form = new JPanel(new GridLayout(0, 1, 0, 2));
form.add(new JLabel("Label:")); form.add(new JLabel("Label:"));
@@ -132,6 +137,8 @@ public final class BookmarksDialog extends JDialog {
form.add(nick); form.add(nick);
form.add(new JLabel("Password (optional):")); form.add(new JLabel("Password (optional):"));
form.add(password); form.add(password);
form.add(new JLabel("Identity:"));
form.add(identity);
int result = JOptionPane.showConfirmDialog(this, form, int result = JOptionPane.showConfirmDialog(this, form,
"Bookmark", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); "Bookmark", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE);
@@ -150,6 +157,7 @@ public final class BookmarksDialog extends JDialog {
} }
b.nickname = nick.getText().trim(); b.nickname = nick.getText().trim();
b.password = new String(password.getPassword()); b.password = new String(password.getPassword());
b.identityId = identity.getSelectedIdentityId();
return true; return true;
} }
} }

View File

@@ -1,5 +1,6 @@
package com.ts3client.ui; package com.ts3client.ui;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import javax.swing.BorderFactory; import javax.swing.BorderFactory;
@@ -23,10 +24,11 @@ public final class ConnectDialog extends JDialog {
private final JTextField portField; private final JTextField portField;
private final JTextField nickField; private final JTextField nickField;
private final JPasswordField passwordField; private final JPasswordField passwordField;
private final IdentityChooser identityChooser;
private boolean confirmed; private boolean confirmed;
public ConnectDialog(Frame owner, Settings settings) { public ConnectDialog(Frame owner, Settings settings, IdentityStore identities) {
super(owner, "Connect to Server", true); super(owner, "Connect to Server", true);
String addr = settings.lastAddress; String addr = settings.lastAddress;
@@ -44,6 +46,7 @@ public final class ConnectDialog extends JDialog {
portField = new JTextField(Integer.toString(port), 6); portField = new JTextField(Integer.toString(port), 6);
nickField = new JTextField(settings.nickname, 18); nickField = new JTextField(settings.nickname, 18);
passwordField = new JPasswordField(settings.serverPassword, 18); passwordField = new JPasswordField(settings.serverPassword, 18);
identityChooser = new IdentityChooser(identities, false, settings.defaultIdentityId);
JPanel form = new JPanel(new GridBagLayout()); JPanel form = new JPanel(new GridBagLayout());
form.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12)); 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++, "Port:", portField);
add(form, c, row++, "Nickname:", nickField); add(form, c, row++, "Nickname:", nickField);
add(form, c, row++, "Password (optional):", passwordField); add(form, c, row++, "Password (optional):", passwordField);
add(form, c, row++, "Identity:", identityChooser);
JPanel buttons = new JPanel(new BorderLayout()); JPanel buttons = new JPanel(new BorderLayout());
JPanel right = new JPanel(); JPanel right = new JPanel();
@@ -116,4 +120,9 @@ public final class ConnectDialog extends JDialog {
public String getPassword() { public String getPassword() {
return new String(passwordField.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();
}
} }

View File

@@ -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<IdentityEntry> listModel = new DefaultListModel<>();
private final JList<IdentityEntry> 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<String> 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<String> 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);
}
}

View File

@@ -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<Object> {
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() : "";
}
}

View File

@@ -4,6 +4,8 @@ import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.JavaSoundAudioBackend; import com.ts3client.audio.desktop.JavaSoundAudioBackend;
import com.ts3client.config.Bookmark; import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks; import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityEntry;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings; import com.ts3client.config.Settings;
import com.ts3client.net.ChannelNode; import com.ts3client.net.ChannelNode;
import com.ts3client.net.ClientEntry; import com.ts3client.net.ClientEntry;
@@ -43,9 +45,13 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
private final Settings settings; private final Settings settings;
private final Bookmarks bookmarks = Bookmarks.load(); private final Bookmarks bookmarks = Bookmarks.load();
private final IdentityStore identities;
private final AudioBackend audio = new JavaSoundAudioBackend(); private final AudioBackend audio = new JavaSoundAudioBackend();
private TeamspeakConnection conn; private TeamspeakConnection conn;
/** Identity of the current/last connection, so it can be saved into a bookmark. */
private String currentIdentityId = "";
private JMenu bookmarksMenu; private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem; private JCheckBoxMenuItem awayItem;
private JCheckBoxMenuItem commanderItem; private JCheckBoxMenuItem commanderItem;
@@ -73,6 +79,7 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
public MainFrame(Settings settings) { public MainFrame(Settings settings) {
super("TS3J — TeamSpeak 3 Java Client"); super("TS3J — TeamSpeak 3 Java Client");
this.settings = settings; this.settings = settings;
this.identities = IdentityStore.load(settings);
setIconImage(Icons.app().getImage()); setIconImage(Icons.app().getImage());
// We tear the connection down ourselves on close, so don't let Swing kill the JVM. // 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); self.add(rename);
JMenu tools = new JMenu("Tools"); JMenu tools = new JMenu("Tools");
JMenuItem identitiesItem = new JMenuItem("Identities…");
identitiesItem.addActionListener(e -> showIdentities());
JMenuItem options = new JMenuItem("Options…"); JMenuItem options = new JMenuItem("Options…");
options.addActionListener(e -> showSettings()); options.addActionListener(e -> showSettings());
tools.add(identitiesItem);
tools.addSeparator();
tools.add(options); tools.add(options);
JMenu help = new JMenu("Help"); 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…"); JMenuItem addCurrent = new JMenuItem("Add current server…");
addCurrent.addActionListener(e -> addCurrentServerBookmark()); addCurrent.addActionListener(e -> addCurrentServerBookmark());
JMenuItem manage = new JMenuItem("Manage bookmarks…"); 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)); this::connectToBookmark, this::rebuildBookmarksMenu).setVisible(true));
bookmarksMenu.add(addCurrent); bookmarksMenu.add(addCurrent);
bookmarksMenu.add(manage); bookmarksMenu.add(manage);
@@ -290,13 +301,16 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
"Connect", JOptionPane.INFORMATION_MESSAGE); "Connect", JOptionPane.INFORMATION_MESSAGE);
return; return;
} }
ConnectDialog dlg = new ConnectDialog(this, settings); ConnectDialog dlg = new ConnectDialog(this, settings, identities);
dlg.setVisible(true); dlg.setVisible(true);
if (!dlg.isConfirmed()) return; 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()) { if (conn.isConnected()) {
JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.", JOptionPane.showMessageDialog(this, "Already connected. Disconnect first.",
"Connect", JOptionPane.INFORMATION_MESSAGE); "Connect", JOptionPane.INFORMATION_MESSAGE);
@@ -307,12 +321,28 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
settings.serverPassword = password; settings.serverPassword = password;
settings.save(); settings.save();
chatPanel.appendSystem("Connecting to " + address + ":" + port + ""); 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) { private void connectToBookmark(Bookmark b) {
String nick = (b.nickname != null && !b.nickname.isBlank()) ? b.nickname : settings.nickname; 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() { private void addCurrentServerBookmark() {
@@ -328,7 +358,9 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve
} }
String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr); String label = JOptionPane.showInputDialog(this, "Bookmark label:", addr);
if (label == null) return; 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(); bookmarks.save();
rebuildBookmarksMenu(); 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() { private void showSettings() {
SettingsDialog dlg = new SettingsDialog(this, settings, SettingsDialog dlg = new SettingsDialog(this, settings,
conn.getMicrophone(), conn.getPlayback(), () -> { conn.getMicrophone(), conn.getPlayback(), () -> {