提交 827a3596 编写于 作者: S Sandeep Somavarapu

Fix #100835

上级 f62bef3b
......@@ -189,6 +189,13 @@ export interface IUserDataSyncBackupStoreService {
//#endregion
// #region User Data Sync Headers
export const HEADER_OPERATION_ID = 'x-operation-id';
export const HEADER_EXECUTION_ID = 'X-Execution-Id';
//#endregion
// #region User Data Sync Error
export enum UserDataSyncErrorCode {
......@@ -218,27 +225,21 @@ export enum UserDataSyncErrorCode {
export class UserDataSyncError extends Error {
constructor(message: string, public readonly code: UserDataSyncErrorCode, public readonly resource?: SyncResource) {
constructor(
message: string,
readonly code: UserDataSyncErrorCode,
readonly resource?: SyncResource,
readonly operationId?: string
) {
super(message);
this.name = `${this.code} (UserDataSyncError) ${this.resource || ''}`;
}
static toUserDataSyncError(error: Error): UserDataSyncError {
if (error instanceof UserDataSyncError) {
return error;
}
const match = /^(.+) \(UserDataSyncError\) (.+)?$/.exec(error.name);
if (match && match[1]) {
return new UserDataSyncError(error.message, <UserDataSyncErrorCode>match[1], <SyncResource>match[2]);
}
return new UserDataSyncError(error.message, UserDataSyncErrorCode.Unknown);
this.name = `${this.code} (UserDataSyncError) syncResource:${this.resource || 'unknown'} operationId:${this.operationId || 'unknown'}`;
}
}
export class UserDataSyncStoreError extends UserDataSyncError {
constructor(message: string, code: UserDataSyncErrorCode) {
super(message, code);
constructor(message: string, code: UserDataSyncErrorCode, readonly operationId: string | undefined) {
super(message, code, undefined, operationId);
}
}
......@@ -248,6 +249,23 @@ export class UserDataAutoSyncError extends UserDataSyncError {
}
}
export namespace UserDataSyncError {
export function toUserDataSyncError(error: Error): UserDataSyncError {
if (error instanceof UserDataSyncError) {
return error;
}
const match = /^(.+) \(UserDataSyncError\) syncResource:(.+) operationId:(.+)$/.exec(error.name);
if (match && match[1]) {
const syncResource = match[2] === 'unknown' ? undefined : match[2] as SyncResource;
const operationId = match[3] === 'unknown' ? undefined : match[3];
return new UserDataSyncError(error.message, <UserDataSyncErrorCode>match[1], syncResource, operationId);
}
return new UserDataSyncError(error.message, UserDataSyncErrorCode.Unknown);
}
}
//#endregion
// #region User Data Synchroniser
......
......@@ -3,7 +3,10 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IUserDataSyncService, SyncStatus, IUserDataSyncStoreService, SyncResource, IUserDataSyncLogService, IUserDataSynchroniser, UserDataSyncErrorCode, UserDataSyncError, ISyncResourceHandle, IUserDataManifest, ISyncTask, IResourcePreview, IManualSyncTask, ISyncResourcePreview } from 'vs/platform/userDataSync/common/userDataSync';
import {
IUserDataSyncService, SyncStatus, IUserDataSyncStoreService, SyncResource, IUserDataSyncLogService, IUserDataSynchroniser, UserDataSyncErrorCode,
UserDataSyncError, ISyncResourceHandle, IUserDataManifest, ISyncTask, IResourcePreview, IManualSyncTask, ISyncResourcePreview, HEADER_EXECUTION_ID
} from 'vs/platform/userDataSync/common/userDataSync';
import { Disposable } from 'vs/base/common/lifecycle';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { Emitter, Event } from 'vs/base/common/event';
......@@ -31,6 +34,12 @@ type SyncErrorClassification = {
const LAST_SYNC_TIME_KEY = 'sync.lastSyncTime';
function createSyncHeaders(executionId: string): IHeaders {
const headers: IHeaders = {};
headers[HEADER_EXECUTION_ID] = executionId;
return headers;
}
export class UserDataSyncService extends Disposable implements IUserDataSyncService {
_serviceBrand: any;
......@@ -133,7 +142,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ
const executionId = generateUuid();
let manifest: IUserDataManifest | null;
try {
manifest = await this.userDataSyncStoreService.manifest({ 'X-Execution-Id': executionId });
manifest = await this.userDataSyncStoreService.manifest(createSyncHeaders(executionId));
} catch (error) {
if (error instanceof UserDataSyncError) {
this.telemetryService.publicLog2<{ resource?: string, executionId?: string }, SyncErrorClassification>(`sync/error/${error.code}`, { resource: error.resource, executionId });
......@@ -166,7 +175,8 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ
await this.checkEnablement();
const executionId = generateUuid();
const syncHeaders: IHeaders = { 'X-Execution-Id': executionId };
const syncHeaders = createSyncHeaders(executionId);
let manifest: IUserDataManifest | null;
try {
manifest = await this.userDataSyncStoreService.manifest(syncHeaders);
......@@ -200,7 +210,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ
this.setStatus(SyncStatus.Syncing);
}
const syncHeaders: IHeaders = { 'X-Execution-Id': executionId };
const syncHeaders = createSyncHeaders(executionId);
for (const synchroniser of this.synchronisers) {
// Return if cancellation is requested
......@@ -257,7 +267,7 @@ export class UserDataSyncService extends Disposable implements IUserDataSyncServ
async acceptPreviewContent(syncResource: SyncResource, resource: URI, content: string, executionId: string = generateUuid()): Promise<void> {
await this.checkEnablement();
const synchroniser = this.getSynchroniser(syncResource);
await synchroniser.acceptPreviewContent(resource, content, false, { 'X-Execution-Id': executionId });
await synchroniser.acceptPreviewContent(resource, content, false, createSyncHeaders(executionId));
}
async resolveContent(resource: URI): Promise<string | null> {
......
......@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { Disposable, } from 'vs/base/common/lifecycle';
import { IUserData, IUserDataSyncStoreService, UserDataSyncErrorCode, IUserDataSyncStore, getUserDataSyncStore, ServerResource, UserDataSyncStoreError, IUserDataSyncLogService, IUserDataManifest, IResourceRefHandle } from 'vs/platform/userDataSync/common/userDataSync';
import { IUserData, IUserDataSyncStoreService, UserDataSyncErrorCode, IUserDataSyncStore, getUserDataSyncStore, ServerResource, UserDataSyncStoreError, IUserDataSyncLogService, IUserDataManifest, IResourceRefHandle, HEADER_OPERATION_ID, HEADER_EXECUTION_ID } from 'vs/platform/userDataSync/common/userDataSync';
import { IRequestService, asText, isSuccess, asJson } from 'vs/platform/request/common/request';
import { joinPath, relativePath } from 'vs/base/common/resources';
import { CancellationToken } from 'vs/base/common/cancellation';
......@@ -65,7 +65,7 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
});
/* A requests session that limits requests per sessions */
this.session = new RequestsSession(REQUEST_SESSION_LIMIT, REQUEST_SESSION_INTERVAL, this.requestService);
this.session = new RequestsSession(REQUEST_SESSION_LIMIT, REQUEST_SESSION_INTERVAL, this.requestService, this.logService);
}
setAuthToken(token: string, type: string): void {
......@@ -83,7 +83,7 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
const context = await this.request({ type: 'GET', url: uri.toString(), headers }, CancellationToken.None);
if (!isSuccess(context)) {
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown);
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown, context.res.headers[HEADER_OPERATION_ID]);
}
const result = await asJson<{ url: string, created: number }[]>(context) || [];
......@@ -102,7 +102,7 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
const context = await this.request({ type: 'GET', url, headers }, CancellationToken.None);
if (!isSuccess(context)) {
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown);
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown, context.res.headers[HEADER_OPERATION_ID]);
}
const content = await asText(context);
......@@ -120,7 +120,7 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
const context = await this.request({ type: 'DELETE', url, headers }, CancellationToken.None);
if (!isSuccess(context)) {
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown);
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown, context.res.headers[HEADER_OPERATION_ID]);
}
}
......@@ -145,12 +145,12 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
}
if (!isSuccess(context)) {
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown);
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown, context.res.headers[HEADER_OPERATION_ID]);
}
const ref = context.res.headers['etag'];
if (!ref) {
throw new UserDataSyncStoreError('Server did not return the ref', UserDataSyncErrorCode.NoRef);
throw new UserDataSyncStoreError('Server did not return the ref', UserDataSyncErrorCode.NoRef, context.res.headers[HEADER_OPERATION_ID]);
}
const content = await asText(context);
return { ref, content };
......@@ -171,12 +171,12 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
const context = await this.request({ type: 'POST', url, data, headers }, CancellationToken.None);
if (!isSuccess(context)) {
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown);
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown, context.res.headers[HEADER_OPERATION_ID]);
}
const newRef = context.res.headers['etag'];
if (!newRef) {
throw new UserDataSyncStoreError('Server did not return the ref', UserDataSyncErrorCode.NoRef);
throw new UserDataSyncStoreError('Server did not return the ref', UserDataSyncErrorCode.NoRef, context.res.headers[HEADER_OPERATION_ID]);
}
return newRef;
}
......@@ -192,7 +192,7 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
const context = await this.request({ type: 'GET', url, headers }, CancellationToken.None);
if (!isSuccess(context)) {
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown);
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown, context.res.headers[HEADER_OPERATION_ID]);
}
const manifest = await asJson<IUserDataManifest>(context);
......@@ -227,7 +227,7 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
const context = await this.request({ type: 'DELETE', url, headers }, CancellationToken.None);
if (!isSuccess(context)) {
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown);
throw new UserDataSyncStoreError('Server returned ' + context.res.statusCode, UserDataSyncErrorCode.Unknown, context.res.headers[HEADER_OPERATION_ID]);
}
// clear cached session.
......@@ -241,7 +241,7 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
private async request(options: IRequestOptions, token: CancellationToken): Promise<IRequestContext> {
if (!this.authToken) {
throw new UserDataSyncStoreError('No Auth Token Available', UserDataSyncErrorCode.Unauthorized);
throw new UserDataSyncStoreError('No Auth Token Available', UserDataSyncErrorCode.Unauthorized, undefined);
}
const commonHeaders = await this.commonHeadersPromise;
......@@ -258,40 +258,48 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
let context;
try {
context = await this.session.request(options, token);
this.logService.trace('Request finished', { url: options.url, status: context.res.statusCode });
} catch (e) {
if (!(e instanceof UserDataSyncStoreError)) {
e = new UserDataSyncStoreError(`Connection refused for the request '${options.url?.toString()}'.`, UserDataSyncErrorCode.ConnectionRefused);
e = new UserDataSyncStoreError(`Connection refused for the request '${options.url?.toString()}'.`, UserDataSyncErrorCode.ConnectionRefused, undefined);
}
this.logService.info('Request failed', options.url);
throw e;
}
const operationId = context.res.headers[HEADER_OPERATION_ID];
const requestInfo = { url: options.url, status: context.res.statusCode, 'execution-id': options.headers[HEADER_EXECUTION_ID], 'operation-id': operationId };
if (isSuccess(context)) {
this.logService.trace('Request succeeded', requestInfo);
} else {
this.logService.info('Request failed', requestInfo);
}
if (context.res.statusCode === 401) {
this.authToken = undefined;
this._onTokenFailed.fire();
throw new UserDataSyncStoreError(`Request '${options.url?.toString()}' failed because of Unauthorized (401).`, UserDataSyncErrorCode.Unauthorized);
throw new UserDataSyncStoreError(`Request '${options.url?.toString()}' failed because of Unauthorized (401).`, UserDataSyncErrorCode.Unauthorized, operationId);
}
this._onTokenSucceed.fire();
if (context.res.statusCode === 410) {
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because the requested resource is not longer available (410).`, UserDataSyncErrorCode.Gone);
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because the requested resource is not longer available (410).`, UserDataSyncErrorCode.Gone, operationId);
}
if (context.res.statusCode === 412) {
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because of Precondition Failed (412). There is new data exists for this resource. Make the request again with latest data.`, UserDataSyncErrorCode.PreconditionFailed);
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because of Precondition Failed (412). There is new data exists for this resource. Make the request again with latest data.`, UserDataSyncErrorCode.PreconditionFailed, operationId);
}
if (context.res.statusCode === 413) {
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because of too large payload (413).`, UserDataSyncErrorCode.TooLarge);
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because of too large payload (413).`, UserDataSyncErrorCode.TooLarge, operationId);
}
if (context.res.statusCode === 426) {
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed with status Upgrade Required (426). Please upgrade the client and try again.`, UserDataSyncErrorCode.UpgradeRequired);
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed with status Upgrade Required (426). Please upgrade the client and try again.`, UserDataSyncErrorCode.UpgradeRequired, operationId);
}
if (context.res.statusCode === 429) {
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because of too many requests (429).`, UserDataSyncErrorCode.TooManyRequests);
throw new UserDataSyncStoreError(`${options.type} request '${options.url?.toString()}' failed because of too many requests (429).`, UserDataSyncErrorCode.TooManyRequests, operationId);
}
return context;
......@@ -315,13 +323,14 @@ export class UserDataSyncStoreService extends Disposable implements IUserDataSyn
export class RequestsSession {
private count: number = 0;
private requests: string[] = [];
private startTime: Date | undefined = undefined;
constructor(
private readonly limit: number,
private readonly interval: number, /* in ms */
private readonly requestService: IRequestService,
private readonly logService: IUserDataSyncLogService,
) { }
request(options: IRequestOptions, token: CancellationToken): Promise<IRequestContext> {
......@@ -329,12 +338,13 @@ export class RequestsSession {
this.reset();
}
if (this.count >= this.limit) {
throw new UserDataSyncStoreError(`Too many requests. Allowed only ${this.limit} requests in ${this.interval / (1000 * 60)} minutes.`, UserDataSyncErrorCode.LocalTooManyRequests);
if (this.requests.length >= this.limit) {
this.logService.info('Too many requests', ...this.requests);
throw new UserDataSyncStoreError(`Too many requests. Allowed only ${this.limit} requests in ${this.interval / (1000 * 60)} minutes.`, UserDataSyncErrorCode.LocalTooManyRequests, undefined);
}
this.startTime = this.startTime || new Date();
this.count++;
this.requests.push(options.url!);
return this.requestService.request(options, token);
}
......@@ -344,7 +354,7 @@ export class RequestsSession {
}
private reset(): void {
this.count = 0;
this.requests = [];
this.startTime = undefined;
}
......
......@@ -14,6 +14,7 @@ import { CancellationToken } from 'vs/base/common/cancellation';
import { IRequestService } from 'vs/platform/request/common/request';
import { newWriteableBufferStream } from 'vs/base/common/buffer';
import { timeout } from 'vs/base/common/async';
import { NullLogService } from 'vs/platform/log/common/log';
suite('UserDataSyncStoreService', () => {
......@@ -332,7 +333,7 @@ suite('UserDataSyncRequestsSession', () => {
};
test('too many requests are thrown when limit exceeded', async () => {
const testObject = new RequestsSession(1, 500, requestService);
const testObject = new RequestsSession(1, 500, requestService, new NullLogService());
await testObject.request({}, CancellationToken.None);
try {
......@@ -346,14 +347,14 @@ suite('UserDataSyncRequestsSession', () => {
});
test('requests are handled after session is expired', async () => {
const testObject = new RequestsSession(1, 500, requestService);
const testObject = new RequestsSession(1, 500, requestService, new NullLogService());
await testObject.request({}, CancellationToken.None);
await timeout(600);
await testObject.request({}, CancellationToken.None);
});
test('too many requests are thrown after session is expired', async () => {
const testObject = new RequestsSession(1, 500, requestService);
const testObject = new RequestsSession(1, 500, requestService, new NullLogService());
await testObject.request({}, CancellationToken.None);
await timeout(600);
await testObject.request({}, CancellationToken.None);
......
......@@ -27,9 +27,11 @@ class UserDataSyncReportIssueContribution extends Disposable implements IWorkben
switch (error.code) {
case UserDataSyncErrorCode.LocalTooManyRequests:
case UserDataSyncErrorCode.TooManyRequests:
const operationId = error.operationId ? localize('operationId', "Operation Id: {0}", error.operationId) : undefined;
const message = localize('too many requests', "Turned off syncing preferences on this device because it is making too many requests.");
this.notificationService.notify({
severity: Severity.Error,
message: localize('too many requests', "Turned off syncing preferences on this device because it is making too many requests."),
message: operationId ? `${message} ${operationId}` : message,
});
return;
}
......
......@@ -291,25 +291,28 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
if (error.resource === SyncResource.Keybindings || error.resource === SyncResource.Settings) {
this.disableSync(error.resource);
const sourceArea = getSyncAreaLabel(error.resource);
this.handleTooLargeError(error.resource, localize('too large', "Disabled syncing {0} because size of the {1} file to sync is larger than {2}. Please open the file and reduce the size and enable sync", sourceArea.toLowerCase(), sourceArea.toLowerCase(), '100kb'));
this.handleTooLargeError(error.resource, localize('too large', "Disabled syncing {0} because size of the {1} file to sync is larger than {2}. Please open the file and reduce the size and enable sync", sourceArea.toLowerCase(), sourceArea.toLowerCase(), '100kb'), error);
}
break;
case UserDataSyncErrorCode.Incompatible:
case UserDataSyncErrorCode.Gone:
case UserDataSyncErrorCode.UpgradeRequired:
this.userDataSyncWorkbenchService.turnoff(false);
const message = localize('error upgrade required', "Preferences sync is disabled because the current version ({0}, {1}) is not compatible with the sync service. Please update before turning on sync.", this.productService.version, this.productService.commit);
const operationId = error.operationId ? localize('operationId', "Operation Id: {0}", error.operationId) : undefined;
this.notificationService.notify({
severity: Severity.Error,
message: localize('error upgrade required', "Preferences sync is disabled because the current version ({0}, {1}) is not compatible with the sync service. Please update before turning on sync.", this.productService.version, this.productService.commit),
message: operationId ? `${message} ${operationId}` : message,
});
break;
}
}
private handleTooLargeError(resource: SyncResource, message: string): void {
private handleTooLargeError(resource: SyncResource, message: string, error: UserDataSyncError): void {
const operationId = error.operationId ? localize('operationId', "Operation Id: {0}", error.operationId) : undefined;
this.notificationService.notify({
severity: Severity.Error,
message,
message: operationId ? `${message} ${operationId}` : message,
actions: {
primary: [new Action('open sync file', localize('open file', "Open {0} File", getSyncAreaLabel(resource)), undefined, true,
() => resource === SyncResource.Settings ? this.preferencesService.openGlobalSettings(true) : this.preferencesService.openGlobalKeybindingSettings(true))]
......@@ -432,16 +435,18 @@ export class UserDataSyncWorkbenchContribution extends Disposable implements IWo
switch (e.code) {
case UserDataSyncErrorCode.TooLarge:
if (e.resource === SyncResource.Keybindings || e.resource === SyncResource.Settings) {
this.handleTooLargeError(e.resource, localize('too large while starting sync', "Preferences sync cannot be turned on because size of the {0} file to sync is larger than {1}. Please open the file and reduce the size and turn on sync", getSyncAreaLabel(e.resource).toLowerCase(), '100kb'));
this.handleTooLargeError(e.resource, localize('too large while starting sync', "Preferences sync cannot be turned on because size of the {0} file to sync is larger than {1}. Please open the file and reduce the size and turn on sync", getSyncAreaLabel(e.resource).toLowerCase(), '100kb'), e);
return;
}
break;
case UserDataSyncErrorCode.Incompatible:
case UserDataSyncErrorCode.Gone:
case UserDataSyncErrorCode.UpgradeRequired:
const message = localize('error upgrade required while starting sync', "Preferences sync cannot be turned on because the current version ({0}, {1}) is not compatible with the sync service. Please update before turning on sync.", this.productService.version, this.productService.commit);
const operationId = e.operationId ? localize('operationId', "Operation Id: {0}", e.operationId) : undefined;
this.notificationService.notify({
severity: Severity.Error,
message: localize('error upgrade required while starting sync', "Preferences sync cannot be turned on because the current version ({0}, {1}) is not compatible with the sync service. Please update before turning on sync.", this.productService.version, this.productService.commit),
message: operationId ? `${message} ${operationId}` : message,
});
return;
}
......
......@@ -48,9 +48,12 @@ class UserDataSyncReportIssueContribution extends Disposable implements IWorkben
switch (error.code) {
case UserDataSyncErrorCode.LocalTooManyRequests:
case UserDataSyncErrorCode.TooManyRequests:
const operationId = error.operationId ? localize('operationId', "Operation Id: {0}", error.operationId) : undefined;
const message = localize({ key: 'too many requests', comment: ['Preferences Sync is the name of the feature'] }, "Preferences sync is disabled because the current device is making too many requests. Please report an issue by providing the sync logs.");
this.notificationService.notify({
severity: Severity.Error,
message: localize({ key: 'too many requests', comment: ['Preferences Sync is the name of the feature'] }, "Preferences sync is disabled because the current device is making too many requests. Please report an issue by providing the sync logs."),
message: operationId ? `${message} ${operationId}` : message,
source: error.operationId ? localize('preferences sync', "Preferences Sync. Operation Id: {0}", error.operationId) : undefined,
actions: {
primary: [
new Action('Show Sync Logs', localize('show sync logs', "Show Log"), undefined, true, () => this.commandService.executeCommand(SHOW_SYNC_LOG_COMMAND_ID)),
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册