mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore(chromium): remove CRTarget, use CRPage and CRServiceWorker instead (#1436)
This commit is contained in:
parent
5a93872063
commit
7ef394b345
@ -27,9 +27,9 @@ import * as types from '../types';
|
||||
import { ConnectionEvents, CRConnection, CRSession } from './crConnection';
|
||||
import { CRPage } from './crPage';
|
||||
import { readProtocolStream } from './crProtocolHelper';
|
||||
import { CRTarget } from './crTarget';
|
||||
import { Events } from './events';
|
||||
import { Protocol } from './protocol';
|
||||
import { CRExecutionContext } from './crExecutionContext';
|
||||
|
||||
export class CRBrowser extends platform.EventEmitter implements Browser {
|
||||
readonly _connection: CRConnection;
|
||||
@ -37,7 +37,9 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
|
||||
private _clientRootSessionPromise: Promise<CRSession> | null = null;
|
||||
readonly _defaultContext: CRBrowserContext;
|
||||
readonly _contexts = new Map<string, CRBrowserContext>();
|
||||
_targets = new Map<string, CRTarget>();
|
||||
_crPages = new Map<string, CRPage>();
|
||||
_backgroundPages = new Map<string, CRPage>();
|
||||
_serviceWorkers = new Map<string, CRServiceWorker>();
|
||||
readonly _firstPagePromise: Promise<void>;
|
||||
private _firstPageCallback = () => {};
|
||||
|
||||
@ -59,7 +61,7 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
|
||||
// First page and background pages in the persistent context are created automatically
|
||||
// and may be initialized before we enable auto-attach.
|
||||
function attachToExistingPage({targetInfo}: Protocol.Target.targetCreatedPayload) {
|
||||
if (!CRTarget.isPageType(targetInfo.type))
|
||||
if (targetInfo.type !== 'page' && targetInfo.type !== 'background_page')
|
||||
return;
|
||||
existingPageAttachPromises.push(session.send('Target.attachToTarget', {targetId: targetInfo.targetId, flatten: true}));
|
||||
}
|
||||
@ -106,65 +108,79 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
|
||||
|
||||
_onAttachedToTarget({targetInfo, sessionId, waitingForDebugger}: Protocol.Target.attachedToTargetPayload) {
|
||||
const session = this._connection.session(sessionId)!;
|
||||
if (!CRTarget.isPageType(targetInfo.type) && targetInfo.type !== 'service_worker') {
|
||||
assert(targetInfo.type === 'browser' || targetInfo.type === 'other');
|
||||
if (waitingForDebugger) {
|
||||
// Ideally, detaching should resume any target, but there is a bug in the backend.
|
||||
session.send('Runtime.runIfWaitingForDebugger').catch(debugError).then(() => {
|
||||
this._session.send('Target.detachFromTarget', { sessionId }).catch(debugError);
|
||||
});
|
||||
}
|
||||
const context = (targetInfo.browserContextId && this._contexts.has(targetInfo.browserContextId)) ?
|
||||
this._contexts.get(targetInfo.browserContextId)! : this._defaultContext;
|
||||
|
||||
assert(!this._crPages.has(targetInfo.targetId), 'Duplicate target ' + targetInfo.targetId);
|
||||
assert(!this._backgroundPages.has(targetInfo.targetId), 'Duplicate target ' + targetInfo.targetId);
|
||||
assert(!this._serviceWorkers.has(targetInfo.targetId), 'Duplicate target ' + targetInfo.targetId);
|
||||
|
||||
if (targetInfo.type === 'background_page') {
|
||||
const backgroundPage = new CRPage(session, targetInfo.targetId, context, null);
|
||||
this._backgroundPages.set(targetInfo.targetId, backgroundPage);
|
||||
backgroundPage.pageOrError().then(() => {
|
||||
context.emit(Events.CRBrowserContext.BackgroundPage, backgroundPage._page);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { context, target } = this._createTarget(targetInfo, session);
|
||||
|
||||
if (CRTarget.isPageType(targetInfo.type)) {
|
||||
target.pageOrError().then(async () => {
|
||||
const page = target._crPage!.page();
|
||||
if (targetInfo.type === 'page') {
|
||||
this._firstPageCallback();
|
||||
context.emit(CommonEvents.BrowserContext.Page, page);
|
||||
const opener = target.opener();
|
||||
if (!opener)
|
||||
return;
|
||||
// Opener page must have been initialized already and resumed in order to
|
||||
// create this popup but there is a chance that not all responses have been
|
||||
// received yet so we cannot use opener._crPage?.page()
|
||||
const openerPage = await opener.pageOrError();
|
||||
if (openerPage instanceof Page && !openerPage.isClosed())
|
||||
openerPage.emit(CommonEvents.Page.Popup, page);
|
||||
} else if (targetInfo.type === 'background_page') {
|
||||
context.emit(Events.CRBrowserContext.BackgroundPage, page);
|
||||
if (targetInfo.type === 'page') {
|
||||
const opener = targetInfo.openerId ? this._crPages.get(targetInfo.openerId) || null : null;
|
||||
const crPage = new CRPage(session, targetInfo.targetId, context, opener);
|
||||
this._crPages.set(targetInfo.targetId, crPage);
|
||||
crPage.pageOrError().then(() => {
|
||||
this._firstPageCallback();
|
||||
context.emit(CommonEvents.BrowserContext.Page, crPage._page);
|
||||
if (opener) {
|
||||
opener.pageOrError().then(openerPage => {
|
||||
if (openerPage instanceof Page && !openerPage.isClosed())
|
||||
openerPage.emit(CommonEvents.Page.Popup, crPage._page);
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
assert(targetInfo.type === 'service_worker');
|
||||
target.serviceWorkerOrError().then(workerOrError => {
|
||||
if (workerOrError instanceof Worker)
|
||||
context.emit(Events.CRBrowserContext.ServiceWorker, workerOrError);
|
||||
});
|
||||
}
|
||||
|
||||
private _createTarget(targetInfo: Protocol.Target.TargetInfo, session: CRSession) {
|
||||
const {browserContextId} = targetInfo;
|
||||
const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId)! : this._defaultContext;
|
||||
const target = new CRTarget(this, targetInfo, context, session);
|
||||
assert(!this._targets.has(targetInfo.targetId), 'Target should not exist before targetCreated');
|
||||
this._targets.set(targetInfo.targetId, target);
|
||||
return { context, target };
|
||||
}
|
||||
|
||||
_onDetachedFromTarget({targetId}: Protocol.Target.detachFromTargetParameters) {
|
||||
const target = this._targets.get(targetId!)!;
|
||||
if (!target)
|
||||
if (targetInfo.type === 'service_worker') {
|
||||
const serviceWorker = new CRServiceWorker(context, session, targetInfo.url);
|
||||
this._serviceWorkers.set(targetInfo.targetId, serviceWorker);
|
||||
context.emit(Events.CRBrowserContext.ServiceWorker, serviceWorker);
|
||||
return;
|
||||
this._targets.delete(targetId!);
|
||||
target._didClose();
|
||||
}
|
||||
|
||||
assert(targetInfo.type === 'browser' || targetInfo.type === 'other');
|
||||
if (waitingForDebugger) {
|
||||
// Ideally, detaching should resume any target, but there is a bug in the backend.
|
||||
session.send('Runtime.runIfWaitingForDebugger').catch(debugError).then(() => {
|
||||
this._session.send('Target.detachFromTarget', { sessionId }).catch(debugError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async _closePage(page: Page) {
|
||||
await this._session.send('Target.closeTarget', { targetId: CRTarget.fromPage(page)._targetId });
|
||||
_onDetachedFromTarget(payload: Protocol.Target.detachFromTargetParameters) {
|
||||
const targetId = payload.targetId!;
|
||||
const crPage = this._crPages.get(targetId);
|
||||
if (crPage) {
|
||||
this._crPages.delete(targetId);
|
||||
crPage.didClose();
|
||||
return;
|
||||
}
|
||||
const backgroundPage = this._backgroundPages.get(targetId);
|
||||
if (backgroundPage) {
|
||||
this._backgroundPages.delete(targetId);
|
||||
backgroundPage.didClose();
|
||||
return;
|
||||
}
|
||||
const serviceWorker = this._serviceWorkers.get(targetId);
|
||||
if (serviceWorker) {
|
||||
this._serviceWorkers.delete(targetId);
|
||||
serviceWorker.emit(CommonEvents.Worker.Close);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async _closePage(crPage: CRPage) {
|
||||
await this._session.send('Target.closeTarget', { targetId: crPage._targetId });
|
||||
}
|
||||
|
||||
async close() {
|
||||
@ -232,6 +248,21 @@ export class CRBrowser extends platform.EventEmitter implements Browser {
|
||||
}
|
||||
}
|
||||
|
||||
class CRServiceWorker extends Worker {
|
||||
readonly _browserContext: CRBrowserContext;
|
||||
|
||||
constructor(browserContext: CRBrowserContext, session: CRSession, url: string) {
|
||||
super(url);
|
||||
this._browserContext = browserContext;
|
||||
session.once('Runtime.executionContextCreated', event => {
|
||||
this._createExecutionContext(new CRExecutionContext(session, event.context));
|
||||
});
|
||||
// This might fail if the target is closed before we receive all execution contexts.
|
||||
session.send('Runtime.enable', {}).catch(e => {});
|
||||
session.send('Runtime.runIfWaitingForDebugger').catch(e => {});
|
||||
}
|
||||
}
|
||||
|
||||
export class CRBrowserContext extends BrowserContextBase {
|
||||
readonly _browser: CRBrowser;
|
||||
readonly _browserContextId: string | null;
|
||||
@ -253,19 +284,20 @@ export class CRBrowserContext extends BrowserContextBase {
|
||||
await this.setHTTPCredentials(this._options.httpCredentials);
|
||||
}
|
||||
|
||||
_targets(): CRTarget[] {
|
||||
return Array.from(this._browser._targets.values()).filter(target => target.context() === this);
|
||||
}
|
||||
|
||||
pages(): Page[] {
|
||||
return this._targets().filter(target => target.type() === 'page').map(target => target._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[];
|
||||
const result: Page[] = [];
|
||||
for (const crPage of this._browser._crPages.values()) {
|
||||
if (crPage._browserContext === this && crPage._initializedPage)
|
||||
result.push(crPage._initializedPage);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async newPage(): Promise<Page> {
|
||||
assertBrowserContextIsNotOwned(this);
|
||||
const { targetId } = await this._browser._session.send('Target.createTarget', { url: 'about:blank', browserContextId: this._browserContextId || undefined });
|
||||
const target = this._browser._targets.get(targetId)!;
|
||||
const result = await target.pageOrError();
|
||||
const crPage = this._browser._crPages.get(targetId)!;
|
||||
const result = await crPage.pageOrError();
|
||||
if (result instanceof Page) {
|
||||
if (result.isClosed())
|
||||
throw new Error('Page has been closed.');
|
||||
@ -392,15 +424,20 @@ export class CRBrowserContext extends BrowserContextBase {
|
||||
}
|
||||
|
||||
backgroundPages(): Page[] {
|
||||
return this._targets().filter(target => target.type() === 'background_page').map(target => target._initializedPage).filter(pageOrNull => !!pageOrNull) as Page[];
|
||||
const result: Page[] = [];
|
||||
for (const backgroundPage of this._browser._backgroundPages.values()) {
|
||||
if (backgroundPage._browserContext === this && backgroundPage._initializedPage)
|
||||
result.push(backgroundPage._initializedPage);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
serviceWorkers(): Worker[] {
|
||||
return this._targets().filter(target => target.type() === 'service_worker').map(target => target._initializedWorker).filter(workerOrNull => !!workerOrNull) as any as Worker[];
|
||||
return Array.from(this._browser._serviceWorkers.values()).filter(serviceWorker => serviceWorker._browserContext === this);
|
||||
}
|
||||
|
||||
async newCDPSession(page: Page): Promise<CRSession> {
|
||||
const targetId = CRTarget.fromPage(page)._targetId;
|
||||
const targetId = (page._delegate as CRPage)._targetId;
|
||||
const rootSession = await this._browser._clientRootSession();
|
||||
const { sessionId } = await rootSession.send('Target.attachToTarget', { targetId, flatten: true });
|
||||
return this._browser._connection.session(sessionId)!;
|
||||
|
||||
@ -23,7 +23,7 @@ import * as network from '../network';
|
||||
import * as frames from '../frames';
|
||||
import * as platform from '../platform';
|
||||
import { Credentials } from '../types';
|
||||
import { CRTarget } from './crTarget';
|
||||
import { CRPage } from './crPage';
|
||||
|
||||
export class CRNetworkManager {
|
||||
private _client: CRSession;
|
||||
@ -169,7 +169,7 @@ export class CRNetworkManager {
|
||||
let frame = event.frameId ? this._page._frameManager.frame(event.frameId) : workerFrame;
|
||||
|
||||
// Check if it's main resource request interception (targetId === main frame id).
|
||||
if (!frame && interceptionId && event.frameId === CRTarget.fromPage(this._page)._targetId) {
|
||||
if (!frame && interceptionId && event.frameId === (this._page._delegate as CRPage)._targetId) {
|
||||
// Main resource request for the page is being intercepted so the Frame is not created
|
||||
// yet. Precreate it here for the purposes of request interception. It will be updated
|
||||
// later as soon as the request contnues and we receive frame tree from the page.
|
||||
|
||||
@ -20,7 +20,7 @@ import * as js from '../javascript';
|
||||
import * as frames from '../frames';
|
||||
import { debugError, helper, RegisteredListener, assert } from '../helper';
|
||||
import * as network from '../network';
|
||||
import { CRSession, CRConnection } from './crConnection';
|
||||
import { CRSession, CRConnection, CRSessionEvents } from './crConnection';
|
||||
import { EVALUATION_SCRIPT_URL, CRExecutionContext } from './crExecutionContext';
|
||||
import { CRNetworkManager } from './crNetworkManager';
|
||||
import { Page, Worker, PageBinding } from '../page';
|
||||
@ -33,32 +33,35 @@ import { RawMouseImpl, RawKeyboardImpl } from './crInput';
|
||||
import { getAccessibilityTree } from './crAccessibility';
|
||||
import { CRCoverage } from './crCoverage';
|
||||
import { CRPDF } from './crPdf';
|
||||
import { CRBrowser, CRBrowserContext } from './crBrowser';
|
||||
import { CRBrowserContext } from './crBrowser';
|
||||
import * as types from '../types';
|
||||
import { ConsoleMessage } from '../console';
|
||||
import * as platform from '../platform';
|
||||
import { CRTarget } from './crTarget';
|
||||
|
||||
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
||||
|
||||
export class CRPage implements PageDelegate {
|
||||
readonly _client: CRSession;
|
||||
private readonly _page: Page;
|
||||
readonly _page: Page;
|
||||
readonly _networkManager: CRNetworkManager;
|
||||
private readonly _contextIdToContext = new Map<number, dom.FrameExecutionContext>();
|
||||
private _eventListeners: RegisteredListener[] = [];
|
||||
readonly rawMouse: RawMouseImpl;
|
||||
readonly rawKeyboard: RawKeyboardImpl;
|
||||
private readonly _browser: CRBrowser;
|
||||
readonly _targetId: string;
|
||||
private readonly _opener: CRPage | null;
|
||||
private readonly _pdf: CRPDF;
|
||||
private readonly _coverage: CRCoverage;
|
||||
private readonly _browserContext: CRBrowserContext;
|
||||
readonly _browserContext: CRBrowserContext;
|
||||
private _firstNonInitialNavigationCommittedPromise: Promise<void>;
|
||||
private _firstNonInitialNavigationCommittedCallback = () => {};
|
||||
private readonly _pagePromise: Promise<Page | Error>;
|
||||
_initializedPage: Page | null = null;
|
||||
|
||||
constructor(client: CRSession, browser: CRBrowser, browserContext: CRBrowserContext) {
|
||||
constructor(client: CRSession, targetId: string, browserContext: CRBrowserContext, opener: CRPage | null) {
|
||||
this._client = client;
|
||||
this._browser = browser;
|
||||
this._targetId = targetId;
|
||||
this._opener = opener;
|
||||
this.rawKeyboard = new RawKeyboardImpl(client);
|
||||
this.rawMouse = new RawMouseImpl(client);
|
||||
this._pdf = new CRPDF(client);
|
||||
@ -67,9 +70,15 @@ export class CRPage implements PageDelegate {
|
||||
this._page = new Page(this, browserContext);
|
||||
this._networkManager = new CRNetworkManager(client, this._page);
|
||||
this._firstNonInitialNavigationCommittedPromise = new Promise(f => this._firstNonInitialNavigationCommittedCallback = f);
|
||||
client.once(CRSessionEvents.Disconnected, () => this._page._didDisconnect());
|
||||
this._pagePromise = this._initialize().then(() => this._initializedPage = this._page).catch(e => e);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
async pageOrError(): Promise<Page | Error> {
|
||||
return this._pagePromise;
|
||||
}
|
||||
|
||||
private async _initialize() {
|
||||
let lifecycleEventsEnabled: Promise<any>;
|
||||
const promises: Promise<any>[] = [
|
||||
this._client.send('Page.enable'),
|
||||
@ -195,10 +204,6 @@ export class CRPage implements PageDelegate {
|
||||
this._handleFrameTree(child);
|
||||
}
|
||||
|
||||
page(): Page {
|
||||
return this._page;
|
||||
}
|
||||
|
||||
_onFrameAttached(frameId: string, parentFrameId: string | null) {
|
||||
this._page._frameManager.frameAttached(frameId, parentFrameId);
|
||||
}
|
||||
@ -402,10 +407,9 @@ export class CRPage implements PageDelegate {
|
||||
}
|
||||
|
||||
async opener(): Promise<Page | null> {
|
||||
const openerTarget = CRTarget.fromPage(this._page).opener();
|
||||
if (!openerTarget)
|
||||
if (!this._opener)
|
||||
return null;
|
||||
const openerPage = await openerTarget.pageOrError();
|
||||
const openerPage = await this._opener.pageOrError();
|
||||
if (openerPage instanceof Page && !openerPage.isClosed())
|
||||
return openerPage;
|
||||
return null;
|
||||
@ -440,7 +444,7 @@ export class CRPage implements PageDelegate {
|
||||
if (runBeforeUnload)
|
||||
await this._client.send('Page.close');
|
||||
else
|
||||
await this._browser._closePage(this._page);
|
||||
await this._browserContext._browser._closePage(this);
|
||||
}
|
||||
|
||||
canScreenshotOutsideViewport(): boolean {
|
||||
|
||||
@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { assert } from '../helper';
|
||||
import { Page, Worker } from '../page';
|
||||
import { CRBrowser, CRBrowserContext } from './crBrowser';
|
||||
import { CRSession, CRSessionEvents } from './crConnection';
|
||||
import { CRExecutionContext } from './crExecutionContext';
|
||||
import { CRPage } from './crPage';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
const targetSymbol = Symbol('target');
|
||||
|
||||
export class CRTarget {
|
||||
private readonly _targetInfo: Protocol.Target.TargetInfo;
|
||||
private readonly _browser: CRBrowser;
|
||||
private readonly _browserContext: CRBrowserContext;
|
||||
readonly _targetId: string;
|
||||
private readonly _pagePromise: Promise<Page | Error> | null = null;
|
||||
readonly _crPage: CRPage | null = null;
|
||||
_initializedPage: Page | null = null;
|
||||
private readonly _workerPromise: Promise<Worker | Error> | null = null;
|
||||
_initializedWorker: Worker | null = null;
|
||||
|
||||
static fromPage(page: Page): CRTarget {
|
||||
return (page as any)[targetSymbol];
|
||||
}
|
||||
|
||||
static isPageType(type: string): boolean {
|
||||
return type === 'page' || type === 'background_page';
|
||||
}
|
||||
|
||||
constructor(
|
||||
browser: CRBrowser,
|
||||
targetInfo: Protocol.Target.TargetInfo,
|
||||
browserContext: CRBrowserContext,
|
||||
session: CRSession) {
|
||||
this._targetInfo = targetInfo;
|
||||
this._browser = browser;
|
||||
this._browserContext = browserContext;
|
||||
this._targetId = targetInfo.targetId;
|
||||
if (CRTarget.isPageType(targetInfo.type)) {
|
||||
this._crPage = new CRPage(session, this._browser, this._browserContext);
|
||||
const page = this._crPage.page();
|
||||
(page as any)[targetSymbol] = this;
|
||||
session.once(CRSessionEvents.Disconnected, () => page._didDisconnect());
|
||||
this._pagePromise = this._crPage.initialize().then(() => this._initializedPage = page).catch(e => e);
|
||||
} else if (targetInfo.type === 'service_worker') {
|
||||
this._workerPromise = this._initializeServiceWorker(session);
|
||||
} else {
|
||||
assert(false, 'Unsupported target type: ' + targetInfo.type);
|
||||
}
|
||||
}
|
||||
|
||||
_didClose() {
|
||||
if (this._crPage)
|
||||
this._crPage.didClose();
|
||||
}
|
||||
|
||||
async pageOrError(): Promise<Page | Error> {
|
||||
if (CRTarget.isPageType(this.type()))
|
||||
return this._pagePromise!;
|
||||
throw new Error('Not a page.');
|
||||
}
|
||||
|
||||
private async _initializeServiceWorker(session: CRSession): Promise<Worker | Error> {
|
||||
const worker = new Worker(this._targetInfo.url);
|
||||
session.once('Runtime.executionContextCreated', event => {
|
||||
worker._createExecutionContext(new CRExecutionContext(session, event.context));
|
||||
});
|
||||
try {
|
||||
// This might fail if the target is closed before we receive all execution contexts.
|
||||
await Promise.all([
|
||||
session.send('Runtime.enable', {}),
|
||||
session.send('Runtime.runIfWaitingForDebugger'),
|
||||
]);
|
||||
this._initializedWorker = worker;
|
||||
return worker;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
serviceWorkerOrError(): Promise<Worker | Error> {
|
||||
if (this.type() === 'service_worker')
|
||||
return this._workerPromise!;
|
||||
throw new Error('Not a service worker.');
|
||||
}
|
||||
|
||||
type(): 'page' | 'background_page' | 'service_worker' | 'shared_worker' | 'other' | 'browser' {
|
||||
const type = this._targetInfo.type;
|
||||
if (type === 'page' || type === 'background_page' || type === 'service_worker' || type === 'shared_worker' || type === 'browser')
|
||||
return type;
|
||||
return 'other';
|
||||
}
|
||||
|
||||
context(): CRBrowserContext {
|
||||
return this._browserContext;
|
||||
}
|
||||
|
||||
opener(): CRTarget | null {
|
||||
const { openerId } = this._targetInfo;
|
||||
if (!openerId)
|
||||
return null;
|
||||
return this._browser._targets.get(openerId)!;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user