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

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

View File

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

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.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(), () -> {