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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/<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<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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Bookmark> onConnect;
|
||||
private final Runnable onChanged;
|
||||
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() : "";
|
||||
}
|
||||
}
|
||||
@@ -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(), () -> {
|
||||
|
||||
Reference in New Issue
Block a user