diff --git a/ts3-client/core/src/main/java/com/ts3client/config/ClientVersion.java b/ts3-client/core/src/main/java/com/ts3client/config/ClientVersion.java new file mode 100644 index 0000000..f8428b3 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/config/ClientVersion.java @@ -0,0 +1,88 @@ +package com.ts3client.config; + +import java.util.List; + +/** + * The version, platform and signature a client reports to the server in + * {@code clientinit}. Other clients see the version and platform in the info + * panel ("3.6.2 on Windows"). + * + *

The signature is TeamSpeak's, over the version and platform strings + * together, so the three only work as a set. Servers answer "client is modified" + * for a version they do not accept — an invented one, or one older than their + * configured minimum — which is why {@link #PRESETS} holds genuine triples taken + * from released clients, newest first per platform. + * + * @param label human-readable name, e.g. "3.6.2 on Windows" + * @param version {@code client_version}, e.g. "3.6.2 [Build: 1695203293]" + * @param platform {@code client_platform}, e.g. "Windows" + * @param sign {@code client_version_sign}, base64 + */ +public record ClientVersion(String label, String version, String platform, String sign) { + + /** Platform names the official clients report. */ + public static final List PLATFORMS = + List.of("Windows", "Linux", "OS X", "Android", "iOS"); + + /** + * Signed triples from released TeamSpeak clients. "3.?.?" is the version the + * SDK-based clients report; it is signed for every platform and is what this + * client uses by default. + */ + public static final List PRESETS = List.of( + new ClientVersion("3.?.? on Windows", "3.?.? [Build: 5680278000]", "Windows", + "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg=="), + new ClientVersion("3.6.3 on Windows", "3.6.3 [Build: 1701166057]", "Windows", + "ML3yE6pV0QGyhc8o7k1bgqDSj+5gZIQFMjBNhWJ6L0FtJwniBq8l1wivx3DzgVVCvyRbzfg8MsdV/rjl5sHNCw=="), + new ClientVersion("3.6.2 on Windows", "3.6.2 [Build: 1695203293]", "Windows", + "4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw=="), + new ClientVersion("3.6.1 on Windows", "3.6.1 [Build: 1690872913]", "Windows", + "+YmPaYUBL4F9+TgxyFhlT8qIi1Ym+So4NNMcuohla/u4kfJCGabZ1vDF4cIOAghIqWfO+bQ0OkORZrCBdpi2Cw=="), + new ClientVersion("3.6.0 on Windows", "3.6.0 [Build: 1686749764]", "Windows", + "gfzG6CfwNYeqrjT68s7ahPd24A/XS0P8LLAB7hrE54MnrlO12RiflD2w69VZ7EYlqnd9GAiVzdGJHCmCFWtlAQ=="), + + new ClientVersion("3.?.? on Linux", "3.?.? [Build: 5680278000]", "Linux", + "Hjd+N58Gv3ENhoKmGYy2bNRBsNNgm5kpiaQWxOj5HN2DXttG6REjymSwJtpJ8muC2gSwRuZi0R+8Laan5ts5CQ=="), + new ClientVersion("3.6.2 on Linux", "3.6.2 [Build: 1695203293]", "Linux", + "p4iF1jZ3ZOz9MEkKSZ2bvnFtm9WmUcQy9mAP//erFE4PF1sB6K1CSANrr+3X4B0aZR0u+K2pjnv8kiKsWKQCBQ=="), + new ClientVersion("3.6.1 on Linux", "3.6.1 [Build: 1690193193]", "Linux", + "UAcE5h5FgPcdChWTtiO816HX8f2qdoDcDEw6crbJ96Yo7rp1Bt+qx/rkKK9bzlVWMhOd4fI7pr++1XaPkEBnAQ=="), + new ClientVersion("3.6.0 on Linux", "3.6.0 [Build: 1686749764]", "Linux", + "TccRBHwn5LSA0lfpa2B42QmfCOiG+MqqyhlOqy5RcuRQNUGPD0e47lUz+Z/8+Gsdc1TPG4+vYlRl8+v2v+26Aw=="), + new ClientVersion("3.5.8 on Linux", "3.5.8 [Build: 1644595825]", "Linux", + "CK+cWJQESZgUdw1AFHINMbwiLjFGSiupour0Dfj/j1/xwx4L3nW3G+alYtiQw/wQwlWm873HS6PvXUBarz/tCw=="), + + new ClientVersion("3.?.? on OS X", "3.?.? [Build: 5680278000]", "OS X", + "SttEnjoWE8jqIM6BOHSfiZP9DGjW0EP/ajU4bdKqgGMV4aYq/kzwVA9gxbmdIzV4lbaokvXBqrRjfBHrTVh8Cg=="), + new ClientVersion("3.6.2 on OS X", "3.6.2 [Build: 1695203293]", "OS X", + "SOCkLcsxINIGVc+3OQ5Vrmv2BVNNFA9LX5Jj9tqbIiYv2HU6Z5E8AL5+v5G2LDN0F+U55+uW43ezUoYAr1WuBA=="), + new ClientVersion("3.6.1 on OS X", "3.6.1 [Build: 1690193193]", "OS X", + "P+KOw42V8K0nN5FbGItCaJzrDuWcRgfE3MRzrtbYAhRMTXHzRxvwLkoLM7+vKTW135NtWZlKbZuDAvxgwhSLBg=="), + new ClientVersion("3.6.0 on OS X", "3.6.0 [Build: 1686749764]", "OS X", + "g8H7Op8eCrn0wHutuD/SlmUcZPmkSlFUBhvZpSgm9kuQl4dapzKJq2anI5jDwRs/ooBm/wrIlSiWI6HXuOPZDA=="), + + new ClientVersion("3.?.? on Android", "3.?.? [Build: 5680278000]", "Android", + "AWb948BY32Z7bpIyoAlQguSmxOGcmjESPceQe1DpW5IZ4+AW1KfTk2VUIYNfUPsxReDJMCtlhVKslzhR2lf0AA=="), + new ClientVersion("3.8.7 on Android", "3.8.7 [Build: 1785186501]", "Android", + "mbt0WPhXJ+YKJUix5b6t+ZOHTCfvRqhhhrpcS5koM9CUL0BAFQZRG5Di5rJrqipZdk+gEUtpAX9I5nkuL7V9Dw=="), + new ClientVersion("3.8.6 on Android", "3.8.6 [Build: 1774892004]", "Android", + "Hh0oEZupsq8f0BuUHhMKdkXsMmgZZ03dJL8qAwnlH2JAHuefaAZFS/buiH2MLwgdRkZ81ffYk5YOTfc/bJczDw=="), + + new ClientVersion("3.?.? on iOS", "3.?.? [Build: 5680278000]", "iOS", + "XrAf+Buq6Eb0ehEW/niFp06YX+nGGOS0Ke4MoUBzn+cX9q6G5C0A/d5XtgcNMe8r9jJgV/adIYVpsGS3pVlSAA=="), + new ClientVersion("3.8.2 on iOS", "3.8.2 [Build: 1764028963]", "iOS", + "Bc57xjumaioWkouTRqoS6XbSIXysrPH+gxynXgL4/nYr38l4OocUTpQaMMHevmgk/FZxJWFNEhU3Hpg5+M+MAA=="), + new ClientVersion("3.8.1 on iOS", "3.8.1 [Build: 1763403218]", "iOS", + "GM/7isPK6LDKTN3Hdelenbr99dnPyWTccLCOCFKK41321XMsdG02putu+NskCLddlDPu2CPYJ4YABRS2pJ88Ag==")); + + /** The triple used when the user has not chosen one; matches the library's built-in. */ + public static final ClientVersion DEFAULT = PRESETS.get(0); + + /** The preset carrying exactly these three values, or null when the user typed their own. */ + public static ClientVersion match(String version, String platform, String sign) { + return PRESETS.stream() + .filter(v -> v.version.equals(version) && v.platform.equals(platform) && v.sign.equals(sign)) + .findFirst() + .orElse(null); + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java index 641ee4c..a322b2a 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Settings.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Settings.java @@ -51,6 +51,16 @@ public final class Settings { /** Id of the identity used when a server doesn't select one of its own. */ public String defaultIdentityId = ""; + // ---- reported client version ---- + /** Report {@link #clientVersion}/{@link #clientPlatform} instead of the built-in ones. */ + public boolean customVersion = false; + /** Version string other clients see, e.g. "3.5.6 [Build: 1603963200]". */ + public String clientVersion = ClientVersion.DEFAULT.version(); + /** Operating system other clients see, e.g. "Windows". */ + public String clientPlatform = ClientVersion.DEFAULT.platform(); + /** TeamSpeak's signature over the version and platform; servers may verify it. */ + public String clientVersionSign = ClientVersion.DEFAULT.sign(); + // ---- audio devices (mixer names; empty = system default) ---- public String inputDevice = ""; public String outputDevice = ""; @@ -152,6 +162,10 @@ public final class Settings { serverPassword = props.getProperty("serverPassword", serverPassword); identityFile = props.getProperty("identityFile", identityFile); defaultIdentityId = props.getProperty("defaultIdentityId", defaultIdentityId); + customVersion = parseB(props.getProperty("customVersion"), customVersion); + clientVersion = props.getProperty("clientVersion", clientVersion); + clientPlatform = props.getProperty("clientPlatform", clientPlatform); + clientVersionSign = props.getProperty("clientVersionSign", clientVersionSign); inputDevice = props.getProperty("inputDevice", inputDevice); outputDevice = props.getProperty("outputDevice", outputDevice); inputMode = parseMode(props.getProperty("inputMode"), inputMode); @@ -186,6 +200,10 @@ public final class Settings { props.setProperty("serverPassword", serverPassword); props.setProperty("identityFile", identityFile); props.setProperty("defaultIdentityId", defaultIdentityId); + props.setProperty("customVersion", Boolean.toString(customVersion)); + props.setProperty("clientVersion", clientVersion); + props.setProperty("clientPlatform", clientPlatform); + props.setProperty("clientVersionSign", clientVersionSign); props.setProperty("inputDevice", inputDevice); props.setProperty("outputDevice", outputDevice); props.setProperty("inputMode", inputMode.name()); diff --git a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java index f5d4ca7..40959ed 100644 --- a/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java +++ b/ts3-client/core/src/main/java/com/ts3client/net/TeamspeakConnection.java @@ -226,6 +226,7 @@ public final class TeamspeakConnection implements TS3Listener { ui.onError("Network error: " + rootMessage(t)); }); + applyClientVersion(); applyDefaultChannel(channel, channelPassword); ui.onStatus("Connecting to " + address + ":" + port + "…"); @@ -256,7 +257,7 @@ public final class TeamspeakConnection implements TS3Listener { applyMicrophoneState(); } catch (Exception e) { connected = false; - ui.onError("Connection failed: " + rootMessage(e)); + ui.onError("Connection failed: " + rootMessage(e) + versionHint(e)); safeCleanup(); // Report the failed attempt as a disconnect too, so the UI leaves the // "connecting" state and the tab becomes reusable. @@ -265,6 +266,38 @@ public final class TeamspeakConnection implements TS3Listener { } } + /** + * "client is modified" is what a server answers when it does not accept the reported + * version: too old for its minimum, not a version it knows for that platform, or a + * signature that does not belong to the two. Easy to run into after editing them by + * hand, and the bare message does not point anywhere. + */ + private String versionHint(Exception e) { + if (!settings.customVersion) return ""; + String message = rootMessage(e); + return message != null && message.contains("client is modified") + ? " — the server did not accept the custom client version; pick a newer one" + + " of the listed versions in Options." + : ""; + } + + /** + * Reports the version and operating system the user configured, which is what + * other clients see in the info panel. Each field falls back to the library's + * built-in one when left empty; the signature has to match the version and + * platform it was issued for, or a server may refuse the connection. + */ + private void applyClientVersion() { + if (!settings.customVersion) return; + setIfPresent("client.version_string", settings.clientVersion); + setIfPresent("client.version_platform", settings.clientPlatform); + setIfPresent("client.version_sign", settings.clientVersionSign); + } + + private void setIfPresent(String option, String value) { + if (value != null && !value.isBlank()) client.setOption(option, value.trim()); + } + /** * Asks the server, as part of {@code clientinit}, to place us in a specific * channel right away instead of the default one. This is the path a real client diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVersionPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVersionPanel.java new file mode 100644 index 0000000..5073e13 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ClientVersionPanel.java @@ -0,0 +1,190 @@ +package com.ts3client.ui; + +import com.ts3client.config.ClientVersion; +import com.ts3client.config.Settings; + +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JTextField; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; + +/** + * Options page for the version and operating system reported to the server, which + * is what other clients see next to the nickname ("3.6.2 on Windows"). + * + *

Servers verify TeamSpeak's signature over the version and platform and refuse + * the connection ("client is modified") when it does not match, so the page is + * built around the signed versions in {@link ClientVersion#PRESETS}. The fields + * behind them can still be edited by hand for a version whose signature is known + * from somewhere else. Changes take effect on the next connect. + */ +final class ClientVersionPanel extends JPanel { + + /** Combo entry that leaves the three fields to the user. */ + private static final String CUSTOM = "Custom…"; + + private final Settings settings; + + private final JCheckBox enabled = new JCheckBox("Report a custom version and operating system"); + private final JComboBox presetCombo = new JComboBox<>(); + private final JTextField versionField = new JTextField(24); + private final JComboBox platformCombo = new JComboBox<>(); + private final JTextField signField = new JTextField(24); + + /** Set while a preset is being copied into the fields, so that does not count as an edit. */ + private boolean filling; + + ClientVersionPanel(Settings settings) { + super(new BorderLayout(0, 8)); + this.settings = settings; + + setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + add(buildForm(), BorderLayout.NORTH); + + load(); + enabled.addActionListener(e -> updateEnabled()); + updateEnabled(); + } + + /** Copies the edited version into the settings; the caller saves them. */ + void apply() { + settings.customVersion = enabled.isSelected(); + settings.clientVersion = versionField.getText().trim(); + settings.clientPlatform = String.valueOf(platformCombo.getEditor().getItem()).trim(); + settings.clientVersionSign = signField.getText().trim(); + } + + private JComponent buildForm() { + JPanel p = new JPanel(new GridBagLayout()); + GridBagConstraints c = new GridBagConstraints(); + c.insets = new Insets(2, 2, 2, 2); + c.anchor = GridBagConstraints.WEST; + c.fill = GridBagConstraints.HORIZONTAL; + + presetCombo.addItem(CUSTOM); + for (ClientVersion preset : ClientVersion.PRESETS) presetCombo.addItem(preset); + presetCombo.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean selected, boolean focus) { + super.getListCellRendererComponent(list, value, index, selected, focus); + setText(value instanceof ClientVersion v ? v.label() : String.valueOf(value)); + return this; + } + }); + presetCombo.setToolTipText("Versions with a signature TeamSpeak issued for them"); + presetCombo.addActionListener(e -> onPresetSelected()); + + platformCombo.setEditable(true); + for (String platform : ClientVersion.PLATFORMS) platformCombo.addItem(platform); + + versionField.setToolTipText("Version string, e.g. 3.6.2 [Build: 1695203293]"); + platformCombo.setToolTipText("Operating system shown to other clients"); + signField.setToolTipText("TeamSpeak's signature for this version and platform"); + versionField.getDocument().addDocumentListener(new EditListener()); + signField.getDocument().addDocumentListener(new EditListener()); + platformCombo.addActionListener(e -> onFieldEdited()); + + int row = 0; + c.gridx = 0; + c.gridy = row++; + c.gridwidth = 2; + p.add(enabled, c); + c.gridwidth = 1; + + addRow(p, c, row++, new JLabel("Version:"), presetCombo); + addRow(p, c, row++, new JLabel("Version string:"), versionField); + addRow(p, c, row++, new JLabel("Operating system:"), platformCombo); + addRow(p, c, row++, new JLabel("Signature:"), signField); + + c.gridx = 0; + c.gridy = row; + c.gridwidth = 2; + p.add(new JLabel("The three fields belong together: the " + + "signature is TeamSpeak's, over that version and operating system. A server " + + "answers \"client is modified\" and refuses the connection when it does not " + + "accept the version — made up, or older than the server's minimum — so prefer " + + "a recent one from the list. Takes effect the next time you connect." + + ""), c); + return p; + } + + private void load() { + enabled.setSelected(settings.customVersion); + ClientVersion preset = ClientVersion.match( + settings.clientVersion, settings.clientPlatform, settings.clientVersionSign); + fill(preset != null ? preset + : new ClientVersion("", settings.clientVersion, settings.clientPlatform, settings.clientVersionSign)); + presetCombo.setSelectedItem(preset != null ? preset : CUSTOM); + } + + private void onPresetSelected() { + if (presetCombo.getSelectedItem() instanceof ClientVersion preset) fill(preset); + } + + /** A hand-edited field no longer describes the selected preset. */ + private void onFieldEdited() { + if (filling) return; + ClientVersion preset = ClientVersion.match(versionField.getText().trim(), + String.valueOf(platformCombo.getEditor().getItem()).trim(), signField.getText().trim()); + presetCombo.setSelectedItem(preset != null ? preset : CUSTOM); + } + + private void fill(ClientVersion version) { + filling = true; + try { + versionField.setText(version.version()); + platformCombo.setSelectedItem(version.platform()); + platformCombo.getEditor().setItem(version.platform()); + signField.setText(version.sign()); + } finally { + filling = false; + } + } + + private void updateEnabled() { + boolean on = enabled.isSelected(); + presetCombo.setEnabled(on); + versionField.setEnabled(on); + platformCombo.setEnabled(on); + signField.setEnabled(on); + } + + /** Notices any typing in the version or signature field. */ + private final class EditListener implements javax.swing.event.DocumentListener { + @Override + public void insertUpdate(javax.swing.event.DocumentEvent e) { + onFieldEdited(); + } + + @Override + public void removeUpdate(javax.swing.event.DocumentEvent e) { + onFieldEdited(); + } + + @Override + public void changedUpdate(javax.swing.event.DocumentEvent e) { + onFieldEdited(); + } + } + + private static void addRow(JPanel p, GridBagConstraints c, int row, JLabel label, Component field) { + c.gridx = 0; + c.gridy = row; + c.weightx = 0; + p.add(label, c); + c.gridx = 1; + c.weightx = 1; + p.add(field, c); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java index 08f8236..911e216 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SettingsDialog.java @@ -57,6 +57,7 @@ public final class SettingsDialog extends JDialog { private NotificationsPanel notificationsPanel; private IconPackPanel iconPackPanel; + private ClientVersionPanel clientVersionPanel; private JComboBox inputCombo; private JComboBox outputCombo; @@ -107,6 +108,8 @@ public final class SettingsDialog extends JDialog { tabs.addTab("Notifications", notificationsPanel); iconPackPanel = new IconPackPanel(settings); tabs.addTab("Design", iconPackPanel); + clientVersionPanel = new ClientVersionPanel(settings); + tabs.addTab("Client Version", scrollable(clientVersionPanel)); JPanel buttons = new JPanel(new BorderLayout()); JPanel right = new JPanel(); @@ -551,6 +554,7 @@ public final class SettingsDialog extends JDialog { settings.music = musicCheck.isSelected(); notificationsPanel.apply(); iconPackPanel.apply(); + clientVersionPanel.apply(); settings.save(); if (liveMic != null) {