Updated MinecraftAuth to 3.0.0

This commit is contained in:
RaphiMC 2023-11-19 20:17:16 +01:00
parent b3855008d9
commit 98cef186ff
No known key found for this signature in database
GPG Key ID: 0F6BB0657A03AC94
11 changed files with 84 additions and 127 deletions

View File

@ -88,7 +88,7 @@ dependencies {
include("net.raphimc.netminecraft:all:2.3.7-SNAPSHOT") {
exclude group: "com.google.code.gson", module: "gson"
}
include("net.raphimc:MinecraftAuth:2.1.7-SNAPSHOT") {
include("net.raphimc:MinecraftAuth:3.0.0-SNAPSHOT") {
exclude group: "com.google.code.gson", module: "gson"
exclude group: "org.slf4j", module: "slf4j-api"
}

View File

@ -25,8 +25,8 @@ import com.viaversion.viaversion.api.minecraft.signature.storage.ChatSession1_19
import com.viaversion.viaversion.api.minecraft.signature.storage.ChatSession1_19_3;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import net.raphimc.mcauth.step.bedrock.StepMCChain;
import net.raphimc.mcauth.step.java.StepPlayerCertificates;
import net.raphimc.minecraftauth.step.bedrock.StepMCChain;
import net.raphimc.minecraftauth.step.java.StepPlayerCertificates;
import net.raphimc.netminecraft.packet.PacketTypes;
import net.raphimc.netminecraft.packet.impl.login.C2SLoginHelloPacket1_19_3;
import net.raphimc.netminecraft.packet.impl.login.C2SLoginHelloPacket1_20_2;
@ -66,29 +66,29 @@ public class ExternalInterface {
if (Options.CHAT_SIGNING && proxyConnection.getServerVersion().isNewerThanOrEqualTo(VersionEnum.r1_19) && account instanceof MicrosoftAccount microsoftAccount) {
final StepPlayerCertificates.PlayerCertificates playerCertificates = microsoftAccount.getPlayerCertificates();
final Instant expiresAt = Instant.ofEpochMilli(playerCertificates.expireTimeMs());
final long expiresAtMillis = playerCertificates.expireTimeMs();
final PublicKey publicKey = playerCertificates.publicKey();
final Instant expiresAt = Instant.ofEpochMilli(playerCertificates.getExpireTimeMs());
final long expiresAtMillis = playerCertificates.getExpireTimeMs();
final PublicKey publicKey = playerCertificates.getPublicKey();
final byte[] publicKeyBytes = publicKey.getEncoded();
final byte[] keySignature = playerCertificates.publicKeySignature();
final PrivateKey privateKey = playerCertificates.privateKey();
final byte[] keySignature = playerCertificates.getPublicKeySignature();
final PrivateKey privateKey = playerCertificates.getPrivateKey();
final UUID uuid = proxyConnection.getGameProfile().getId();
byte[] loginHelloKeySignature = keySignature;
if (proxyConnection.getClientVersion().equals(VersionEnum.r1_19)) {
loginHelloKeySignature = playerCertificates.legacyPublicKeySignature();
loginHelloKeySignature = playerCertificates.getLegacyPublicKeySignature();
}
proxyConnection.setLoginHelloPacket(new C2SLoginHelloPacket1_20_2(proxyConnection.getGameProfile().getName(), expiresAt, publicKey, loginHelloKeySignature, proxyConnection.getGameProfile().getId()));
user.put(new ChatSession1_19_0(uuid, privateKey, new ProfileKey(expiresAtMillis, publicKeyBytes, playerCertificates.legacyPublicKeySignature())));
user.put(new ChatSession1_19_0(uuid, privateKey, new ProfileKey(expiresAtMillis, publicKeyBytes, playerCertificates.getLegacyPublicKeySignature())));
user.put(new ChatSession1_19_1(uuid, privateKey, new ProfileKey(expiresAtMillis, publicKeyBytes, keySignature)));
user.put(new ChatSession1_19_3(uuid, privateKey, new ProfileKey(expiresAtMillis, publicKeyBytes, keySignature)));
} else if (proxyConnection.getServerVersion().equals(VersionEnum.bedrockLatest) && account instanceof BedrockAccount bedrockAccount) {
final StepMCChain.MCChain mcChain = bedrockAccount.getMcChain();
final UUID deviceId = mcChain.prevResult().initialXblSession().prevResult2().id();
final String playFabId = bedrockAccount.getPlayFabToken().playFabId();
user.put(new AuthChainData(mcChain.mojangJwt(), mcChain.identityJwt(), mcChain.publicKey(), mcChain.privateKey(), deviceId, playFabId));
final UUID deviceId = mcChain.getXblXsts().getInitialXblSession().getXblDeviceToken().getId();
final String playFabId = bedrockAccount.getPlayFabToken().getPlayFabId();
user.put(new AuthChainData(mcChain.getMojangJwt(), mcChain.getIdentityJwt(), mcChain.getPublicKey(), mcChain.getPrivateKey(), deviceId, playFabId));
}
}
@ -116,7 +116,7 @@ public class ExternalInterface {
}
} else if (proxyConnection.getUserOptions().account() instanceof MicrosoftAccount microsoftAccount) {
try {
AuthLibServices.SESSION_SERVICE.joinServer(microsoftAccount.getGameProfile(), microsoftAccount.getMcProfile().prevResult().prevResult().access_token(), serverIdHash);
AuthLibServices.SESSION_SERVICE.joinServer(microsoftAccount.getGameProfile(), microsoftAccount.getMcProfile().getMcToken().getAccessToken(), serverIdHash);
} catch (Throwable e) {
proxyConnection.kickClient("§cFailed to authenticate with Mojang servers! Please try again in a couple of seconds.");
}

View File

@ -21,7 +21,7 @@ import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.lenni0451.reflect.stream.RStream;
import net.raphimc.viaproxy.saves.impl.NewAccountsSave;
import net.raphimc.viaproxy.saves.impl.AccountsSaveV3;
import net.raphimc.viaproxy.saves.impl.UISave;
import net.raphimc.viaproxy.util.logging.Logger;
@ -34,7 +34,7 @@ public class SaveManager {
private static final File SAVE_FILE = new File("saves.json");
private static final Gson GSON = new Gson();
public final NewAccountsSave accountsSave = new NewAccountsSave();
public final AccountsSaveV3 accountsSave = new AccountsSaveV3();
public final UISave uiSave = new UISave();
public SaveManager() {

View File

@ -20,7 +20,7 @@ package net.raphimc.viaproxy.saves.impl;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import net.raphimc.mcauth.util.MicrosoftConstants;
import net.raphimc.minecraftauth.util.MicrosoftConstants;
import net.raphimc.viaproxy.ViaProxy;
import net.raphimc.viaproxy.saves.AbstractSave;
import net.raphimc.viaproxy.saves.impl.accounts.Account;
@ -31,12 +31,12 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class NewAccountsSave extends AbstractSave {
public class AccountsSaveV3 extends AbstractSave {
private List<Account> accounts = new ArrayList<>();
public NewAccountsSave() {
super("new_accounts");
public AccountsSaveV3() {
super("accountsV3");
}
@Override
@ -44,7 +44,7 @@ public class NewAccountsSave extends AbstractSave {
this.accounts = new ArrayList<>();
for (JsonElement element : jsonElement.getAsJsonArray()) {
final JsonObject jsonObject = element.getAsJsonObject();
final String type = jsonObject.get("account_type").getAsString();
final String type = jsonObject.get("accountType").getAsString();
final Class<?> clazz = Class.forName(type);
final Account account = (Account) clazz.getConstructor(JsonObject.class).newInstance(jsonObject);
this.accounts.add(account);
@ -56,7 +56,7 @@ public class NewAccountsSave extends AbstractSave {
final JsonArray array = new JsonArray();
for (Account account : this.accounts) {
final JsonObject jsonObject = account.toJson();
jsonObject.addProperty("account_type", account.getClass().getName());
jsonObject.addProperty("accountType", account.getClass().getName());
array.add(jsonObject);
}
return array;

View File

@ -18,60 +18,63 @@
package net.raphimc.viaproxy.saves.impl.accounts;
import com.google.gson.JsonObject;
import net.raphimc.mcauth.MinecraftAuth;
import net.raphimc.mcauth.step.bedrock.StepMCChain;
import net.raphimc.mcauth.step.bedrock.StepPlayFabToken;
import net.raphimc.viaproxy.util.logging.Logger;
import net.raphimc.minecraftauth.MinecraftAuth;
import net.raphimc.minecraftauth.step.AbstractStep;
import net.raphimc.minecraftauth.step.bedrock.StepMCChain;
import net.raphimc.minecraftauth.step.bedrock.StepPlayFabToken;
import net.raphimc.minecraftauth.step.bedrock.session.StepFullBedrockSession;
import net.raphimc.minecraftauth.step.xbl.StepXblXstsToken;
import net.raphimc.minecraftauth.util.MicrosoftConstants;
import org.apache.http.impl.client.CloseableHttpClient;
import java.util.UUID;
public class BedrockAccount extends Account {
private StepMCChain.MCChain mcChain;
private StepPlayFabToken.PlayFabToken playFabToken;
public static final AbstractStep<?, StepFullBedrockSession.FullBedrockSession> DEVICE_CODE_LOGIN = MinecraftAuth.builder()
.withClientId(MicrosoftConstants.BEDROCK_ANDROID_TITLE_ID).withScope(MicrosoftConstants.SCOPE_TITLE_AUTH)
.deviceCode()
.withDeviceToken("Android")
.sisuTitleAuthentication(MicrosoftConstants.BEDROCK_XSTS_RELYING_PARTY)
.buildMinecraftBedrockChainStep(true, true);
public BedrockAccount(final JsonObject jsonObject) throws Exception {
this.mcChain = MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.fromJson(jsonObject.getAsJsonObject("mc_chain"));
if (jsonObject.has("play_fab_token")) {
try {
this.playFabToken = MinecraftAuth.BEDROCK_PLAY_FAB_TOKEN.fromJson(jsonObject.getAsJsonObject("play_fab_token"));
} catch (Throwable e) {
Logger.LOGGER.warn("Failed to load PlayFab token for Bedrock account. It will be regenerated.", e);
}
}
private StepFullBedrockSession.FullBedrockSession bedrockSession;
public BedrockAccount(final JsonObject jsonObject) {
this.bedrockSession = DEVICE_CODE_LOGIN.fromJson(jsonObject.getAsJsonObject("bedrockSession"));
}
public BedrockAccount(final StepMCChain.MCChain mcChain) {
this.mcChain = mcChain;
public BedrockAccount(final StepFullBedrockSession.FullBedrockSession bedrockSession) {
this.bedrockSession = bedrockSession;
}
@Override
public JsonObject toJson() {
final JsonObject jsonObject = new JsonObject();
jsonObject.add("mc_chain", this.mcChain.toJson());
if (this.playFabToken != null) {
jsonObject.add("play_fab_token", this.playFabToken.toJson());
}
jsonObject.add("bedrockSession", DEVICE_CODE_LOGIN.toJson(this.bedrockSession));
return jsonObject;
}
@Override
public String getName() {
return this.mcChain.displayName();
return this.bedrockSession.getMcChain().getDisplayName();
}
@Override
public UUID getUUID() {
return this.mcChain.id();
return this.bedrockSession.getMcChain().getId();
}
public StepMCChain.MCChain getMcChain() {
return this.mcChain;
return this.bedrockSession.getMcChain();
}
public StepPlayFabToken.PlayFabToken getPlayFabToken() {
return this.playFabToken;
return this.bedrockSession.getPlayFabToken();
}
public StepXblXstsToken.XblXsts<?> getRealmsXsts() {
return this.bedrockSession.getRealmsXsts();
}
@Override
@ -83,18 +86,7 @@ public class BedrockAccount extends Account {
public boolean refresh(CloseableHttpClient httpClient) throws Exception {
if (!super.refresh(httpClient)) return false;
this.mcChain = MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.refresh(httpClient, this.mcChain);
try {
if (this.playFabToken == null) {
throw new NullPointerException();
}
this.playFabToken = MinecraftAuth.BEDROCK_PLAY_FAB_TOKEN.refresh(httpClient, this.playFabToken);
} catch (Throwable e) {
this.playFabToken = null;
this.playFabToken = MinecraftAuth.BEDROCK_PLAY_FAB_TOKEN.getFromInput(httpClient, this.mcChain.prevResult().fullXblSession());
}
this.bedrockSession = DEVICE_CODE_LOGIN.refresh(httpClient, this.bedrockSession);
return true;
}

View File

@ -18,60 +18,58 @@
package net.raphimc.viaproxy.saves.impl.accounts;
import com.google.gson.JsonObject;
import net.raphimc.mcauth.MinecraftAuth;
import net.raphimc.mcauth.step.java.StepMCProfile;
import net.raphimc.mcauth.step.java.StepPlayerCertificates;
import net.raphimc.viaproxy.util.logging.Logger;
import net.raphimc.minecraftauth.MinecraftAuth;
import net.raphimc.minecraftauth.step.AbstractStep;
import net.raphimc.minecraftauth.step.java.StepMCProfile;
import net.raphimc.minecraftauth.step.java.StepPlayerCertificates;
import net.raphimc.minecraftauth.step.java.session.StepFullJavaSession;
import net.raphimc.minecraftauth.util.MicrosoftConstants;
import org.apache.http.impl.client.CloseableHttpClient;
import java.util.UUID;
public class MicrosoftAccount extends Account {
private StepMCProfile.MCProfile mcProfile;
private StepPlayerCertificates.PlayerCertificates playerCertificates;
public static final AbstractStep<?, StepFullJavaSession.FullJavaSession> DEVICE_CODE_LOGIN = MinecraftAuth.builder()
.withClientId(MicrosoftConstants.JAVA_TITLE_ID).withScope(MicrosoftConstants.SCOPE_TITLE_AUTH)
.deviceCode()
.withDeviceToken("Win32")
.sisuTitleAuthentication(MicrosoftConstants.JAVA_XSTS_RELYING_PARTY)
.buildMinecraftJavaProfileStep(true);
public MicrosoftAccount(final JsonObject jsonObject) throws Throwable {
this.mcProfile = MinecraftAuth.JAVA_DEVICE_CODE_LOGIN.fromJson(jsonObject.getAsJsonObject("mc_profile"));
if (jsonObject.has("player_certificates")) {
try {
this.playerCertificates = MinecraftAuth.JAVA_PLAYER_CERTIFICATES.fromJson(jsonObject.getAsJsonObject("player_certificates"));
} catch (Throwable e) {
Logger.LOGGER.warn("Failed to load player certificates for Microsoft account. They will be regenerated.", e);
}
}
private StepFullJavaSession.FullJavaSession javaSession;
public MicrosoftAccount(final JsonObject jsonObject) {
this.javaSession = DEVICE_CODE_LOGIN.fromJson(jsonObject.getAsJsonObject("javaSession"));
}
public MicrosoftAccount(final StepMCProfile.MCProfile mcProfile) {
this.mcProfile = mcProfile;
public MicrosoftAccount(final StepFullJavaSession.FullJavaSession javaSession) {
this.javaSession = javaSession;
}
@Override
public JsonObject toJson() {
final JsonObject jsonObject = new JsonObject();
jsonObject.add("mc_profile", this.mcProfile.toJson());
if (this.playerCertificates != null) {
jsonObject.add("player_certificates", this.playerCertificates.toJson());
}
jsonObject.add("javaSession", DEVICE_CODE_LOGIN.toJson(this.javaSession));
return jsonObject;
}
@Override
public String getName() {
return this.mcProfile.name();
return this.javaSession.getMcProfile().getName();
}
@Override
public UUID getUUID() {
return this.mcProfile.id();
return this.javaSession.getMcProfile().getId();
}
public StepMCProfile.MCProfile getMcProfile() {
return this.mcProfile;
return this.javaSession.getMcProfile();
}
public StepPlayerCertificates.PlayerCertificates getPlayerCertificates() {
return this.playerCertificates;
return this.javaSession.getPlayerCertificates();
}
@Override
@ -83,18 +81,7 @@ public class MicrosoftAccount extends Account {
public boolean refresh(CloseableHttpClient httpClient) throws Exception {
if (!super.refresh(httpClient)) return false;
this.mcProfile = MinecraftAuth.JAVA_DEVICE_CODE_LOGIN.refresh(httpClient, this.mcProfile);
try {
if (this.playerCertificates == null) {
throw new NullPointerException();
}
this.playerCertificates = MinecraftAuth.JAVA_PLAYER_CERTIFICATES.refresh(httpClient, this.playerCertificates);
} catch (Throwable e) {
this.playerCertificates = null;
this.playerCertificates = MinecraftAuth.JAVA_PLAYER_CERTIFICATES.getFromInput(httpClient, this.mcProfile.prevResult().prevResult());
}
this.javaSession = DEVICE_CODE_LOGIN.refresh(httpClient, this.javaSession);
return true;
}

View File

@ -18,9 +18,8 @@
package net.raphimc.viaproxy.ui.impl;
import net.lenni0451.lambdaevents.EventHandler;
import net.raphimc.mcauth.MinecraftAuth;
import net.raphimc.mcauth.step.msa.StepMsaDeviceCode;
import net.raphimc.mcauth.util.MicrosoftConstants;
import net.raphimc.minecraftauth.step.msa.StepMsaDeviceCode;
import net.raphimc.minecraftauth.util.MicrosoftConstants;
import net.raphimc.viaproxy.ViaProxy;
import net.raphimc.viaproxy.cli.options.Options;
import net.raphimc.viaproxy.saves.impl.accounts.Account;
@ -181,7 +180,7 @@ public class AccountsTab extends AUITab {
this.addMicrosoftAccountButton.setEnabled(false);
this.handleLogin(msaDeviceCodeConsumer -> {
try (final CloseableHttpClient httpClient = MicrosoftConstants.createHttpClient()) {
return new MicrosoftAccount(MinecraftAuth.JAVA_DEVICE_CODE_LOGIN.getFromInput(httpClient, new StepMsaDeviceCode.MsaDeviceCodeCallback(msaDeviceCodeConsumer)));
return new MicrosoftAccount(MicrosoftAccount.DEVICE_CODE_LOGIN.getFromInput(httpClient, new StepMsaDeviceCode.MsaDeviceCodeCallback(msaDeviceCodeConsumer)));
}
});
});
@ -193,7 +192,7 @@ public class AccountsTab extends AUITab {
this.addBedrockAccountButton.setEnabled(false);
this.handleLogin(msaDeviceCodeConsumer -> {
try (final CloseableHttpClient httpClient = MicrosoftConstants.createHttpClient()) {
return new BedrockAccount(MinecraftAuth.BEDROCK_DEVICE_CODE_LOGIN.getFromInput(httpClient, new StepMsaDeviceCode.MsaDeviceCodeCallback(msaDeviceCodeConsumer)));
return new BedrockAccount(BedrockAccount.DEVICE_CODE_LOGIN.getFromInput(httpClient, new StepMsaDeviceCode.MsaDeviceCodeCallback(msaDeviceCodeConsumer)));
}
});
});

View File

@ -17,14 +17,13 @@
*/
package net.raphimc.viaproxy.ui.popups;
import net.raphimc.mcauth.step.msa.StepMsaDeviceCode;
import net.raphimc.minecraftauth.step.msa.StepMsaDeviceCode;
import net.raphimc.viaproxy.ui.I18n;
import net.raphimc.viaproxy.ui.ViaProxyUI;
import net.raphimc.viaproxy.util.GBC;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.StringSelection;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
@ -60,7 +59,7 @@ public class AddAccountPopup extends JDialog {
}
});
this.setTitle(I18n.get("popup.login_account.title"));
this.setSize(400, 200);
this.setSize(400, 140);
this.setResizable(false);
this.setLocationRelativeTo(this.parent);
}
@ -72,31 +71,17 @@ public class AddAccountPopup extends JDialog {
JLabel browserLabel = new JLabel("<html><p>" + I18n.get("popup.login_account.instructions.browser") + "</p></html>");
GBC.create(contentPane).grid(0, 0).weightx(1).insets(BORDER_PADDING, BORDER_PADDING, 0, BORDER_PADDING).fill(GridBagConstraints.HORIZONTAL).add(browserLabel);
JLabel urlLabel = new JLabel("<html><a href=\"\">" + this.deviceCode.verificationUri() + "</a></html>");
JLabel urlLabel = new JLabel("<html><a href=\"\">" + this.deviceCode.getDirectVerificationUri() + "</a></html>");
urlLabel.addMouseListener(new MouseAdapter() {
@Override
public void mouseReleased(MouseEvent e) {
AddAccountPopup.this.parent.openURL(AddAccountPopup.this.deviceCode.verificationUri());
AddAccountPopup.this.parent.openURL(AddAccountPopup.this.deviceCode.getDirectVerificationUri());
}
});
GBC.create(contentPane).grid(0, 1).weightx(1).insets(0, BORDER_PADDING, 0, BORDER_PADDING).fill(GridBagConstraints.HORIZONTAL).add(urlLabel);
JLabel enterCodeLabel = new JLabel("<html><p>" + I18n.get("popup.login_account.instructions.code") + "</p></html>");
GBC.create(contentPane).grid(0, 2).weightx(1).insets(BODY_BLOCK_PADDING, BORDER_PADDING, 0, BORDER_PADDING).fill(GridBagConstraints.HORIZONTAL).add(enterCodeLabel);
JLabel codeLabel = new JLabel(this.deviceCode.userCode());
GBC.create(contentPane).grid(0, 3).weightx(1).insets(0, BORDER_PADDING, 0, BORDER_PADDING).fill(GridBagConstraints.HORIZONTAL).add(codeLabel);
JLabel closeInfo = new JLabel("<html><p>" + I18n.get("popup.login_account.instructions.close") + "</p></html>");
GBC.create(contentPane).grid(0, 4).weightx(1).insets(BODY_BLOCK_PADDING, BORDER_PADDING, 0, BORDER_PADDING).fill(GridBagConstraints.HORIZONTAL).add(closeInfo);
}
{
JButton copyCodeButton = new JButton(I18n.get("popup.login_account.instructions.copy_code.label"));
copyCodeButton.addActionListener(event -> {
StringSelection selection = new StringSelection(this.deviceCode.userCode());
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, selection);
});
GBC.create(contentPane).grid(0, 5).weightx(1).insets(BORDER_PADDING, BORDER_PADDING, BORDER_PADDING, BORDER_PADDING).fill(GridBagConstraints.HORIZONTAL).add(copyCodeButton);
GBC.create(contentPane).grid(0, 2).weightx(1).insets(BODY_BLOCK_PADDING, BORDER_PADDING, 0, BORDER_PADDING).fill(GridBagConstraints.HORIZONTAL).add(closeInfo);
}
this.setContentPane(contentPane);
}

View File

@ -83,9 +83,7 @@ tab.ui_settings.language.success=Die Sprache wurde zu %s (%s) geändert. ViaProx
popup.login_account.title=Konto hinzufügen
popup.login_account.instructions.browser=Bitte öffne folgende URL in deinem Browser:
popup.login_account.instructions.code=Gib den folgenden Code ein:
popup.login_account.instructions.close=Das Popup schließt sich automatisch, nachdem du angemeldet wurdest.
popup.login_account.instructions.copy_code.label=Code kopieren
popup.download.title=Lade herunter...

View File

@ -83,9 +83,7 @@ tab.ui_settings.language.success=Language changed to %s (%s). ViaProxy will now
popup.login_account.title=Add Account
popup.login_account.instructions.browser=Please open the following URL in your browser:
popup.login_account.instructions.code=Enter the following code:
popup.login_account.instructions.close=The popup will close automatically after you have been logged in.
popup.login_account.instructions.copy_code.label=Copy code
popup.download.title=Downloading...

View File

@ -83,9 +83,7 @@ tab.ui_settings.language.success=语言已更改为%s%s。ViaProxy即将
popup.login_account.title=添加账户
popup.login_account.instructions.browser=请在浏览器中打开以下URL
popup.login_account.instructions.code=输入以下代码:
popup.login_account.instructions.close=登录后,弹窗将自动关闭。
popup.login_account.instructions.copy_code.label=复制代码
popup.download.title=正在下载...